import { getBoxToBoxArrow } from "perfect-arrows"; import { getStroke, type StrokeOptions } from "perfect-freehand"; import { FolkElement } from "./folk-element"; import { css } from "./tags"; // Point interface for bezier curves interface Point { x: number; y: number; } // Utility functions for bezier curve rendering function lerp(a: Point, b: Point, t: number): Point { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; } function distanceSquared(a: Point, b: Point): number { const dx = b.x - a.x; const dy = b.y - a.y; return dx * dx + dy * dy; } function distance(a: Point, b: Point): number { return Math.sqrt(distanceSquared(a, b)); } function flatness(points: readonly Point[], offset: number): number { const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; let ux = 3 * p2.x - 2 * p1.x - p4.x; ux *= ux; let uy = 3 * p2.y - 2 * p1.y - p4.y; uy *= uy; let vx = 3 * p3.x - 2 * p4.x - p1.x; vx *= vx; let vy = 3 * p3.y - 2 * p4.y - p1.y; vy *= vy; if (ux < vx) ux = vx; if (uy < vy) uy = vy; return ux + uy; } function getPointsOnBezierCurveWithSplitting( points: readonly Point[], offset: number, tolerance: number, outPoints: Point[] = [], ): Point[] { if (flatness(points, offset) < tolerance) { const p0 = points[offset + 0]; if (outPoints.length) { const d = distance(outPoints[outPoints.length - 1], p0); if (d > 1) { outPoints.push(p0); } } else { outPoints.push(p0); } outPoints.push(points[offset + 3]); } else { const t = 0.5; const p1 = points[offset + 0]; const p2 = points[offset + 1]; const p3 = points[offset + 2]; const p4 = points[offset + 3]; const q1 = lerp(p1, p2, t); const q2 = lerp(p2, p3, t); const q3 = lerp(p3, p4, t); const r1 = lerp(q1, q2, t); const r2 = lerp(q2, q3, t); const red = lerp(r1, r2, t); getPointsOnBezierCurveWithSplitting([p1, q1, r1, red], 0, tolerance, outPoints); getPointsOnBezierCurveWithSplitting([red, r2, q3, p4], 0, tolerance, outPoints); } return outPoints; } function pointsOnBezierCurves(points: readonly Point[], tolerance: number = 0.15): Point[] { const newPoints: Point[] = []; const numSegments = (points.length - 1) / 3; for (let i = 0; i < numSegments; i++) { const offset = i * 3; getPointsOnBezierCurveWithSplitting(points, offset, tolerance, newPoints); } return newPoints; } function getSvgPathFromStroke(stroke: number[][]): string { if (stroke.length === 0) return ""; for (const point of stroke) { point[0] = Math.round(point[0] * 100) / 100; point[1] = Math.round(point[1] * 100) / 100; } const d = stroke.reduce( (acc, [x0, y0], i, arr) => { const [x1, y1] = arr[(i + 1) % arr.length]; acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); return acc; }, ["M", ...stroke[0], "Q"] as (string | number)[], ); d.push("Z"); return d.join(" "); } const styles = css` :host { display: block; position: absolute; inset: 0; pointer-events: none; z-index: -1; } `; declare global { interface HTMLElementTagNameMap { "folk-arrow": FolkArrow; } } export class FolkArrow extends FolkElement { static override tagName = "folk-arrow"; static styles = styles; #sourceSelector: string = ""; #targetSelector: string = ""; #sourceElement: Element | null = null; #targetElement: Element | null = null; #sourceRect: DOMRect | null = null; #targetRect: DOMRect | null = null; #resizeObserver: ResizeObserver; #color: string = "#374151"; #strokeWidth: number = 3; #options: StrokeOptions = { size: 7, thinning: 0.5, smoothing: 0.5, streamline: 0.5, simulatePressure: true, easing: (t) => t, start: { taper: 50, easing: (t) => t, cap: true, }, end: { taper: 0, easing: (t) => t, cap: true, }, }; constructor() { super(); this.#resizeObserver = new ResizeObserver(() => this.#updateArrow()); } get source() { return this.#sourceSelector; } set source(value: string) { this.#sourceSelector = value; this.#observeSource(); this.requestUpdate("source"); } get target() { return this.#targetSelector; } set target(value: string) { this.#targetSelector = value; this.#observeTarget(); this.requestUpdate("target"); } get sourceId() { return this.#sourceSelector.replace("#", ""); } set sourceId(value: string) { this.source = `#${value}`; } get targetId() { return this.#targetSelector.replace("#", ""); } set targetId(value: string) { this.target = `#${value}`; } get color() { return this.#color; } set color(value: string) { this.#color = value; this.#updateArrow(); } get strokeWidth() { return this.#strokeWidth; } set strokeWidth(value: number) { this.#strokeWidth = value; this.#options.size = value * 2 + 1; this.#updateArrow(); } override connectedCallback() { super.connectedCallback(); // Parse attributes const sourceAttr = this.getAttribute("source"); const targetAttr = this.getAttribute("target"); const colorAttr = this.getAttribute("color"); const strokeAttr = this.getAttribute("stroke-width"); if (sourceAttr) this.source = sourceAttr; if (targetAttr) this.target = targetAttr; if (colorAttr) this.#color = colorAttr; if (strokeAttr) this.strokeWidth = parseFloat(strokeAttr); // Start animation frame loop for position updates this.#startPositionTracking(); } override disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver.disconnect(); this.#stopPositionTracking(); } #animationFrameId: number | null = null; #startPositionTracking() { const track = () => { this.#updateRects(); this.#updateArrow(); this.#animationFrameId = requestAnimationFrame(track); }; this.#animationFrameId = requestAnimationFrame(track); } #stopPositionTracking() { if (this.#animationFrameId !== null) { cancelAnimationFrame(this.#animationFrameId); this.#animationFrameId = null; } } #observeSource() { if (this.#sourceElement) { this.#resizeObserver.unobserve(this.#sourceElement); } if (this.#sourceSelector) { this.#sourceElement = document.querySelector(this.#sourceSelector); if (this.#sourceElement) { this.#resizeObserver.observe(this.#sourceElement); } } } #observeTarget() { if (this.#targetElement) { this.#resizeObserver.unobserve(this.#targetElement); } if (this.#targetSelector) { this.#targetElement = document.querySelector(this.#targetSelector); if (this.#targetElement) { this.#resizeObserver.observe(this.#targetElement); } } } #updateRects() { if (this.#sourceElement) { this.#sourceRect = this.#sourceElement.getBoundingClientRect(); } if (this.#targetElement) { this.#targetRect = this.#targetElement.getBoundingClientRect(); } } #updateArrow() { if (!this.#sourceRect || !this.#targetRect) { this.style.clipPath = ""; this.style.display = "none"; return; } this.style.display = ""; const [sx, sy, cx, cy, ex, ey] = getBoxToBoxArrow( this.#sourceRect.x, this.#sourceRect.y, this.#sourceRect.width, this.#sourceRect.height, this.#targetRect.x, this.#targetRect.y, this.#targetRect.width, this.#targetRect.height, ); const points = pointsOnBezierCurves([ { x: sx, y: sy }, { x: cx, y: cy }, { x: ex, y: ey }, { x: ex, y: ey }, ]); const stroke = getStroke(points, this.#options); const path = getSvgPathFromStroke(stroke); this.style.clipPath = `path('${path}')`; this.style.backgroundColor = this.#color; } override createRenderRoot() { const root = super.createRenderRoot(); this.#updateArrow(); return root; } toJSON() { return { type: "folk-arrow", id: this.id, sourceId: this.sourceId, targetId: this.targetId, color: this.#color, strokeWidth: this.#strokeWidth, }; } }