Files
AFFiNE-Mirror/blocksuite/affine/blocks/block-surface/src/managers/connector-manager.ts
2025-03-28 07:20:34 +00:00

1448 lines
39 KiB
TypeScript

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<T extends IVec3 | IVec>(
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<IVec3[]>(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;
}
}