import { type BrushElementModel, type Connection, ConnectorElementModel, ConnectorMode, GroupElementModel, type LocalConnectorElementModel, } from '@blocksuite/affine-model'; import { ThemeProvider } from '@blocksuite/affine-shared/services'; import { BlockSuiteError } from '@blocksuite/global/exceptions'; import type { IBound, IVec, IVec3 } from '@blocksuite/global/gfx'; import { almostEqual, Bound, clamp, getBezierCurveBoundingBox, getBezierParameters, getBoundFromPoints, getBoundWithRotation, getPointFromBoundsWithRotation, isOverlap, isVecZero, lineIntersects, PI2, PointLocation, sign, toRadian, Vec, } from '@blocksuite/global/gfx'; import { assertType } from '@blocksuite/global/utils'; import type { GfxController, GfxLocalElementModel, GfxModel, } from '@blocksuite/std/gfx'; import { effect } from '@preact/signals-core'; import last from 'lodash-es/last'; import { Overlay } from '../renderer/overlay.js'; import { AStarRunner } from '../utils/a-star.js'; export type Connectable = Exclude< GfxModel, ConnectorElementModel | BrushElementModel | GroupElementModel >; export type OrthogonalConnectorInput = { startBound: Bound | null; endBound: Bound | null; startPoint: PointLocation; endPoint: PointLocation; }; export const ConnectorEndpointLocations: IVec[] = [ // At top [0.5, 0], // At right [1, 0.5], // At bottom [0.5, 1], // At left [0, 0.5], ]; export const ConnectorEndpointLocationsOnTriangle: IVec[] = [ // At top [0.5, 0], // At right [0.75, 0.5], // At bottom [0.5, 1], // At left [0.25, 0.5], ]; export function isConnectorWithLabel(model: GfxModel | GfxLocalElementModel) { return model instanceof ConnectorElementModel && model.hasLabel(); } export function calculateNearestLocation( point: IVec, bounds: IBound, locations = ConnectorEndpointLocations, shortestDistance = Number.POSITIVE_INFINITY ) { const { x, y, w, h } = bounds; return locations .map(offset => [x + offset[0] * w, y + offset[1] * h] as IVec) .map(point => getPointFromBoundsWithRotation(bounds, point)) .reduce( (prev, curr, index) => { const d = Vec.dist(point, curr); if (d < shortestDistance) { const location = locations[index]; shortestDistance = d; prev[0] = location[0]; prev[1] = location[1]; } return prev; }, [...locations[0]] ) as IVec; } function rBound(ele: GfxModel, anti = false): IBound { const bound = Bound.deserialize(ele.xywh); return { ...bound, rotate: anti ? -ele.rotate : ele.rotate }; } export function isConnectorAndBindingsAllSelected( connector: ConnectorElementModel | LocalConnectorElementModel, selected: GfxModel[] ) { const connectorSelected = selected.find(s => s.id === connector.id); if (!connectorSelected) { return false; } const { source, target } = connector; const startSelected = selected.find(s => s.id === source?.id); const endSelected = selected.find(s => s.id === target?.id); if (!source.id && !target.id) { return true; } if (!source.id && endSelected) { return true; } if (!target.id && startSelected) { return true; } if (startSelected && endSelected) { return true; } return false; } export function getAnchors(ele: GfxModel) { const bound = Bound.deserialize(ele.xywh); const offset = 10; const anchors: { point: PointLocation; coord: IVec }[] = []; const rotate = ele.rotate; [ [bound.center[0], bound.y - offset], [bound.center[0], bound.maxY + offset], [bound.x - offset, bound.center[1]], [bound.maxX + offset, bound.center[1]], ] .map(vec => getPointFromBoundsWithRotation({ ...bound, rotate }, vec as IVec) ) .forEach(vec => { const rst = ele.getLineIntersections(bound.center as IVec, vec as IVec); if (!rst) { console.error(`Failed to get line intersections for ${ele.id}`); return; } const originPoint = getPointFromBoundsWithRotation( { ...bound, rotate: -rotate }, rst[0] ); anchors.push({ point: rst[0], coord: bound.toRelative(originPoint) }); }); return anchors; } function getConnectableRelativePosition(connectable: GfxModel, position: IVec) { const location = connectable.getRelativePointLocation(position as IVec); if (isVecZero(Vec.sub(position, [0, 0.5]))) location.tangent = Vec.rot([0, -1], toRadian(connectable.rotate)); else if (isVecZero(Vec.sub(position, [1, 0.5]))) location.tangent = Vec.rot([0, 1], toRadian(connectable.rotate)); else if (isVecZero(Vec.sub(position, [0.5, 0]))) location.tangent = Vec.rot([1, 0], toRadian(connectable.rotate)); else if (isVecZero(Vec.sub(position, [0.5, 1]))) location.tangent = Vec.rot([-1, 0], toRadian(connectable.rotate)); return location; } export function getNearestConnectableAnchor(ele: Connectable, point: IVec) { const anchors = getAnchors(ele); return closestPoint( anchors.map(a => a.point), point ); } function closestPoint(points: PointLocation[], point: IVec) { const rst = points.map(p => ({ p, d: Vec.dist(p, point) })); rst.sort((a, b) => a.d - b.d); return rst[0].p; } function computePoints( startPoint: IVec, endPoint: IVec, nextStartPoint: IVec, lastEndPoint: IVec, startBound: Bound | null, endBound: Bound | null, expandStartBound: Bound | null, expandEndBound: Bound | null ): [IVec3[], IVec3, IVec3, IVec3, IVec3] { const startPointVec3 = downscalePrecision(startPoint); const endPointVec3 = downscalePrecision(endPoint); let nextStartPointVec3 = downscalePrecision(nextStartPoint); let lastEndPointVec3 = downscalePrecision(lastEndPoint); const result = getConnectablePoints( startPointVec3, endPointVec3, nextStartPointVec3, lastEndPointVec3, startBound, endBound, expandStartBound, expandEndBound ); const points = result.points; nextStartPointVec3 = result.nextStartPoint; lastEndPointVec3 = result.lastEndPoint; const finalPoints = filterConnectablePoints( filterConnectablePoints(points, expandStartBound?.expand(-1) ?? null), expandEndBound?.expand(-1) ?? null ); return [ finalPoints, startPointVec3, endPointVec3, nextStartPointVec3, lastEndPointVec3, ]; } function downscalePrecision(point: IVec | IVec3): IVec3 { return [ Number(point[0].toFixed(2)), Number(point[1].toFixed(2)), point[2] ?? 0, ]; } function filterConnectablePoints( points: T[], bound: Bound | null ): T[] { return points.filter(point => { if (!bound) return true; return !bound.isPointInBound(point as IVec); }); } function pushWithPriority(points: number[][], vecs: IVec[], priority = 0) { points.push(...vecs.map(vec => [...vec, priority])); } function pushLineIntersectsToPoints( points: IVec3[], aLine: IVec[], bLine: IVec[], priority = 0 ) { const rst = lineIntersects(aLine[0], aLine[1], bLine[0], bLine[1], true); if (rst) { pushWithPriority(points, [rst], priority); } } function pushOuterPoints( points: IVec3[], expandStartBound: Bound, expandEndBound: Bound, outerBound: Bound ) { if (expandStartBound && expandEndBound && outerBound) { pushWithPriority(points, outerBound.getVerticesAndMidpoints()); pushWithPriority(points, [outerBound.center], 2); [ expandStartBound.upperLine, expandStartBound.horizontalLine, expandStartBound.lowerLine, expandEndBound.upperLine, expandEndBound.horizontalLine, expandEndBound.lowerLine, ].forEach(line => { pushLineIntersectsToPoints(points, line, outerBound.leftLine, 0); pushLineIntersectsToPoints(points, line, outerBound.rightLine, 0); }); [ expandStartBound.leftLine, expandStartBound.verticalLine, expandStartBound.rightLine, expandEndBound.leftLine, expandEndBound.verticalLine, expandEndBound.rightLine, ].forEach(line => { pushLineIntersectsToPoints(points, line, outerBound.upperLine, 0); pushLineIntersectsToPoints(points, line, outerBound.lowerLine, 0); }); } } function pushBoundMidPoint( points: IVec3[], bound1: Bound, bound2: Bound, expandBound1: Bound, expandBound2: Bound ) { if (bound1.maxX < bound2.x) { const midX = (bound1.maxX + bound2.x) / 2; [ expandBound1.horizontalLine, expandBound2.horizontalLine, expandBound1.upperLine, expandBound1.lowerLine, expandBound2.upperLine, expandBound2.lowerLine, ].forEach((line, index) => { pushLineIntersectsToPoints( points, line, [ [midX, 0], [midX, 1], ], index === 0 || index === 1 ? 6 : 3 ); }); } if (bound1.maxY < bound2.y) { const midY = (bound1.maxY + bound2.y) / 2; [ expandBound1.verticalLine, expandBound2.verticalLine, expandBound1.leftLine, expandBound1.rightLine, expandBound2.leftLine, expandBound2.rightLine, ].forEach((line, index) => { pushLineIntersectsToPoints( points, line, [ [0, midY], [1, midY], ], index === 0 || index === 1 ? 6 : 3 ); }); } } function pushGapMidPoint( points: IVec3[], point: IVec3, bound: Bound, bound2: Bound, expandBound: Bound, expandBound2: Bound ) { /** on top or on bottom */ if ( almostEqual(point[1], bound.y, 0.02) || almostEqual(point[1], bound.maxY, 0.02) ) { const rst = [ bound.upperLine, bound.lowerLine, bound2.upperLine, bound2.lowerLine, ].map(line => { return lineIntersects( point as unknown as IVec, [point[0], point[1] + 1], line[0], line[1], true ) as IVec; }); rst.sort((a, b) => a[1] - b[1]); const midPoint = Vec.lrp(rst[1], rst[2], 0.5); pushWithPriority(points, [midPoint], 6); [ expandBound.leftLine, expandBound.rightLine, expandBound2.leftLine, expandBound2.rightLine, ].forEach(line => { pushLineIntersectsToPoints( points, [midPoint, [midPoint[0] + 1, midPoint[1]]], line, 0 ); }); } else { const rst = [ bound.leftLine, bound.rightLine, bound2.leftLine, bound2.rightLine, ].map(line => { return lineIntersects( point as unknown as IVec, [point[0] + 1, point[1]], line[0], line[1], true ) as IVec; }); rst.sort((a, b) => a[0] - b[0]); const midPoint = Vec.lrp(rst[1], rst[2], 0.5); pushWithPriority(points, [midPoint], 6); [ expandBound.upperLine, expandBound.lowerLine, expandBound2.upperLine, expandBound2.lowerLine, ].forEach(line => { pushLineIntersectsToPoints( points, [midPoint, [midPoint[0], midPoint[1] + 1]], line, 0 ); }); } } function removeDulicatePoints(points: IVec[] | IVec3[]) { points = points.map(downscalePrecision); points.sort((a, b) => a[0] - b[0]); assertType(points); for (let i = 1; i < points.length - 1; i++) { const cur = points[i]; const last = points[i - 1]; if (almostEqual(cur[0], last[0], 0.02)) { cur[0] = last[0]; } } points.sort((a, b) => a[1] - b[1]); for (let i = 1; i < points.length - 1; i++) { const cur = points[i]; const last = points[i - 1]; if (almostEqual(cur[1], last[1], 0.02)) { cur[1] = last[1]; } } points.sort((a, b) => { if (a[0] < b[0]) return -1; if (a[0] > b[0]) return 1; if (a[1] < b[1]) return -1; if (a[1] > b[1]) return 1; return 0; }); for (let i = 1; i < points.length; i++) { const cur = points[i]; const last = points[i - 1]; if ( almostEqual(cur[0], last[0], 0.02) && almostEqual(cur[1], last[1], 0.02) ) { if (cur[2] <= last[2]) points.splice(i, 1); else points.splice(i - 1, 1); i--; continue; } } return points; } function getConnectablePoints( startPoint: IVec3, endPoint: IVec3, nextStartPoint: IVec3, lastEndPoint: IVec3, startBound: Bound | null, endBound: Bound | null, expandStartBound: Bound | null, expandEndBound: Bound | null ) { const lineBound = Bound.fromPoints([ startPoint, endPoint, ] as unknown[] as IVec[]); const outerBound = expandStartBound && expandEndBound && expandStartBound.unite(expandEndBound); let points = [nextStartPoint, lastEndPoint] as IVec3[]; pushWithPriority(points, lineBound.getVerticesAndMidpoints()); if (!startBound || !endBound) { pushWithPriority(points, [lineBound.center], 3); } if (outerBound) { pushOuterPoints(points, expandStartBound, expandEndBound, outerBound); } if (startBound && endBound && expandStartBound && expandEndBound) { pushGapMidPoint( points, startPoint, startBound, endBound, expandStartBound, expandEndBound ); pushGapMidPoint( points, endPoint, endBound, startBound, expandEndBound, expandStartBound ); pushBoundMidPoint( points, startBound, endBound, expandStartBound, expandEndBound ); pushBoundMidPoint( points, endBound, startBound, expandEndBound, expandStartBound ); } if (expandStartBound) { pushWithPriority(points, expandStartBound.getVerticesAndMidpoints()); pushWithPriority( points, expandStartBound.include(lastEndPoint as unknown as IVec).points ); } if (expandEndBound) { pushWithPriority(points, expandEndBound.getVerticesAndMidpoints()); pushWithPriority( points, expandEndBound.include(nextStartPoint as unknown as IVec).points ); } points = removeDulicatePoints(points); const sorted = points.map(point => point[0] + ',' + point[1]).sort(); sorted.forEach((cur, index) => { if (index === 0) return; if (cur === sorted[index - 1]) { throw new Error('duplicate point'); } }); const startEnds = [nextStartPoint, lastEndPoint].map(point => { return points.find( item => almostEqual(item[0], point[0], 0.02) && almostEqual(item[1], point[1], 0.02) ); }) as IVec3[]; if (!startEnds[0] || !startEnds[1]) { throw new BlockSuiteError( BlockSuiteError.ErrorCode.ValueNotExists, 'Failed to get start and end points when getting connectable points' ); } return { points, nextStartPoint: startEnds[0], lastEndPoint: startEnds[1] }; } function getDirectPath(startPoint: IVec, endPoint: IVec): IVec[] { if ( almostEqual(startPoint[0], endPoint[0], 0.02) || almostEqual(startPoint[1], endPoint[1], 0.02) ) { return [startPoint, endPoint]; } else { const vec = Vec.sub(endPoint, startPoint); const mid: IVec = [startPoint[0], startPoint[1] + vec[1]]; return [startPoint, mid, endPoint]; } } function mergePath(points: IVec[] | IVec3[]) { if (points.length === 0) return []; const result: IVec[] = [[points[0][0], points[0][1]]]; for (let i = 1; i < points.length - 1; i++) { const cur = points[i]; const last = points[i - 1]; const next = points[i + 1]; if ( almostEqual(last[0], cur[0], 0.02) && almostEqual(cur[0], next[0], 0.02) ) continue; if ( almostEqual(last[1], cur[1], 0.02) && almostEqual(cur[1], next[1], 0.02) ) continue; result.push([cur[0], cur[1]]); } result.push(last(points as IVec[]) as IVec); for (let i = 0; i < result.length - 1; i++) { const cur = result[i]; const next = result[i + 1]; const isAlmostEqual = almostEqual(cur[0], next[0], 0.02) || almostEqual(cur[1], next[1], 0.02); if (!isAlmostEqual) { console.warn('Expected equal points'); console.warn(points); console.warn(result); } } return result; } function computeOffset(startBound: Bound | null, endBound: Bound | null) { const startOffset = [20, 20, 20, 20]; const endOffset = [20, 20, 20, 20]; if (!(startBound && endBound)) { return [startOffset, endOffset]; } // left, top, right, bottom let overlap = isOverlap(startBound.upperLine, endBound.lowerLine, 0, false); let dist: number; if (overlap && startBound.upperLine[0][1] > endBound.lowerLine[0][1]) { dist = Vec.distanceToLineSegment( startBound.upperLine[0], startBound.upperLine[1], endBound.lowerLine[0], false ); startOffset[1] = Math.max(Math.min(dist / 2, startOffset[1]), 0); } overlap = isOverlap(startBound.rightLine, endBound.leftLine, 1, false); if (overlap && startBound.rightLine[0][0] < endBound.leftLine[0][0]) { dist = Vec.distanceToLineSegment( startBound.rightLine[0], startBound.rightLine[1], endBound.leftLine[0], false ); startOffset[2] = Math.max(Math.min(dist / 2, startOffset[2]), 0); } overlap = isOverlap(startBound.lowerLine, endBound.upperLine, 0, false); if (overlap && startBound.lowerLine[0][1] < endBound.upperLine[0][1]) { dist = Vec.distanceToLineSegment( startBound.lowerLine[0], startBound.lowerLine[1], endBound.upperLine[0], false ); startOffset[3] = Math.max(Math.min(dist / 2, startOffset[3]), 0); } startOffset[0] = endOffset[2] = Math.min(startOffset[0], endOffset[2]) === 0 ? 20 : Math.min(startOffset[0], endOffset[2]); startOffset[1] = endOffset[3] = Math.min(startOffset[1], endOffset[3]) === 0 ? 20 : Math.min(startOffset[1], endOffset[3]); startOffset[2] = endOffset[0] = Math.min(startOffset[2], endOffset[0]) === 0 ? 20 : Math.min(startOffset[2], endOffset[0]); startOffset[3] = endOffset[1] = Math.min(startOffset[3], endOffset[1]) === 0 ? 20 : Math.min(startOffset[3], endOffset[1]); return [startOffset, endOffset]; } function getNextPoint( bound: Bound, point: PointLocation, offsetX = 10, offsetY = 10, offsetW = 10, offsetH = 10 ) { const result: IVec = Array.from(point) as IVec; if (almostEqual(bound.x, result[0])) result[0] -= offsetX; else if (almostEqual(bound.y, result[1])) result[1] -= offsetY; else if (almostEqual(bound.maxX, result[0])) result[0] += offsetW; else if (almostEqual(bound.maxY, result[1])) result[1] += offsetH; else { const direction = Vec.normalize(Vec.sub(result, bound.center)); const xDirection = direction[0] > 0 ? 1 : -1; const yDirection = direction[1] > 0 ? 1 : -1; const slope = Math.abs(point.tangent[0]) < Math.abs(point.tangent[1]) ? 0 : 1; // if the slope is big, use the x direction if (slope === 0) { if (xDirection > 0) { const intersects = lineIntersects( bound.rightLine[0], bound.rightLine[1], result, [bound.maxX + 10, result[1]] ); if (!intersects) { throw new BlockSuiteError( BlockSuiteError.ErrorCode.ValueNotExists, 'Failed to get line intersections for getNextPoint' ); } result[0] = intersects[0] + offsetX; } else { const intersects = lineIntersects( bound.leftLine[0], bound.leftLine[1], result, [bound.x - 10, result[1]] ); if (!intersects) { throw new BlockSuiteError( BlockSuiteError.ErrorCode.ValueNotExists, 'Failed to get line intersections for getNextPoint' ); } result[0] = intersects[0] - offsetX; } } else { if (yDirection > 0) { const intersects = lineIntersects( bound.lowerLine[0], bound.lowerLine[1], result, [result[0], bound.maxY + 10] ); if (!intersects) { throw new BlockSuiteError( BlockSuiteError.ErrorCode.ValueNotExists, 'Failed to get line intersections for getNextPoint' ); } result[1] = intersects[1] + offsetY; } else { const intersects = lineIntersects( bound.upperLine[0], bound.upperLine[1], result, [result[0], bound.y - 10] ); if (!intersects) { throw new BlockSuiteError( BlockSuiteError.ErrorCode.ValueNotExists, 'Failed to get line intersections for getNextPoint' ); } result[1] = intersects[1] - offsetY; } } } return result; } function computeNextStartEndpoint( startPoint: PointLocation, endPoint: PointLocation, startBound: Bound | null, endBound: Bound | null, startOffset: number[] | null, endOffset: number[] | null ) { const nextStartPoint = startBound && startOffset ? getNextPoint( startBound, startPoint, startOffset[0], startOffset[1], startOffset[2], startOffset[3] ) : startPoint; const lastEndPoint = endBound && endOffset ? getNextPoint( endBound, endPoint, endOffset[0], endOffset[1], endOffset[2], endOffset[3] ) : endPoint; return [nextStartPoint, lastEndPoint]; } function adjustStartEndPoint( startPoint: IVec3, endPoint: IVec3, startBound: Bound | null = null, endBound: Bound | null = null ) { if (!endBound) { if ( Math.abs(endPoint[0] - startPoint[0]) > Math.abs(endPoint[1] - startPoint[1]) ) { endPoint[0] += sign(endPoint[0] - startPoint[0]) * 20; } else { endPoint[1] += sign(endPoint[1] - startPoint[1]) * 20; } } if (!startBound) { if ( Math.abs(endPoint[0] - startPoint[0]) > Math.abs(endPoint[1] - startPoint[1]) ) { startPoint[0] -= sign(endPoint[0] - startPoint[0]) * 20; } else { startPoint[1] -= sign(endPoint[1] - startPoint[1]) * 20; } } } function renderRect( ctx: CanvasRenderingContext2D, bounds: IBound, color: string, lineWidth: number ) { const { x, y, w, h } = bounds; ctx.save(); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.setLineDash([lineWidth * 2, lineWidth * 2]); ctx.strokeRect(x, y, w, h); ctx.closePath(); ctx.restore(); } export class ConnectionOverlay extends Overlay { static override overlayName = 'connection'; private _emphasisColor: string; private _themeDisposer: (() => void) | null = null; highlightPoint: IVec | null = null; points: IVec[] = []; sourceBounds: IBound | null = null; targetBounds: IBound | null = null; constructor(gfx: GfxController) { super(gfx); this._emphasisColor = this._getEmphasisColor(); this._setupThemeListener(); } private _findConnectablesInViews() { const gfx = this.gfx; const bound = gfx.viewport.viewportBounds; return gfx.getElementsByBound(bound).filter(ele => ele.connectable); } private _getEmphasisColor(): string { return getComputedStyle(this.gfx.std.host).getPropertyValue( '--affine-text-emphasis-color' ); } private _setupThemeListener(): void { const themeService = this.gfx.std.get(ThemeProvider); this._themeDisposer = effect(() => { themeService.theme$; this._emphasisColor = this._getEmphasisColor(); }); } _clearRect() { this.points = []; this.highlightPoint = null; this._renderer?.refresh(); } override clear() { this.sourceBounds = null; this.targetBounds = null; this._clearRect(); } override dispose() { this._themeDisposer?.(); if (!this._renderer) return; this._renderer.removeOverlay(this); this._renderer = null; } override render(ctx: CanvasRenderingContext2D): void { const zoom = this.gfx.viewport.zoom; const radius = 5 / zoom; const color = this._emphasisColor; ctx.globalAlpha = 0.6; let lineWidth = 1 / zoom; if (this.sourceBounds) { renderRect(ctx, this.sourceBounds, color, lineWidth); } if (this.targetBounds) { renderRect(ctx, this.targetBounds, color, lineWidth); } lineWidth = 2 / zoom; this.points.forEach(p => { ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, PI2); ctx.fillStyle = 'white'; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.fill(); ctx.stroke(); ctx.closePath(); }); ctx.globalAlpha = 1; if (this.highlightPoint) { ctx.beginPath(); ctx.arc(this.highlightPoint[0], this.highlightPoint[1], radius, 0, PI2); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = lineWidth; ctx.fill(); ctx.stroke(); ctx.closePath(); } } /** * Render the connector at the given point. It will try to find * the closest connectable element and render the connector. If the * point is not close to any connectable element, it will just render * the connector at the given point. * @param point the point to render the connector * @param excludedIds the ids of the elements that should be excluded * @returns the connection result */ renderConnector(point: IVec, excludedIds: string[] = []) { const connectables = this._findConnectablesInViews(); const context = this.gfx; const target = []; this._clearRect(); let result: Connection | null = null; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < connectables.length; i++) { const connectable = connectables[i]; // first check if in excluedIds if (excludedIds.includes(connectable.id)) continue; // then check if in expanded bound const bound = Bound.deserialize(connectable.xywh); const rotateBound = Bound.from(getBoundWithRotation(rBound(connectable))); // FIXME: the real path needs to be expanded: diamod, ellipse, trangle. if (!rotateBound.expand(10).isPointInBound(point)) continue; // then check if closes to anchors const anchors = getAnchors(connectable); const len = anchors.length; const pointerViewCoord = context.viewport.toViewCoord(point[0], point[1]); let shortestDistance = Number.POSITIVE_INFINITY; let j = 0; this.points = anchors.map(a => a.point); for (; j < len; j++) { const anchor = anchors[j]; const anchorViewCoord = context.viewport.toViewCoord( anchor.point[0], anchor.point[1] ); const d = Vec.dist(anchorViewCoord, pointerViewCoord); if (d < shortestDistance) { shortestDistance = d; target.push(connectable); this.highlightPoint = anchor.point; result = { id: connectable.id, position: anchor.coord as IVec, }; } } if (shortestDistance < 8 && result) break; // if not, check if closes to bound const nearestPoint = connectable.getNearestPoint(point as IVec) as IVec; if (Vec.dist(nearestPoint, point) < 8) { this.highlightPoint = nearestPoint; const originPoint = getPointFromBoundsWithRotation( rBound(connectable, true), nearestPoint ); this._renderer?.refresh(); target.push(connectable); result = { id: connectable.id, position: bound .toRelative(originPoint) .map(n => clamp(n, 0, 1)) as IVec, }; } if (result) continue; // if not, check if in inside of the element if ( connectable.includesPoint( point[0], point[1], { ignoreTransparent: false, }, this.gfx.std.host ) ) { target.push(connectable); result = { id: connectable.id, }; } } if (last(target) instanceof GroupElementModel) { this.targetBounds = Bound.deserialize(last(target)!.xywh); } else { this.targetBounds = null; } // at last, if not, just return the point if (!result) { result = { position: point as IVec, }; } this._renderer?.refresh(); return result; } } export class PathGenerator { protected _aStarRunner: AStarRunner | null = null; protected _prepareOrthogonalConnectorInfo( connectorInfo: OrthogonalConnectorInput ): [ IVec, IVec, IVec, IVec, Bound | null, Bound | null, Bound | null, Bound | null, ] { const { startBound, endBound, startPoint, endPoint } = connectorInfo; const [startOffset, endOffset] = computeOffset(startBound, endBound); const [nextStartPoint, lastEndPoint] = computeNextStartEndpoint( startPoint, endPoint, startBound, endBound, startOffset, endOffset ); const expandStartBound = startBound ? startBound.expand( startOffset[0], startOffset[1], startOffset[2], startOffset[3] ) : null; const expandEndBound = endBound ? endBound.expand(endOffset[0], endOffset[1], endOffset[2], endOffset[3]) : null; return [ startPoint, endPoint, nextStartPoint, lastEndPoint, startBound, endBound, expandStartBound, expandEndBound, ]; } generateOrthogonalConnectorPath(input: OrthogonalConnectorInput): IVec[] { const info = this._prepareOrthogonalConnectorInfo(input); const [startPoint, endPoint, nextStartPoint, lastEndPoint] = info; const [, , , , startBound, endBound, expandStartBound, expandEndBound] = info; const blocks = []; const expandBlocks = []; startBound && blocks.push(startBound.clone()); endBound && blocks.push(endBound.clone()); expandStartBound && expandBlocks.push(expandStartBound.clone()); expandEndBound && expandBlocks.push(expandEndBound.clone()); if ( startBound && endBound && startBound.isPointInBound(endPoint) && endBound.isPointInBound(startPoint) ) { return getDirectPath(startPoint, endPoint); } if (startBound && expandStartBound?.isPointInBound(endPoint, 0)) { return getDirectPath(startPoint, endPoint); } if (endBound && expandEndBound?.isPointInBound(startPoint, 0)) { return getDirectPath(startPoint, endPoint); } const points = computePoints( startPoint, endPoint, nextStartPoint, lastEndPoint, startBound, endBound, expandStartBound, expandEndBound ); const finalPoints = points[0]; const [, startPointV3, endPointV3, nextStartPointV3, lastEndPointV3] = points; adjustStartEndPoint(startPointV3, endPointV3, startBound, endBound); this._aStarRunner = new AStarRunner( finalPoints, nextStartPointV3, lastEndPointV3, startPointV3, endPointV3, blocks, expandBlocks ); this._aStarRunner.run(); const path = this._aStarRunner.path; if (!endBound) path.pop(); if (!startBound) path.shift(); return mergePath(path); } } export class ConnectorPathGenerator extends PathGenerator { constructor( private readonly options: { getElementById: (id: string) => GfxModel | null; } ) { super(); } static updatePath( connector: ConnectorElementModel | LocalConnectorElementModel, path: PointLocation[] | null, elementGetter?: (id: string) => GfxModel | null ) { const instance = new ConnectorPathGenerator({ getElementById: elementGetter ?? (() => null), }); const points = path ?? instance._generateConnectorPath(connector) ?? []; const bound = connector.mode === ConnectorMode.Curve ? getBezierCurveBoundingBox(getBezierParameters(points)) : getBoundFromPoints(points); const relativePoints = points.map((p: PointLocation) => { return p.setVec(Vec.sub(p, [bound.x, bound.y])); }); connector.updatingPath = true; // the property assignment order matters connector.xywh = bound.serialize(); connector.path = relativePoints; // Updates Connector's Label position. if (isConnectorWithLabel(connector)) { const model = connector as ConnectorElementModel; const [cx, cy] = model.getPointByOffsetDistance( model.labelOffset.distance ); const [, , w, h] = model.labelXYWH!; model.labelXYWH = [cx - w / 2, cy - h / 2, w, h]; } connector.updatingPath = false; } private _computeStartEndPoint( connector: ConnectorElementModel | LocalConnectorElementModel ) { const { source, target } = connector; const start = this._getConnectorEndElement(connector, 'source'); const end = this._getConnectorEndElement(connector, 'target'); let startPoint: PointLocation | null = null; let endPoint: PointLocation | null = null; if ( source.id && !source.position && target.id && !target.position && start && end ) { const startAnchors = getAnchors(start); const endAnchors = getAnchors(end); let minDist = Infinity; let minStartAnchor = new PointLocation(); let minEndAnchor = new PointLocation(); for (const sa of startAnchors) { for (const ea of endAnchors) { const dist = Vec.dist(sa.point, ea.point); if (dist + 0.1 < minDist) { minDist = dist; minStartAnchor = sa.point; minEndAnchor = ea.point; } } } startPoint = minStartAnchor; endPoint = minEndAnchor; } else { startPoint = this._getConnectionPoint(connector, 'source'); endPoint = this._getConnectionPoint(connector, 'target'); } if (!startPoint || !endPoint) return []; return [startPoint, endPoint]; } private _generateConnectorPath( connector: ConnectorElementModel | LocalConnectorElementModel ) { const { mode } = connector; if (mode === ConnectorMode.Straight) { return this._generateStraightConnectorPath(connector); } else if (mode === ConnectorMode.Orthogonal) { const start = this._getConnectorEndElement(connector, 'source'); const end = this._getConnectorEndElement(connector, 'target'); const [startPoint, endPoint] = this._computeStartEndPoint(connector); const startBound = start ? Bound.from(getBoundWithRotation(rBound(start))) : null; const endBound = end ? Bound.from(getBoundWithRotation(rBound(end))) : null; const path = this.generateOrthogonalConnectorPath({ startPoint, endPoint, startBound, endBound, }); return path.map(p => new PointLocation(p)); } else if (mode === ConnectorMode.Curve) { return this._generateCurveConnectorPath(connector); } throw new Error('unknown connector mode'); } private _generateCurveConnectorPath( connector: ConnectorElementModel | LocalConnectorElementModel ) { const { source, target } = connector; let startPoint: PointLocation | null = null; let endPoint: PointLocation | null = null; if (source.id || target.id) { if (!source.position && !target.position) { const start = this._getConnectorEndElement( connector, 'source' ) as Connectable; const end = this._getConnectorEndElement( connector, 'target' ) as Connectable; const sb = Bound.deserialize(start.xywh); const eb = Bound.deserialize(end.xywh); startPoint = getNearestConnectableAnchor(start, eb.center); endPoint = getNearestConnectableAnchor(end, sb.center); } else { startPoint = this._getConnectionPoint(connector, 'source'); endPoint = this._getConnectionPoint(connector, 'target'); } if (!startPoint || !endPoint) return []; if (source.id) { const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2); startPoint.out = Vec.mul( startTangentVertical, Math.max( 100, Math.abs( Vec.pry(Vec.sub(endPoint, startPoint), startTangentVertical) ) / 3 ) ); } if (target.id) { const endTangentVertical = Vec.rot(endPoint.tangent, -Math.PI / 2); endPoint.in = Vec.mul( endTangentVertical, Math.max( 100, Math.abs( Vec.pry(Vec.sub(startPoint, endPoint), endTangentVertical) ) / 3 ) ); } return [startPoint, endPoint]; } else { startPoint = this._getConnectionPoint(connector, 'source'); endPoint = this._getConnectionPoint(connector, 'target'); if (!startPoint || !endPoint) return []; if ( Math.abs(endPoint[0] - startPoint[0]) > Math.abs(endPoint[1] - startPoint[1]) ) { startPoint.out = [Vec.mul(Vec.sub(endPoint, startPoint), 2 / 3)[0], 0]; endPoint.in = [Vec.mul(Vec.sub(startPoint, endPoint), 2 / 3)[0], 0]; } else { startPoint.out = [0, Vec.mul(Vec.sub(endPoint, startPoint), 2 / 3)[1]]; endPoint.in = [0, Vec.mul(Vec.sub(startPoint, endPoint), 2 / 3)[1]]; } return [startPoint, endPoint]; } } private _generateStraightConnectorPath( connector: ConnectorElementModel | LocalConnectorElementModel ) { const { source, target } = connector; if (source.id && !source.position && target.id && !target.position) { const start = this._getConnectorEndElement( connector, 'source' ) as Connectable; const end = this._getConnectorEndElement( connector, 'target' ) as Connectable; const sb = Bound.deserialize(start.xywh); const eb = Bound.deserialize(end.xywh); const startPoint = getNearestConnectableAnchor(start, eb.center); const endPoint = getNearestConnectableAnchor(end, sb.center); return [startPoint, endPoint]; } else { const endPoint = this._getConnectionPoint(connector, 'target'); const startPoint = this._getConnectionPoint(connector, 'source'); return (startPoint && endPoint && [startPoint, endPoint]) ?? []; } } private _getConnectionPoint( connector: ConnectorElementModel | LocalConnectorElementModel, type: 'source' | 'target' ): PointLocation | null { const connection = connector[type]; if (!connection) return null; const connectable = this._getConnectorEndElement(connector, type); if (!connectable && connection.position) { return PointLocation.fromVec(connection.position); } if (!connectable) return null; let point: PointLocation | null = null; if (connection.position) { point = getConnectableRelativePosition(connectable, connection.position); } else { const anotherType = type === 'source' ? 'target' : 'source'; const otherPoint = this._getConnectionPoint(connector, anotherType); if (otherPoint) { point = getNearestConnectableAnchor(connectable, otherPoint); } } return point; } private _getConnectorEndElement( connector: ConnectorElementModel | LocalConnectorElementModel, type: 'source' | 'target' ): Connectable | null { const id = connector[type].id; if (id) { return this.options.getElementById(id) as Connectable; } return null; } hasRelatedElement( connecter: ConnectorElementModel | LocalConnectorElementModel ) { const { source, target } = connecter; if ( (source.id && !this.options.getElementById(source.id)) || (target.id && !this.options.getElementById(target.id)) ) { return false; } return true; } }