@cnvx/svelte-diagrams
Version:
A Svelte 5 library for creating interactive node diagrams with customizable connections and layouts
360 lines (359 loc) • 14.9 kB
JavaScript
export const browser = !!globalThis?.window;
export const dev = globalThis?.process?.env?.NODE_ENV &&
!globalThis?.process.env.NODE_ENV.toLowerCase().startsWith("prod");
export const vector2 = (x, y) => ({ x, y });
export const eq = (a, b) => a.x === b.x && a.y === b.y;
export const Anchor = {
TOP_LEFT: vector2(0, 0),
TOP_RIGHT: vector2(1, 0),
BOTTOM_LEFT: vector2(0, 1),
BOTTOM_RIGHT: vector2(1, 1),
CENTER_LEFT: vector2(0, 0.5),
CENTER_RIGHT: vector2(1, 0.5),
CENTER_TOP: vector2(0.5, 0),
CENTER_BOTTOM: vector2(0.5, 1),
CENTER_CENTER: vector2(0.5, 0.5),
};
export var Side;
(function (Side) {
Side[Side["Right"] = 0] = "Right";
Side[Side["Top"] = 1] = "Top";
Side[Side["Left"] = 2] = "Left";
Side[Side["Bottom"] = 3] = "Bottom";
})(Side || (Side = {}));
export function debugSide(s) {
return {
[Side.Right]: 'Right',
[Side.Top]: 'Top',
[Side.Left]: 'Left',
[Side.Bottom]: 'Bottom'
}[s];
}
export function normaliseAngle(r) {
return ((r % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
}
export function sideToUnitVector2(s) {
return {
[Side.Top]: { x: 0, y: 1 },
[Side.Left]: { x: -1, y: 0 },
[Side.Right]: { x: 1, y: 0 },
[Side.Bottom]: { x: 0, y: -1 }
}[s];
}
export function sideForAngle(rad) {
const a = normaliseAngle(rad);
if (a >= (7 * Math.PI) / 4 || a < Math.PI / 4)
return Side.Right; // 7π/4 → π/4
if (a >= Math.PI / 4 && a < (3 * Math.PI) / 4)
return Side.Top; // π/4 → 3π/4
if (a >= (3 * Math.PI) / 4 && a < (5 * Math.PI) / 4)
return Side.Left; // 3π/4 → 5π/4
return Side.Bottom;
}
;
export function unitVectorFromAngle(rad) {
return {
x: Math.cos(rad),
y: Math.sin(rad)
};
}
export function getBezierEdgeCenter({ sourceX, sourceY, targetX, targetY, sourceControlX, sourceControlY, targetControlX, targetControlY }) {
/*
* cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate
* https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve
*/
const centerX = sourceX * 0.125 + sourceControlX * 0.375 + targetControlX * 0.375 + targetX * 0.125;
const centerY = sourceY * 0.125 + sourceControlY * 0.375 + targetControlY * 0.375 + targetY * 0.125;
const offsetX = Math.abs(centerX - sourceX);
const offsetY = Math.abs(centerY - sourceY);
return [centerX, centerY, offsetX, offsetY];
}
function calculateControlOffset(distance, curvature) {
if (distance >= 0) {
return 0.5 * distance;
}
return curvature * 25 * Math.sqrt(-distance);
}
function getControlWithCurvature({ pos, x1, y1, x2, y2, c }) {
switch (pos) {
case Side.Left:
return [x1 - calculateControlOffset(x1 - x2, c), y1];
case Side.Right:
return [x1 + calculateControlOffset(x2 - x1, c), y1];
case Side.Top:
return [x1, y1 - calculateControlOffset(y1 - y2, c)];
case Side.Bottom:
return [x1, y1 + calculateControlOffset(y2 - y1, c)];
}
}
/**
* The `getBezierPath` util returns everything you need to render a bezier edge
*between two nodes.
* @public
* @returns A path string you can use in an SVG, the `labelX` and `labelY` position (center of path)
* and `offsetX`, `offsetY` between source handle and label.
* - `path`: the path to use in an SVG `<path>` element.
* - `labelX`: the `x` position you can use to render a label for this edge.
* - `labelY`: the `y` position you can use to render a label for this edge.
* - `offsetX`: the absolute difference between the source `x` position and the `x` position of the
* middle of this path.
* - `offsetY`: the absolute difference between the source `y` position and the `y` position of the
* middle of this path.
* @example
* ```js
* const source = { x: 0, y: 20 };
* const target = { x: 150, y: 100 };
*
* const [path, labelX, labelY, offsetX, offsetY] = getBezierPath({
* sourceX: source.x,
* sourceY: source.y,
* sourcePosition: Side.Right,
* targetX: target.x,
* targetY: target.y,
* targetPosition: Side.Left,
*});
*```
*
* @remarks This function returns a tuple (aka a fixed-size array) to make it easier to
*work with multiple edge paths at once.
*/
export function getBezierPath({ sourceX, sourceY, sourcePosition = Side.Bottom, targetX, targetY, targetPosition = Side.Top, curvature = 0.25 }) {
const [sourceControlX, sourceControlY] = getControlWithCurvature({
pos: sourcePosition,
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
c: curvature
});
const [targetControlX, targetControlY] = getControlWithCurvature({
pos: targetPosition,
x1: targetX,
y1: targetY,
x2: sourceX,
y2: sourceY,
c: curvature
});
const [labelX, labelY, offsetX, offsetY] = getBezierEdgeCenter({
sourceX,
sourceY,
targetX,
targetY,
sourceControlX,
sourceControlY,
targetControlX,
targetControlY
});
return [
`M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`,
labelX,
labelY,
offsetX,
offsetY
];
}
// https://github.com/xyflow/xyflow/blob/main/packages/system/src/utils/edges/smoothstep-edge.ts
export function getEdgeCenter({ sourceX, sourceY, targetX, targetY, }) {
const xOffset = Math.abs(targetX - sourceX) / 2;
const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset;
const yOffset = Math.abs(targetY - sourceY) / 2;
const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset;
return [centerX, centerY, xOffset, yOffset];
}
const handleDirections = {
[Side.Left]: { x: -1, y: 0 },
[Side.Right]: { x: 1, y: 0 },
[Side.Top]: { x: 0, y: -1 },
[Side.Bottom]: { x: 0, y: 1 },
};
const getDirection = ({ source, sourcePosition = Side.Bottom, target, }) => {
if (sourcePosition === Side.Left || sourcePosition === Side.Right) {
return source.x < target.x ? { x: 1, y: 0 } : { x: -1, y: 0 };
}
return source.y < target.y ? { x: 0, y: 1 } : { x: 0, y: -1 };
};
const distance = (a, b) => Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2));
/*
* With this function we try to mimic an orthogonal edge routing behaviour
* It's not as good as a real orthogonal edge routing, but it's faster and good enough as a default for step and smooth step edges
*/
function getPoints({ source, sourcePosition = Side.Bottom, target, targetPosition = Side.Top, center, offset, }) {
const sourceDir = handleDirections[sourcePosition];
const targetDir = handleDirections[targetPosition];
const sourceGapped = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset };
const targetGapped = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset };
const dir = getDirection({
source: sourceGapped,
sourcePosition,
target: targetGapped,
});
const dirAccessor = dir.x !== 0 ? 'x' : 'y';
const currDir = dir[dirAccessor];
let points = [];
let centerX, centerY;
const sourceGapOffset = { x: 0, y: 0 };
const targetGapOffset = { x: 0, y: 0 };
const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({
sourceX: source.x,
sourceY: source.y,
targetX: target.x,
targetY: target.y,
});
// opposite handle positions, default case
if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) {
centerX = center.x ?? defaultCenterX;
centerY = center.y ?? defaultCenterY;
/*
* --->
* |
* >---
*/
const verticalSplit = [
{ x: centerX, y: sourceGapped.y },
{ x: centerX, y: targetGapped.y },
];
/*
* |
* ---
* |
*/
const horizontalSplit = [
{ x: sourceGapped.x, y: centerY },
{ x: targetGapped.x, y: centerY },
];
if (sourceDir[dirAccessor] === currDir) {
points = dirAccessor === 'x' ? verticalSplit : horizontalSplit;
}
else {
points = dirAccessor === 'x' ? horizontalSplit : verticalSplit;
}
}
else {
// sourceTarget means we take x from source and y from target, targetSource is the opposite
const sourceTarget = [{ x: sourceGapped.x, y: targetGapped.y }];
const targetSource = [{ x: targetGapped.x, y: sourceGapped.y }];
// this handles edges with same handle positions
if (dirAccessor === 'x') {
points = sourceDir.x === currDir ? targetSource : sourceTarget;
}
else {
points = sourceDir.y === currDir ? sourceTarget : targetSource;
}
if (sourcePosition === targetPosition) {
const diff = Math.abs(source[dirAccessor] - target[dirAccessor]);
// if an edge goes from right to right for example (sourcePosition === targetPosition) and the distance between source.x and target.x is less than the offset, the added point and the gapped source/target will overlap. This leads to a weird edge path. To avoid this we add a gapOffset to the source/target
if (diff <= offset) {
const gapOffset = Math.min(offset - 1, offset - diff);
if (sourceDir[dirAccessor] === currDir) {
sourceGapOffset[dirAccessor] = (sourceGapped[dirAccessor] > source[dirAccessor] ? -1 : 1) * gapOffset;
}
else {
targetGapOffset[dirAccessor] = (targetGapped[dirAccessor] > target[dirAccessor] ? -1 : 1) * gapOffset;
}
}
}
// these are conditions for handling mixed handle positions like Right -> Bottom for example
if (sourcePosition !== targetPosition) {
const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x';
const isSameDir = sourceDir[dirAccessor] === targetDir[dirAccessorOpposite];
const sourceGtTargetOppo = sourceGapped[dirAccessorOpposite] > targetGapped[dirAccessorOpposite];
const sourceLtTargetOppo = sourceGapped[dirAccessorOpposite] < targetGapped[dirAccessorOpposite];
const flipSourceTarget = (sourceDir[dirAccessor] === 1 && ((!isSameDir && sourceGtTargetOppo) || (isSameDir && sourceLtTargetOppo))) ||
(sourceDir[dirAccessor] !== 1 && ((!isSameDir && sourceLtTargetOppo) || (isSameDir && sourceGtTargetOppo)));
if (flipSourceTarget) {
points = dirAccessor === 'x' ? sourceTarget : targetSource;
}
}
const sourceGapPoint = { x: sourceGapped.x + sourceGapOffset.x, y: sourceGapped.y + sourceGapOffset.y };
const targetGapPoint = { x: targetGapped.x + targetGapOffset.x, y: targetGapped.y + targetGapOffset.y };
const maxXDistance = Math.max(Math.abs(sourceGapPoint.x - points[0].x), Math.abs(targetGapPoint.x - points[0].x));
const maxYDistance = Math.max(Math.abs(sourceGapPoint.y - points[0].y), Math.abs(targetGapPoint.y - points[0].y));
// we want to place the label on the longest segment of the edge
if (maxXDistance >= maxYDistance) {
centerX = (sourceGapPoint.x + targetGapPoint.x) / 2;
centerY = points[0].y;
}
else {
centerX = points[0].x;
centerY = (sourceGapPoint.y + targetGapPoint.y) / 2;
}
}
const pathPoints = [
source,
{ x: sourceGapped.x + sourceGapOffset.x, y: sourceGapped.y + sourceGapOffset.y },
...points,
{ x: targetGapped.x + targetGapOffset.x, y: targetGapped.y + targetGapOffset.y },
target,
];
return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY];
}
function getBend(a, b, c, size) {
const bendSize = Math.min(distance(a, b) / 2, distance(b, c) / 2, size);
const { x, y } = b;
// no bend
if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) {
return `L${x} ${y}`;
}
// first segment is horizontal
if (a.y === y) {
const xDir = a.x < c.x ? -1 : 1;
const yDir = a.y < c.y ? 1 : -1;
return `L ${x + bendSize * xDir},${y}Q ${x},${y} ${x},${y + bendSize * yDir}`;
}
const xDir = a.x < c.x ? 1 : -1;
const yDir = a.y < c.y ? -1 : 1;
return `L ${x},${y + bendSize * yDir}Q ${x},${y} ${x + bendSize * xDir},${y}`;
}
/**
* The `getSmoothStepPath` util returns everything you need to render a stepped path
* between two nodes. The `borderRadius` property can be used to choose how rounded
* the corners of those steps are.
* @public
* @returns A path string you can use in an SVG, the `labelX` and `labelY` position (center of path)
* and `offsetX`, `offsetY` between source handle and label.
*
* - `path`: the path to use in an SVG `<path>` element.
* - `labelX`: the `x` position you can use to render a label for this edge.
* - `labelY`: the `y` position you can use to render a label for this edge.
* - `offsetX`: the absolute difference between the source `x` position and the `x` position of the
* middle of this path.
* - `offsetY`: the absolute difference between the source `y` position and the `y` position of the
* middle of this path.
* @example
* ```js
* const source = { x: 0, y: 20 };
* const target = { x: 150, y: 100 };
*
* const [path, labelX, labelY, offsetX, offsetY] = getSmoothStepPath({
* sourceX: source.x,
* sourceY: source.y,
* sourcePosition: Side.Right,
* targetX: target.x,
* targetY: target.y,
* targetPosition: Side.Left,
* });
* ```
* @remarks This function returns a tuple (aka a fixed-size array) to make it easier to work with multiple edge paths at once.
*/
export function getSmoothStepPath({ sourceX, sourceY, sourcePosition = Side.Bottom, targetX, targetY, targetPosition = Side.Top, borderRadius = 5, centerX, centerY, offset = 20, }) {
const [points, labelX, labelY, offsetX, offsetY] = getPoints({
source: { x: sourceX, y: sourceY },
sourcePosition,
target: { x: targetX, y: targetY },
targetPosition,
center: { x: centerX, y: centerY },
offset,
});
const path = points.reduce((res, p, i) => {
let segment = '';
if (i > 0 && i < points.length - 1) {
segment = getBend(points[i - 1], p, points[i + 1], borderRadius);
}
else {
segment = `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`;
}
res += segment;
return res;
}, '');
return [path, labelX, labelY, offsetX, offsetY];
}