From 2ada8f5a90b97fd45de5be0143429800b4e6abb2 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 15 Apr 2026 18:24:28 +0800 Subject: [PATCH] feat(tag): perf --- components/label/useRenderTag.ts | 430 +++++++++++++++++++++---------- 1 file changed, 293 insertions(+), 137 deletions(-) diff --git a/components/label/useRenderTag.ts b/components/label/useRenderTag.ts index e60ea48..05b966c 100644 --- a/components/label/useRenderTag.ts +++ b/components/label/useRenderTag.ts @@ -5,6 +5,145 @@ import { useBottomToolsStore } from "./useBottomToolsStore" import { usePaperStore } from "./usePaperStore" import { useTopToolsStore } from "./useTopToolsStore" +const TAG_FONT_SIZE = 12 +const TAG_VERTICAL_GAP = 8 +const TAG_TEXT_PADDING_X = 6 +const TAG_TEXT_PADDING_Y = 4 +const TAG_MAX_LINE_LENGTH = 18 + +const wrapLongToken = (token: string, maxLineLength: number) => { + if (token.length <= maxLineLength) return [token] + + const parts: string[] = [] + for (let index = 0; index < token.length; index += maxLineLength) { + parts.push(token.slice(index, index + maxLineLength)) + } + return parts +} + +const wrapTagContent = ( + content: string, + maxLineLength = TAG_MAX_LINE_LENGTH +) => { + const normalized = content.trim().replace(/\s+/g, " ") + if (!normalized) return "" + + const lines: string[] = [] + let currentLine = "" + + normalized.split(" ").forEach((token) => { + if (!token) return + + const wrappedTokens = wrapLongToken(token, maxLineLength) + wrappedTokens.forEach((wrappedToken) => { + const nextLine = currentLine + ? `${currentLine} ${wrappedToken}` + : wrappedToken + + if (nextLine.length <= maxLineLength) { + currentLine = nextLine + return + } + + if (currentLine) lines.push(currentLine) + currentLine = wrappedToken + }) + }) + + if (currentLine) lines.push(currentLine) + return lines.join("\n") +} + +const getTagAnchorPoint = (item: paper.Item) => { + const segmentPoints: paper.Point[] = [] + + if (item instanceof paper.CompoundPath) { + item.children.forEach((child) => { + if (!(child instanceof paper.Path)) return + child.segments.forEach((segment) => { + segmentPoints.push(segment.point.clone()) + }) + }) + } else if (item instanceof paper.Path) { + item.segments.forEach((segment) => { + segmentPoints.push(segment.point.clone()) + }) + } + + if (!segmentPoints.length) { + return new paper.Point(item.bounds.left, item.bounds.top) + } + + return segmentPoints.reduce((highestPoint, point) => + point.y < highestPoint.y ? point : highestPoint + ) +} + +const getTextMaskBounds = (textBounds: paper.Rectangle) => + new paper.Rectangle( + new paper.Point( + textBounds.left - TAG_TEXT_PADDING_X, + textBounds.top - TAG_TEXT_PADDING_Y + ), + new paper.Size( + textBounds.width + TAG_TEXT_PADDING_X * 2, + textBounds.height + TAG_TEXT_PADDING_Y * 2 + ) + ) + +const toPaperColor = (color: paper.Color | string | null | undefined) => { + if (color instanceof paper.Color) return color.clone() + if (!color) return null + + try { + return new paper.Color(color) + } catch { + return null + } +} + +const getTagBackgroundColor = ( + fillColor: paper.Color | string | null | undefined, + strokeColor: paper.Color | string | null | undefined +) => { + const resolvedColor = toPaperColor(strokeColor) ?? toPaperColor(fillColor) + if (!resolvedColor) return new paper.Color(1, 1, 1, 0.9) + + const tagBackgroundColor = resolvedColor.clone() + tagBackgroundColor.alpha = 0.9 + return tagBackgroundColor +} + +const getRelativeLuminance = ( + color: paper.Color | string | null | undefined +) => { + const resolvedColor = toPaperColor(color) + if (!resolvedColor) return 1 + + const red = resolvedColor.red + const green = resolvedColor.green + const blue = resolvedColor.blue + + const toLinear = (channel: number) => + channel <= 0.03928 + ? channel / 12.92 + : Math.pow((channel + 0.055) / 1.055, 2.4) + + return ( + 0.2126 * toLinear(red) + 0.7152 * toLinear(green) + 0.0722 * toLinear(blue) + ) +} + +const getContrastTextColor = ( + backgroundColor: paper.Color | string | null | undefined +) => { + const backgroundLuminance = getRelativeLuminance(backgroundColor) + const whiteContrast = 1.05 / (backgroundLuminance + 0.05) + const blackContrast = (backgroundLuminance + 0.05) / 0.05 + + return whiteContrast > blackContrast ? "#ffffff" : "#000000" +} + export const removeTagTexts = (id?: number) => { const group = usePaperStore.getState().group if (!group) return @@ -26,7 +165,7 @@ const useRenderTag = () => { Number.isFinite(group.scaling?.x) && group.scaling.x !== 0 ? group.scaling.x : 1 - const applyTagCompensation = ( + const applyPointTagCompensation = ( item: paper.Item, anchor: [number, number] ) => { @@ -37,146 +176,163 @@ const useRenderTag = () => { // 清空之前渲染的标签 removeTagTexts() - for (const [imageId, categoryMap] of dataMap) { - if (imageId === activeImage) { - for (const [categoryId, labelList] of categoryMap) { - let category = useTopToolsStore - .getState() - .objectOperations.find( - (item) => item.category_id.toString() === categoryId - )! - const { label_class } = category - labelList.forEach(([id, _d, detail]) => { - const path = usePaperStore.getState().getItemById(id) - if (path) { - const bounds = path.bounds - const tagAnchor: [number, number] = [bounds.left, bounds.top - 6] - const pos = [...tagAnchor] - let pathData = path.data - let content = `` - if (configs.includes("ID")) - content += `${id}${ - path.data.parentGroupId ? `@${path.data.parentGroupId}` : "" - }` - if (configs.includes("类别")) content += ` ${label_class}` + const categoryMap = dataMap.get(activeImage) + if (!categoryMap) return - if ( - detail.sub_attributes && - Object.keys(detail.sub_attributes).length - ) { - Object.keys(detail.sub_attributes).forEach((key) => { - if (configs.includes("子属性")) content += ` ${key}:` - if (configs.includes("属性值")) - content += `${detail.sub_attributes[key]};` - }) - } - // 文本 - const text = new paper.PointText(pos) - text.set({ - content, - fontSize: 12, - fontWeight: "bold", - fillColor: "black", - data: { - id, - type: "tag", - tag_category: "text", - tag_anchor: tagAnchor, - }, - parent: group, - visible, - }) - applyTagCompensation(text, tagAnchor) + for (const [categoryId, labelList] of categoryMap) { + let category = useTopToolsStore + .getState() + .objectOperations.find( + (item) => item.category_id.toString() === categoryId + )! + const { label_class } = category + labelList.forEach(([id, _d, detail]) => { + const path = usePaperStore.getState().getItemById(id) + if (!path) return - // 文本框 - const textMask = new paper.Path.Rectangle(text.bounds) - textMask.set({ - fillColor: pathData.fillColor, - strokeColor: pathData.strokeColor, - strokeWidth: 1, - strokeScaling: false, - data: { - id, - type: "tag", - tag_category: "text_mask", - tag_anchor: tagAnchor, - }, - parent: group, - visible, - }) - applyTagCompensation(textMask, tagAnchor) - // 标注对象外接框 - const mask = new paper.Path.Rectangle(bounds) - mask.set({ - strokeColor: pathData.strokeColor, - strokeWidth: 2, - strokeScaling: false, - data: { - id, - type: "tag", - tag_category: "mask", - }, - parent: group, - visible: visible && configs.includes("外框"), - }) - // 标注对象点id - if (path instanceof paper.CompoundPath) { - let index = 1 - path.children.forEach((child) => { - const segments = (child as paper.Path).segments - segments.forEach((segment) => { - const pointTextPosition: [number, number] = [ - segment.point.x - 10, - segment.point.y + 5, - ] - const pointText = new paper.PointText(pointTextPosition) - pointText.set({ - content: index, - fontSize: 12, - fontWeight: "bold", - fillColor: pathData.strokeColor, - data: { - id, - type: "tag", - tag_category: "point_text", - tag_anchor: pointTextPosition, - }, - parent: group, - visible: visible && configs.includes("点ID"), - }) - applyTagCompensation(pointText, pointTextPosition) - index++ - }) - }) - } else { - const segments = (path as paper.Path).segments - segments.forEach((segment, index) => { - const pointTextPosition: [number, number] = [ - segment.point.x - 10, - segment.point.y + 5, - ] - const pointText = new paper.PointText(pointTextPosition) - pointText.set({ - content: index + 1, - fontSize: 12, - fontWeight: "bold", - fillColor: pathData.strokeColor, - data: { - id, - type: "tag", - tag_category: "point_text", - tag_anchor: pointTextPosition, - }, - parent: group, - visible: visible && configs.includes("点ID"), - }) - applyTagCompensation(pointText, pointTextPosition) - }) - } - } + const bounds = path.bounds + const pathData = path.data + const anchorPoint = getTagAnchorPoint(path) + const tagAnchor: [number, number] = [anchorPoint.x, anchorPoint.y] + const tagBackgroundColor = getTagBackgroundColor( + pathData.fillColor, + pathData.strokeColor + ) + const tagTextColor = getContrastTextColor(tagBackgroundColor) + let content = `` + + if (configs.includes("ID")) { + content += `${id}${ + path.data.parentGroupId ? `@${path.data.parentGroupId}` : "" + }` + } + if (configs.includes("类别")) content += ` ${label_class}` + + if ( + detail.sub_attributes && + Object.keys(detail.sub_attributes).length + ) { + Object.keys(detail.sub_attributes).forEach((key) => { + if (configs.includes("子属性")) content += ` ${key}:` + if (configs.includes("属性值")) + content += `${detail.sub_attributes[key]};` }) } - } + + const wrappedContent = wrapTagContent(content) + if (wrappedContent) { + const text = new paper.PointText(anchorPoint) + text.set({ + content: wrappedContent, + fontSize: TAG_FONT_SIZE, + fontWeight: "bold", + fillColor: tagTextColor, + justification: "center", + data: { + id, + type: "tag", + tag_category: "text", + tag_anchor: tagAnchor, + }, + parent: group, + visible, + }) + text.translate( + new paper.Point( + anchorPoint.x - text.bounds.center.x, + anchorPoint.y - TAG_VERTICAL_GAP - text.bounds.bottom + ) + ) + + const textMask = new paper.Path.Rectangle( + getTextMaskBounds(text.bounds) + ) + textMask.set({ + fillColor: tagBackgroundColor.clone(), + strokeColor: pathData.strokeColor, + strokeWidth: 1, + strokeScaling: false, + data: { + id, + type: "tag", + tag_category: "text_mask", + tag_anchor: tagAnchor, + }, + parent: group, + visible, + }) + text.bringToFront() + } + + // 标注对象外接框 + const mask = new paper.Path.Rectangle(bounds) + mask.set({ + strokeColor: pathData.strokeColor, + strokeWidth: 2, + strokeScaling: false, + data: { + id, + type: "tag", + tag_category: "mask", + }, + parent: group, + visible: visible && configs.includes("外框"), + }) + // 标注对象点id + if (path instanceof paper.CompoundPath) { + let index = 1 + path.children.forEach((child) => { + const segments = (child as paper.Path).segments + segments.forEach((segment) => { + const pointTextPosition: [number, number] = [ + segment.point.x - 10, + segment.point.y + 5, + ] + const pointText = new paper.PointText(pointTextPosition) + pointText.set({ + content: index, + fontSize: 12, + fontWeight: "bold", + fillColor: pathData.strokeColor, + data: { + id, + type: "tag", + tag_category: "point_text", + tag_anchor: pointTextPosition, + }, + parent: group, + visible: visible && configs.includes("点ID"), + }) + applyPointTagCompensation(pointText, pointTextPosition) + index++ + }) + }) + } else { + const segments = (path as paper.Path).segments + segments.forEach((segment, index) => { + const pointTextPosition: [number, number] = [ + segment.point.x - 10, + segment.point.y + 5, + ] + const pointText = new paper.PointText(pointTextPosition) + pointText.set({ + content: index + 1, + fontSize: 12, + fontWeight: "bold", + fillColor: pathData.strokeColor, + data: { + id, + type: "tag", + tag_category: "point_text", + tag_anchor: pointTextPosition, + }, + parent: group, + visible: visible && configs.includes("点ID"), + }) + applyPointTagCompensation(pointText, pointTextPosition) + }) + } + }) } }, [])