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; } }