646 lines
20 KiB
JavaScript
646 lines
20 KiB
JavaScript
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
const DEFAULT_CLASS_NAME = "callout-connector";
|
|
const DEFAULT_DRAW_ANIMATION_NAME = "calloutConnectorDraw";
|
|
const DEFAULT_SOURCE_ANCHOR_GAP_PX = 6;
|
|
const MIN_SOURCE_ANCHOR_GAP_PX = 4;
|
|
|
|
function createSvgElement(tagName) {
|
|
return document.createElementNS(SVG_NS, tagName);
|
|
}
|
|
|
|
function resolveElementAnchorSide(side) {
|
|
switch (side) {
|
|
case "right":
|
|
case "top":
|
|
case "bottom":
|
|
case "left":
|
|
return side;
|
|
default:
|
|
return "left";
|
|
}
|
|
}
|
|
|
|
function resolveElementAnchorAlignRatio(ratio) {
|
|
if (!Number.isFinite(ratio)) return 0.5;
|
|
return Math.min(Math.max(ratio, 0), 1);
|
|
}
|
|
|
|
function resolveAnchorElement(target) {
|
|
if (target instanceof HTMLElement) return target;
|
|
if (target?.element instanceof HTMLElement) return target.element;
|
|
if (typeof target?.selector === "string") {
|
|
const matched = document.querySelector(target.selector);
|
|
return matched instanceof HTMLElement ? matched : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveRectElement(target) {
|
|
if (target instanceof HTMLElement) return target;
|
|
if (target?.element instanceof HTMLElement) return target.element;
|
|
if (typeof target?.selector === "string") {
|
|
const matched = document.querySelector(target.selector);
|
|
return matched instanceof HTMLElement ? matched : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isFiniteRect(rect) {
|
|
return (
|
|
rect &&
|
|
Number.isFinite(rect.left) &&
|
|
Number.isFinite(rect.top) &&
|
|
Number.isFinite(rect.right) &&
|
|
Number.isFinite(rect.bottom)
|
|
);
|
|
}
|
|
|
|
function normalizeRect(rect) {
|
|
if (!rect) return null;
|
|
const left = Number(rect.left);
|
|
const top = Number(rect.top);
|
|
const right = Number(rect.right);
|
|
const bottom = Number(rect.bottom);
|
|
if (
|
|
!Number.isFinite(left) ||
|
|
!Number.isFinite(top) ||
|
|
!Number.isFinite(right) ||
|
|
!Number.isFinite(bottom)
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
left: Math.min(left, right),
|
|
top: Math.min(top, bottom),
|
|
right: Math.max(left, right),
|
|
bottom: Math.max(top, bottom),
|
|
};
|
|
}
|
|
|
|
function expandRect(rect, padding = 0) {
|
|
const normalized = normalizeRect(rect);
|
|
if (!normalized) return null;
|
|
if (typeof padding === "object" && padding !== null) {
|
|
const leftPadding = Number.isFinite(padding.left) ? Number(padding.left) : 0;
|
|
const topPadding = Number.isFinite(padding.top) ? Number(padding.top) : 0;
|
|
const rightPadding = Number.isFinite(padding.right) ? Number(padding.right) : 0;
|
|
const bottomPadding = Number.isFinite(padding.bottom) ? Number(padding.bottom) : 0;
|
|
return {
|
|
left: normalized.left - leftPadding,
|
|
top: normalized.top - topPadding,
|
|
right: normalized.right + rightPadding,
|
|
bottom: normalized.bottom + bottomPadding,
|
|
};
|
|
}
|
|
return {
|
|
left: normalized.left - padding,
|
|
top: normalized.top - padding,
|
|
right: normalized.right + padding,
|
|
bottom: normalized.bottom + padding,
|
|
};
|
|
}
|
|
|
|
function dedupeSequentialPoints(points) {
|
|
const nextPoints = [];
|
|
for (const point of points) {
|
|
const previous = nextPoints[nextPoints.length - 1];
|
|
if (
|
|
previous &&
|
|
Math.abs(previous.x - point.x) < 0.5 &&
|
|
Math.abs(previous.y - point.y) < 0.5
|
|
) {
|
|
continue;
|
|
}
|
|
nextPoints.push(point);
|
|
}
|
|
return nextPoints;
|
|
}
|
|
|
|
// Compute the anchor point on the nearest perimeter edge of rect to source.
|
|
// gap is applied outward from the edge, so the anchor is outside the rect.
|
|
export function computeNearestPerimeterAnchor(source, rect, gap = 0) {
|
|
const { left, top, right, bottom } = rect;
|
|
const midX = (left + right) * 0.5;
|
|
const midY = (top + bottom) * 0.5;
|
|
|
|
if (source.x <= left) return { x: left - gap, y: midY, side: "left" };
|
|
if (source.x >= right) return { x: right + gap, y: midY, side: "right" };
|
|
if (source.y <= top) return { x: midX, y: top - gap, side: "top" };
|
|
if (source.y >= bottom) return { x: midX, y: bottom + gap, side: "bottom" };
|
|
|
|
// Source inside rect: snap to nearest edge midpoint
|
|
const dLeft = source.x - left;
|
|
const dRight = right - source.x;
|
|
const dTop = source.y - top;
|
|
const dBottom = bottom - source.y;
|
|
const minD = Math.min(dLeft, dRight, dTop, dBottom);
|
|
|
|
if (minD === dLeft) return { x: left - gap, y: midY, side: "left" };
|
|
if (minD === dRight) return { x: right + gap, y: midY, side: "right" };
|
|
if (minD === dTop) return { x: midX, y: top - gap, side: "top" };
|
|
return { x: midX, y: bottom + gap, side: "bottom" };
|
|
}
|
|
|
|
export function resolveConnectorObstacleRect(target, options = {}) {
|
|
if (!target) return null;
|
|
|
|
if (typeof target === "function") {
|
|
return resolveConnectorObstacleRect(target(), options);
|
|
}
|
|
|
|
const resolvedPadding =
|
|
target && (Number.isFinite(target.padding) || (typeof target.padding === "object" && target.padding))
|
|
? target.padding
|
|
: options.padding;
|
|
const padding =
|
|
Number.isFinite(resolvedPadding) || (typeof resolvedPadding === "object" && resolvedPadding)
|
|
? resolvedPadding
|
|
: 0;
|
|
|
|
if (isFiniteRect(target)) {
|
|
return expandRect(target, padding);
|
|
}
|
|
|
|
if (
|
|
Number.isFinite(target.x) &&
|
|
Number.isFinite(target.y) &&
|
|
Number.isFinite(target.width) &&
|
|
Number.isFinite(target.height)
|
|
) {
|
|
return expandRect(
|
|
{
|
|
left: Number(target.x),
|
|
top: Number(target.y),
|
|
right: Number(target.x) + Number(target.width),
|
|
bottom: Number(target.y) + Number(target.height),
|
|
},
|
|
padding,
|
|
);
|
|
}
|
|
|
|
const element = resolveRectElement(target);
|
|
if (!(element instanceof HTMLElement)) return null;
|
|
return expandRect(element.getBoundingClientRect(), padding);
|
|
}
|
|
|
|
export function resolveConnectorAnchor(target) {
|
|
if (!target) return null;
|
|
|
|
if (typeof target === "function") {
|
|
return resolveConnectorAnchor(target());
|
|
}
|
|
|
|
if (Number.isFinite(target.x) && Number.isFinite(target.y)) {
|
|
return { x: Number(target.x), y: Number(target.y) };
|
|
}
|
|
|
|
const element = resolveAnchorElement(target);
|
|
if (!(element instanceof HTMLElement)) return null;
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
const side = resolveElementAnchorSide(target.side);
|
|
const alignRatio = resolveElementAnchorAlignRatio(
|
|
target.alignRatio ?? target.anchorRatio ?? target.ratio,
|
|
);
|
|
const offsetX = Number.isFinite(target.offsetX) ? Number(target.offsetX) : 0;
|
|
const offsetY = Number.isFinite(target.offsetY) ? Number(target.offsetY) : 0;
|
|
|
|
let x = rect.left + rect.width * 0.5;
|
|
let y = rect.top + rect.height * 0.5;
|
|
|
|
if (side === "left") {
|
|
x = rect.left;
|
|
y = rect.top + rect.height * alignRatio;
|
|
} else if (side === "right") {
|
|
x = rect.right;
|
|
y = rect.top + rect.height * alignRatio;
|
|
} else if (side === "top") {
|
|
x = rect.left + rect.width * alignRatio;
|
|
y = rect.top;
|
|
} else if (side === "bottom") {
|
|
x = rect.left + rect.width * alignRatio;
|
|
y = rect.bottom;
|
|
}
|
|
|
|
return {
|
|
x: x + offsetX,
|
|
y: y + offsetY,
|
|
};
|
|
}
|
|
|
|
function resolveConnectorRect(target) {
|
|
return resolveConnectorObstacleRect(target, { padding: 0 });
|
|
}
|
|
|
|
function createRectSideMidpoint(rect, side, gap = 0) {
|
|
const normalizedRect = normalizeRect(rect);
|
|
if (!normalizedRect) return null;
|
|
|
|
const midpointX = (normalizedRect.left + normalizedRect.right) * 0.5;
|
|
const midpointY = (normalizedRect.top + normalizedRect.bottom) * 0.5;
|
|
|
|
if (side === "left") {
|
|
return { x: normalizedRect.left - gap, y: midpointY, side };
|
|
}
|
|
if (side === "right") {
|
|
return { x: normalizedRect.right + gap, y: midpointY, side };
|
|
}
|
|
if (side === "top") {
|
|
return { x: midpointX, y: normalizedRect.top - gap, side };
|
|
}
|
|
if (side === "bottom") {
|
|
return { x: midpointX, y: normalizedRect.bottom + gap, side };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
function createRectEdgeAnchor(rect, side, position, gap = 0) {
|
|
const normalizedRect = normalizeRect(rect);
|
|
if (!normalizedRect) return null;
|
|
|
|
const x =
|
|
side === "left"
|
|
? normalizedRect.left - gap
|
|
: side === "right"
|
|
? normalizedRect.right + gap
|
|
: clamp(
|
|
Number.isFinite(position?.x) ? Number(position.x) : (normalizedRect.left + normalizedRect.right) * 0.5,
|
|
normalizedRect.left,
|
|
normalizedRect.right,
|
|
);
|
|
const y =
|
|
side === "top"
|
|
? normalizedRect.top - gap
|
|
: side === "bottom"
|
|
? normalizedRect.bottom + gap
|
|
: clamp(
|
|
Number.isFinite(position?.y) ? Number(position.y) : (normalizedRect.top + normalizedRect.bottom) * 0.5,
|
|
normalizedRect.top,
|
|
normalizedRect.bottom,
|
|
);
|
|
|
|
return { x, y, side };
|
|
}
|
|
|
|
function createOrthogonalPointsFromDirections(startPoint, endPoint, directions = []) {
|
|
if (!startPoint || !endPoint) return null;
|
|
const normalizedDirections = directions.filter(Boolean);
|
|
if (!normalizedDirections.length) {
|
|
return dedupeSequentialPoints([startPoint, endPoint]);
|
|
}
|
|
|
|
const firstDirection = normalizedDirections[0];
|
|
const corner =
|
|
firstDirection === "left" || firstDirection === "right"
|
|
? { x: endPoint.x, y: startPoint.y }
|
|
: { x: startPoint.x, y: endPoint.y };
|
|
|
|
return dedupeSequentialPoints([startPoint, corner, endPoint]);
|
|
}
|
|
|
|
function resolveSourceAnchorGapPx(sourceGapPx) {
|
|
if (!Number.isFinite(sourceGapPx)) return DEFAULT_SOURCE_ANCHOR_GAP_PX;
|
|
return Math.max(MIN_SOURCE_ANCHOR_GAP_PX, Math.round(sourceGapPx * 0.4));
|
|
}
|
|
|
|
export function createElbowConnectorPoints(source, target, options = {}) {
|
|
const resolvedSource = resolveConnectorAnchor(source);
|
|
const resolvedTarget = resolveConnectorAnchor(target);
|
|
if (!resolvedSource || !resolvedTarget) return null;
|
|
|
|
const {
|
|
startFrom = "source",
|
|
sourceGapPx = 12,
|
|
targetGapPx = 8,
|
|
elbowOffsetPx = 18,
|
|
elbowDropPx = 14,
|
|
} = options;
|
|
|
|
const sourcePoint = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
|
const targetPoint = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
|
if (
|
|
!Number.isFinite(sourcePoint.x) ||
|
|
!Number.isFinite(sourcePoint.y) ||
|
|
!Number.isFinite(targetPoint.x) ||
|
|
!Number.isFinite(targetPoint.y)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const horizontalDirection = sourcePoint.x <= targetPoint.x ? 1 : -1;
|
|
const startX = sourcePoint.x + horizontalDirection * sourceGapPx;
|
|
const startY = sourcePoint.y;
|
|
const endX = targetPoint.x - horizontalDirection * targetGapPx;
|
|
const endY = targetPoint.y;
|
|
const elbowX = endX - horizontalDirection * elbowOffsetPx;
|
|
const elbowY = Math.min(startY, endY) + elbowDropPx;
|
|
|
|
if (Math.abs(endX - startX) < 8 && Math.abs(endY - startY) < 8) {
|
|
return null;
|
|
}
|
|
|
|
const orderedPoints = [
|
|
{ x: startX, y: startY },
|
|
{ x: elbowX, y: elbowY },
|
|
{ x: endX, y: endY },
|
|
];
|
|
|
|
return {
|
|
points: startFrom === "target" ? orderedPoints.slice().reverse() : orderedPoints,
|
|
start: startFrom === "target" ? orderedPoints[2] : orderedPoints[0],
|
|
end: startFrom === "target" ? orderedPoints[0] : orderedPoints[2],
|
|
};
|
|
}
|
|
|
|
function createAdaptiveConnectorPoints(source, target, options = {}) {
|
|
const resolvedSource = resolveConnectorAnchor(source);
|
|
if (!resolvedSource) return null;
|
|
|
|
const {
|
|
startFrom = "source",
|
|
sourceGapPx = 12,
|
|
targetGapPx = 8,
|
|
obstacleClearancePx = 8,
|
|
obstacles = [],
|
|
targetAnchor = null,
|
|
sourceRect = null,
|
|
} = options;
|
|
|
|
const sp = { x: Number(resolvedSource.x), y: Number(resolvedSource.y) };
|
|
if (!Number.isFinite(sp.x) || !Number.isFinite(sp.y)) return null;
|
|
|
|
const normalizedObstacles = (Array.isArray(obstacles) ? obstacles : [obstacles])
|
|
.map((obstacle) => resolveConnectorObstacleRect(obstacle, { padding: obstacleClearancePx }))
|
|
.filter(Boolean);
|
|
|
|
const fallbackTargetRect = resolveConnectorObstacleRect(target, { padding: 0 });
|
|
const resolvedTarget =
|
|
resolveConnectorAnchor(targetAnchor ?? target) ||
|
|
(fallbackTargetRect
|
|
? computeNearestPerimeterAnchor(
|
|
sp,
|
|
fallbackTargetRect,
|
|
Math.max(targetGapPx, obstacleClearancePx + 1),
|
|
)
|
|
: null);
|
|
if (!resolvedTarget) return null;
|
|
|
|
const end = { x: Number(resolvedTarget.x), y: Number(resolvedTarget.y) };
|
|
if (!Number.isFinite(end.x) || !Number.isFinite(end.y)) return null;
|
|
|
|
const primaryObstacle = normalizedObstacles[0] || null;
|
|
const relationRect = fallbackTargetRect || primaryObstacle;
|
|
if (!relationRect) return null;
|
|
|
|
const sourceRelationRect = resolveConnectorRect(sourceRect ?? source);
|
|
const targetCenterX = (relationRect.left + relationRect.right) * 0.5;
|
|
const targetCenterY = (relationRect.top + relationRect.bottom) * 0.5;
|
|
|
|
const leftMidpoint = createRectSideMidpoint(relationRect, "left", targetGapPx);
|
|
const rightMidpoint = createRectSideMidpoint(relationRect, "right", targetGapPx);
|
|
const isTargetAbove = relationRect.bottom < sp.y;
|
|
const isTargetBelow = relationRect.top > sp.y;
|
|
const isSourceWithinAnchorHorizontalRange =
|
|
sp.x >= leftMidpoint.x && sp.x <= rightMidpoint.x;
|
|
const isRightMidpointLeftOfSource = rightMidpoint.x < sp.x;
|
|
const isLeftMidpointRightOfSource = leftMidpoint.x > sp.x;
|
|
|
|
let directions = [];
|
|
let targetSide = null;
|
|
const isTargetCenterWithinSourceVerticalRange =
|
|
sourceRelationRect &&
|
|
targetCenterY >= sourceRelationRect.top &&
|
|
targetCenterY <= sourceRelationRect.bottom;
|
|
const isTargetCenterWithinSourceHorizontalRange =
|
|
sourceRelationRect &&
|
|
targetCenterX >= sourceRelationRect.left &&
|
|
targetCenterX <= sourceRelationRect.right;
|
|
|
|
if (isRightMidpointLeftOfSource) {
|
|
targetSide = "right";
|
|
if (isTargetCenterWithinSourceVerticalRange) {
|
|
directions = ["left"];
|
|
} else if (rightMidpoint.y < sp.y) {
|
|
directions = ["top", "left"];
|
|
} else if (rightMidpoint.y > sp.y) {
|
|
directions = ["bottom", "left"];
|
|
} else {
|
|
directions = ["left"];
|
|
}
|
|
} else if (isLeftMidpointRightOfSource) {
|
|
targetSide = "left";
|
|
if (isTargetCenterWithinSourceVerticalRange) {
|
|
directions = ["right"];
|
|
} else if (leftMidpoint.y < sp.y) {
|
|
directions = ["top", "right"];
|
|
} else if (leftMidpoint.y > sp.y) {
|
|
directions = ["bottom", "right"];
|
|
} else {
|
|
directions = ["right"];
|
|
}
|
|
} else if (isSourceWithinAnchorHorizontalRange) {
|
|
if (isTargetAbove) {
|
|
targetSide = "bottom";
|
|
directions = isTargetCenterWithinSourceHorizontalRange
|
|
? ["top"]
|
|
: targetCenterX >= sp.x
|
|
? ["right", "top"]
|
|
: ["left", "top"];
|
|
} else if (isTargetBelow) {
|
|
targetSide = "top";
|
|
directions = isTargetCenterWithinSourceHorizontalRange
|
|
? ["bottom"]
|
|
: targetCenterX >= sp.x
|
|
? ["right", "bottom"]
|
|
: ["left", "bottom"];
|
|
}
|
|
}
|
|
|
|
if (!targetSide || !directions.length) {
|
|
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
|
}
|
|
|
|
const derivedTargetAnchor = createRectSideMidpoint(relationRect, targetSide, targetGapPx);
|
|
const targetPoint =
|
|
resolvedTarget && targetAnchor
|
|
? end
|
|
: derivedTargetAnchor || end;
|
|
|
|
const sourceSide = directions[0] || null;
|
|
const sourceAnchorGapPx = resolveSourceAnchorGapPx(sourceGapPx);
|
|
const shouldSlideSourceAnchorAlongEdge =
|
|
(sourceSide === "left" || sourceSide === "right") &&
|
|
isTargetCenterWithinSourceVerticalRange ||
|
|
(sourceSide === "top" || sourceSide === "bottom") &&
|
|
isTargetCenterWithinSourceHorizontalRange;
|
|
const derivedSourceAnchor =
|
|
sourceRelationRect && sourceSide
|
|
? shouldSlideSourceAnchorAlongEdge
|
|
? createRectEdgeAnchor(sourceRelationRect, sourceSide, targetPoint, sourceAnchorGapPx)
|
|
: createRectSideMidpoint(sourceRelationRect, sourceSide, sourceAnchorGapPx)
|
|
: null;
|
|
const startPoint = derivedSourceAnchor || sp;
|
|
|
|
let pts = createOrthogonalPointsFromDirections(startPoint, targetPoint, directions);
|
|
if (!pts) {
|
|
return createElbowConnectorPoints(source, targetAnchor ?? target, options);
|
|
}
|
|
pts = dedupeSequentialPoints(pts);
|
|
return {
|
|
points: startFrom === "target" ? pts.slice().reverse() : pts,
|
|
start: startFrom === "target" ? pts[pts.length - 1] : pts[0],
|
|
end: startFrom === "target" ? pts[0] : pts[pts.length - 1],
|
|
};
|
|
}
|
|
|
|
export function createConnectorPath(source, target, options = {}) {
|
|
const {
|
|
routingMode = "simple",
|
|
} = options;
|
|
|
|
if (routingMode === "adaptive") {
|
|
return createAdaptiveConnectorPoints(source, target, options);
|
|
}
|
|
|
|
if (routingMode === "simple") {
|
|
return createElbowConnectorPoints(source, target, options);
|
|
}
|
|
|
|
return createElbowConnectorPoints(source, target, options);
|
|
}
|
|
|
|
export class CalloutConnector {
|
|
constructor({
|
|
container = null,
|
|
containerId = "container",
|
|
className = DEFAULT_CLASS_NAME,
|
|
drawAnimationName = DEFAULT_DRAW_ANIMATION_NAME,
|
|
} = {}) {
|
|
this.container = container;
|
|
this.containerId = containerId;
|
|
this.className = className;
|
|
this.drawAnimationName = drawAnimationName;
|
|
this.connectorEl = null;
|
|
this.polylineEl = null;
|
|
this.startpointEl = null;
|
|
this.endpointEl = null;
|
|
}
|
|
|
|
resolveContainer() {
|
|
if (this.container instanceof HTMLElement) return this.container;
|
|
this.container = document.getElementById(this.containerId);
|
|
return this.container instanceof HTMLElement ? this.container : null;
|
|
}
|
|
|
|
ensure() {
|
|
if (this.connectorEl instanceof SVGSVGElement) {
|
|
return this.connectorEl;
|
|
}
|
|
|
|
const container = this.resolveContainer();
|
|
if (!container) return null;
|
|
|
|
const connector = createSvgElement("svg");
|
|
connector.setAttribute("class", this.className);
|
|
connector.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
|
|
connector.setAttribute("preserveAspectRatio", "none");
|
|
|
|
const polyline = createSvgElement("polyline");
|
|
const startpoint = createSvgElement("circle");
|
|
const endpoint = createSvgElement("circle");
|
|
startpoint.setAttribute("r", "4");
|
|
endpoint.setAttribute("r", "4");
|
|
|
|
connector.append(startpoint, polyline, endpoint);
|
|
container.appendChild(connector);
|
|
|
|
connector.addEventListener("animationend", (event) => {
|
|
if (
|
|
event.animationName === this.drawAnimationName &&
|
|
this.connectorEl?.classList.contains("is-visible")
|
|
) {
|
|
if (this.polylineEl) {
|
|
this.polylineEl.style.strokeDashoffset = "0";
|
|
}
|
|
this.connectorEl?.classList.remove("is-animating");
|
|
}
|
|
});
|
|
|
|
this.connectorEl = connector;
|
|
this.polylineEl = polyline;
|
|
this.startpointEl = startpoint;
|
|
this.endpointEl = endpoint;
|
|
return connector;
|
|
}
|
|
|
|
isVisible() {
|
|
return this.connectorEl?.classList.contains("is-visible") === true;
|
|
}
|
|
|
|
isAnimating() {
|
|
return this.connectorEl?.classList.contains("is-animating") === true;
|
|
}
|
|
|
|
hide() {
|
|
const connector = this.ensure();
|
|
if (!connector) return;
|
|
connector.classList.remove("is-visible", "is-animating");
|
|
}
|
|
|
|
render(path, { animate = false } = {}) {
|
|
const connector = this.ensure();
|
|
if (
|
|
!connector ||
|
|
!this.polylineEl ||
|
|
!this.startpointEl ||
|
|
!this.endpointEl ||
|
|
!Array.isArray(path?.points) ||
|
|
path.points.length < 2
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const viewWidth = window.innerWidth;
|
|
const viewHeight = window.innerHeight;
|
|
connector.setAttribute("viewBox", `0 0 ${viewWidth} ${viewHeight}`);
|
|
|
|
const pointsText = path.points
|
|
.map((point) => `${point.x.toFixed(2)},${point.y.toFixed(2)}`)
|
|
.join(" ");
|
|
this.polylineEl.setAttribute("points", pointsText);
|
|
this.startpointEl.setAttribute("cx", path.start.x.toFixed(2));
|
|
this.startpointEl.setAttribute("cy", path.start.y.toFixed(2));
|
|
this.endpointEl.setAttribute("cx", path.end.x.toFixed(2));
|
|
this.endpointEl.setAttribute("cy", path.end.y.toFixed(2));
|
|
|
|
const totalLength =
|
|
typeof this.polylineEl.getTotalLength === "function"
|
|
? this.polylineEl.getTotalLength()
|
|
: 0;
|
|
|
|
this.polylineEl.style.strokeDasharray = totalLength > 0 ? `${totalLength}` : "";
|
|
this.polylineEl.style.strokeDashoffset =
|
|
totalLength > 0 ? `${animate ? totalLength : 0}` : "";
|
|
connector.style.setProperty(
|
|
"--connector-length",
|
|
totalLength > 0 ? `${totalLength}` : "0px",
|
|
);
|
|
connector.classList.add("is-visible");
|
|
|
|
if (animate && totalLength > 0) {
|
|
connector.classList.remove("is-animating");
|
|
void connector.getBoundingClientRect();
|
|
this.polylineEl.style.strokeDashoffset = `${totalLength}`;
|
|
connector.classList.add("is-animating");
|
|
} else {
|
|
connector.classList.remove("is-animating");
|
|
}
|
|
|
|
return totalLength > 0;
|
|
}
|
|
}
|