import { debounce } from "../../utils/debounce"; import { wrapArray } from "../../utils/utilities"; import { Geometry } from "./Geometry"; import { RelationShape } from "./RelationShape"; import { createPropertyWatcher, DOMWatcher } from "./watchers"; const parentImagePropsWatch = { parent: [ "zoomScale", "zoomingPositionX", "zoomingPositionY", "rotation", "currentImage", "containerWidth", "containerHeight", "canvasSize", ], }; const obtainWatcher = (node) => { // that's a tricky way to get watcher also for an exact result instead of whole region // works for global classifications and per-regions const isResult = !!node.from_name; if (isResult) { return DOMWatcher; } switch (node.type) { case "richtextregion": case "paragraphs": return DOMWatcher; case "audioregion": { return createPropertyWatcher(["bboxTriggers"]); } case "rectangleregion": return createPropertyWatcher(["x", "y", "width", "height", "hidden", parentImagePropsWatch]); case "ellipseregion": return createPropertyWatcher(["x", "y", "radiusX", "radiusY", "rotation", "hidden", parentImagePropsWatch]); case "polygonregion": return createPropertyWatcher(["hidden", { points: ["x", "y"] }, parentImagePropsWatch]); case "vectorregion": return createPropertyWatcher(["hidden", "bbox", parentImagePropsWatch]); case "keypointregion": return createPropertyWatcher(["x", "y", "hidden", parentImagePropsWatch]); case "brushregion": return createPropertyWatcher(["needsUpdate", "hidden", "touchesLength", parentImagePropsWatch]); case "timeseriesregion": return createPropertyWatcher(["start", "end", { parent: ["zoomedRange"] }]); default: return null; } }; const createShape = (node, root) => { return new RelationShape({ root, element: node, watcher: obtainWatcher(node), }); }; const connect = (relation, root) => { return { id: relation.id, label: wrapArray(relation.labels ?? []).join(", "), color: "#fa541c", direction: relation.direction, start: createShape(relation.startNode, root), end: createShape(relation.endNode, root), onChange(callback) { const onChangedCallback = debounce(callback, 50); this.start.onUpdate(onChangedCallback); this.end.onUpdate(onChangedCallback); }, destroy() { this.start.destroy(); this.end.destroy(); }, }; }; /** * Calculate BBox for the shape * @param {RelationShape} shape * @param {HTMLOrSVGElement} root */ const calculateBBox = (shape, root) => { const { x, y } = Geometry.getDOMBBox(root, true) ?? { x: 0, y: 0 }; const bboxList = shape.boundingBox(); return bboxList.map((bbox) => { const padded = Geometry.padding(bbox, 3); return { ...padded, x: padded.x - x, y: padded.y - y, }; }); }; const getNodesBBox = ({ start, end, root }) => { const [startBBox, endBBox] = Geometry.closestRects(calculateBBox(start, root), calculateBBox(end, root)); return { start: startBBox, end: endBBox, }; }; const shapesIntersect = ({ x1, y1, w1, x2, y2, w2 }) => { if (y1 === y2) return false; const leftIntersection = x1 <= x2 && x2 <= x1 + w1; const rightIntersection = x1 <= x2 + w2 && x2 + w2 <= x1 + w1; return leftIntersection || rightIntersection; }; const calculateTopPath = ({ x1, y1, w1, x2, y2, w2, limit }) => { const xw1 = x1 + w1 * 0.5; const xw2 = x2 + w2 * 0.5; const top = Math.min(y1, y2) - limit; const l1 = Math.min(top, y1 - limit); const l2 = Math.min(top, y2 - limit); const toEnd = xw1 < xw2; return { x1: xw1, x2: xw2, y1, y2, l1, l2, toEnd }; }; const calculateSidePath = ({ x1, y1, w1, h1, x2, y2, w2, h2, limit }) => { let renderingSide = "left"; if (Math.min(x1, x2) - limit < 0) { renderingSide = "right"; } let xs1; let xs2; let ys1; let ys2; let l1; let l2; if (renderingSide === "left") { xs1 = x1; ys1 = y1 + h1 * 0.5; xs2 = x2; ys2 = y2 + h2 * 0.5; const left = Math.min(xs1, xs2) - limit; l1 = Math.min(left, xs1 - limit); l2 = Math.min(left, xs2 - limit); } else { xs1 = x1 + w1; ys1 = y1 + h1 * 0.5; xs2 = x2 + w2; ys2 = y2 + h2 * 0.5; const left = Math.max(xs1, xs2) + limit; l1 = Math.max(left, xs1 + limit); l2 = Math.max(left, xs2 + limit); } const toEnd = ys1 < ys2; return { x1: xs1, x2: xs2, y1: ys1, y2: ys2, l1, l2, toEnd, renderingSide }; }; const buildPathCommand = ({ x1, y1, x2, y2, l1, l2, toEnd, renderingSide }, orientation) => { const radius = 5; const vertical = orientation === "vertical"; let px1; let py1; let px2; let py2; let px3; let py3; let px4; let py4; let sweep; let arc1; let arc2; let ex; let ey; if (vertical) { px1 = x1; py1 = y1; px2 = x1; py2 = l1 + radius; px3 = x2 + radius * (toEnd ? -1 : 1); py3 = l2; px4 = x2; py4 = y2; sweep = toEnd ? 1 : 0; arc1 = toEnd ? `${radius} -${radius}` : `-${radius} -${radius}`; arc2 = toEnd ? `${radius} ${radius}` : `-${radius} ${radius}`; // Edge center coordinates ex = Math.min(x1, x2) + Math.abs(x2 - x1) / 2; ey = l1; } else if (!vertical && renderingSide === "right") { px1 = x1; py1 = y1; px2 = l1 - radius; py2 = y1; px3 = l2; py3 = y2 + radius * (toEnd ? -1 : 1); px4 = x2; py4 = y2; sweep = toEnd ? 1 : 0; arc1 = toEnd ? `${radius} ${radius}` : `${radius} -${radius}`; arc2 = toEnd ? `-${radius} ${radius}` : `-${radius} -${radius}`; // Edge center coordinates ex = l1; ey = Math.min(y1, y2) + Math.abs(y2 - y1) / 2; } else if (!vertical && renderingSide === "left") { px1 = x1; py1 = y1; px2 = l1 + radius; py2 = y1; px3 = l2; py3 = y2 + radius * (toEnd ? -1 : 1); px4 = x2; py4 = y2; sweep = toEnd ? 0 : 1; arc1 = toEnd ? `-${radius} ${radius}` : `-${radius} -${radius}`; arc2 = toEnd ? `${radius} ${radius}` : `${radius} -${radius}`; // Edge center coordinates ex = l1; ey = Math.min(y1, y2) + Math.abs(y2 - y1) / 2; } const pathCommand = [ `M ${px1} ${py1}`, `${px2} ${py2}`, `a 5 5 0 0 ${sweep} ${arc1}`, // rounded corner `L ${px3} ${py3}`, `a 5 5 0 0 ${sweep} ${arc2}`, // rounded corner `L ${px4} ${py4}`, ]; return [pathCommand.join(" "), [ex, ey]]; }; const calculatePath = (start, end) => { const { x: x1, y: y1, width: w1, height: h1 } = start; const { x: x2, y: y2, width: w2, height: h2 } = end; const limit = 15; const intersecting = shapesIntersect({ x1, y1, w1, x2, y2, w2, }); const coordinatesCalculator = intersecting ? calculateSidePath : calculateTopPath; const coordinates = coordinatesCalculator({ x1, y1, w1, h1, x2, y2, w2, h2, limit, }); const pathCommand = buildPathCommand(coordinates, intersecting ? "horizontal" : "vertical"); return pathCommand; }; export default { obtainWatcher, createShape, connect, getNodesBBox, calculatePath, calculateBBox, };