import { getBoxToBoxArrow } from "perfect-arrows"; import { getStroke, type StrokeOptions } from "perfect-freehand"; import { FolkElement } from "./folk-element"; import { css } from "./tags"; import { isCompatible, dataTypeColor, inferFlowKind, flowKindColor } from "./data-types"; // 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 type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy"; /** A governance gate on an arrow — data only flows when the gate is open. */ export interface ArrowGate { shapeId: string; // governance shape ID portName: string; // port to watch (e.g. "decision-out") condition: "truthy" | "passed" | "threshold"; threshold?: number; } /** An inline data transform on a pipe arrow. */ export interface ArrowTransform { type: "filter" | "map" | "pick" | "count" | "first" | "last"; expression: string; // safe dot-path, NO eval() } 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; #arrowStyle: ArrowStyle = "smooth"; // Data piping #sourcePort: string = ""; #targetPort: string = ""; #pipeDebounce: ReturnType | null = null; #portListener: ((e: Event) => void) | null = null; // Flow bridge #flowId: string = ""; get flowId() { return this.#flowId; } set flowId(value: string) { this.#flowId = value; } // Governance gate #gate: ArrowGate | null = null; #gateOpen: boolean = true; #gateListener: ((e: Event) => void) | null = null; get gate(): ArrowGate | null { return this.#gate; } set gate(value: ArrowGate | null) { this.#teardownGate(); this.#gate = value; if (value) this.#setupGate(); this.#updateArrow(); } // Data transforms #transform: ArrowTransform | null = null; get transform(): ArrowTransform | null { return this.#transform; } set transform(value: ArrowTransform | null) { this.#transform = value; this.#updateArrow(); } get sourcePort() { return this.#sourcePort; } set sourcePort(value: string) { this.#sourcePort = value; this.#setupPipe(); } get targetPort() { return this.#targetPort; } set targetPort(value: string) { this.#targetPort = value; this.#setupPipe(); } /** True when this arrow connects ports (is a data pipe). */ get isPipe(): boolean { return !!(this.#sourcePort && this.#targetPort); } #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(); } get arrowStyle(): ArrowStyle { return this.#arrowStyle; } set arrowStyle(value: ArrowStyle) { if (value === this.#arrowStyle) return; this.#arrowStyle = value; this.setAttribute("arrow-style", value); 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"); const styleAttr = this.getAttribute("arrow-style"); if (sourceAttr) this.source = sourceAttr; if (targetAttr) this.target = targetAttr; if (colorAttr) this.#color = colorAttr; if (strokeAttr) this.strokeWidth = parseFloat(strokeAttr); if (styleAttr) this.#arrowStyle = styleAttr as ArrowStyle; // Start animation frame loop for position updates this.#startPositionTracking(); } override disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver.disconnect(); this.#stopPositionTracking(); this.#teardownPipe(); this.#teardownGate(); } #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; } } /** Set up data pipe listener when both ports are connected. */ #setupPipe(): void { this.#teardownPipe(); if (!this.#sourcePort || !this.#targetPort) { // Reset arrow color when pipe is disconnected this.#updateArrow(); return; } const srcEl = this.#sourceElement as any; const tgtEl = this.#targetElement as any; if (!srcEl || !tgtEl) return; // Check type compatibility const srcPort = srcEl.getPort?.(this.#sourcePort); const tgtPort = tgtEl.getPort?.(this.#targetPort); if (srcPort && tgtPort && !isCompatible(srcPort.type, tgtPort.type)) { console.warn(`[FolkArrow] Type mismatch: ${srcPort.type} -> ${tgtPort.type}`); return; } // Tint arrow color: use FlowKind if both endpoints are folk-rapp, else DataType if (srcPort) { const bothRapp = srcEl.tagName === "FOLK-RAPP" && tgtEl.tagName === "FOLK-RAPP"; this.#color = bothRapp ? flowKindColor(inferFlowKind(this.#sourcePort)) : dataTypeColor(srcPort.type); this.#updateArrow(); } // Listen for port value changes on the source this.#portListener = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail.name !== this.#sourcePort) return; // Debounce to avoid rapid-fire updates if (this.#pipeDebounce) clearTimeout(this.#pipeDebounce); this.#pipeDebounce = setTimeout(() => { // Gate check: skip if gate is closed if (!this.#gateOpen) return; // Apply transform if present let value = this.#transform ? this.#applyTransform(detail.value) : detail.value; if (tgtEl.setPortValue) { tgtEl.setPortValue(this.#targetPort, value); } }, 100); }; srcEl.addEventListener("port-value-changed", this.#portListener); } /** Remove pipe listener. */ #teardownPipe(): void { if (this.#portListener && this.#sourceElement) { this.#sourceElement.removeEventListener("port-value-changed", this.#portListener); this.#portListener = null; } if (this.#pipeDebounce) { clearTimeout(this.#pipeDebounce); this.#pipeDebounce = null; } } /** Set up gate listener on a governance shape. */ #setupGate(): void { if (!this.#gate) return; const gateEl = document.getElementById(this.#gate.shapeId) as any; if (!gateEl) return; this.#gateListener = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail.name !== this.#gate!.portName) return; this.#evaluateGate(detail.value); }; gateEl.addEventListener("port-value-changed", this.#gateListener); // Evaluate current value immediately const currentVal = gateEl.getPortValue?.(this.#gate.portName); if (currentVal !== undefined) this.#evaluateGate(currentVal); } /** Remove gate listener. */ #teardownGate(): void { if (this.#gateListener && this.#gate) { const gateEl = document.getElementById(this.#gate.shapeId); if (gateEl) { gateEl.removeEventListener("port-value-changed", this.#gateListener); } } this.#gateListener = null; this.#gateOpen = true; } /** Evaluate the gate condition against the current value. */ #evaluateGate(value: unknown): void { const wasOpen = this.#gateOpen; if (!this.#gate) { this.#gateOpen = true; } else if (this.#gate.condition === "truthy") { this.#gateOpen = !!value; } else if (this.#gate.condition === "passed") { const v = value as any; this.#gateOpen = !!(v?.passed || v?.decided || v?.winner); } else if (this.#gate.condition === "threshold") { const v = value as any; const num = typeof v === "number" ? v : (v?.margin ?? v?.score ?? 0); this.#gateOpen = num >= (this.#gate.threshold ?? 0.5); } if (wasOpen !== this.#gateOpen) this.#updateArrow(); } /** Safe dot-path accessor: getPath({a: {b: 1}}, "a.b") → 1 */ static #getPath(obj: any, path: string): any { if (!obj || !path) return obj; const parts = path.split("."); let current = obj; for (const part of parts) { if (current == null) return undefined; // Support bracket notation: items[0] const match = part.match(/^(\w+)\[(\d+)\]$/); if (match) { current = current[match[1]]; if (Array.isArray(current)) current = current[parseInt(match[2])]; else return undefined; } else { current = current[part]; } } return current; } /** Evaluate a simple predicate (no eval): "status === 'DONE'" */ static #evalPredicate(item: any, expr: string): boolean { // Parse: field op value const match = expr.match(/^([\w.[\]]+)\s*(===|!==|>=|<=|>|<)\s*['"]?([^'"]*?)['"]?$/); if (!match) return !!item; const [, field, op, val] = match; const fieldVal = FolkArrow.#getPath(item, field); const numVal = Number(val); const useNum = !isNaN(numVal) && typeof fieldVal === "number"; switch (op) { case "===": return useNum ? fieldVal === numVal : String(fieldVal) === val; case "!==": return useNum ? fieldVal !== numVal : String(fieldVal) !== val; case ">": return useNum ? fieldVal > numVal : false; case "<": return useNum ? fieldVal < numVal : false; case ">=": return useNum ? fieldVal >= numVal : false; case "<=": return useNum ? fieldVal <= numVal : false; default: return false; } } /** Apply the transform to a data value. */ #applyTransform(value: unknown): unknown { if (!this.#transform) return value; const { type, expression } = this.#transform; const arr = Array.isArray(value) ? value : [value]; switch (type) { case "filter": return arr.filter(item => FolkArrow.#evalPredicate(item, expression)); case "map": return arr.map(item => FolkArrow.#getPath(item, expression)); case "pick": // Pick specific fields: "title,status,id" const fields = expression.split(",").map(f => f.trim()); return arr.map(item => { const result: Record = {}; for (const f of fields) { result[f] = FolkArrow.#getPath(item, f); } return result; }); case "count": return arr.length; case "first": return arr[0]; case "last": return arr[arr.length - 1]; default: return value; } } #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); } } if (this.isPipe) this.#setupPipe(); } #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); } } if (this.isPipe) this.#setupPipe(); } #updateRects() { if (this.#sourceElement) { this.#sourceRect = this.#sourceElement.getBoundingClientRect(); } if (this.#targetElement) { this.#targetRect = this.#targetElement.getBoundingClientRect(); } } #svg: SVGSVGElement | null = null; #updateArrow() { if (!this.#sourceRect || !this.#targetRect) { this.style.clipPath = ""; this.style.display = "none"; if (this.#svg) this.#svg.innerHTML = ""; 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, ); // Gate visual: dim and use gray when gated closed const effectiveColor = (this.#gate && !this.#gateOpen) ? "#94a3b8" : this.#color; const gatedClosed = this.#gate && !this.#gateOpen; if (this.#arrowStyle === "smooth") { // Original behavior: perfect-freehand tapered stroke via clipPath if (this.#svg) this.#svg.style.display = "none"; this.style.clipPath = ""; this.style.backgroundColor = ""; 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 = effectiveColor; this.style.opacity = gatedClosed ? "0.4" : ""; } else { // SVG-based rendering for other styles this.style.clipPath = ""; this.style.backgroundColor = ""; if (!this.#svg) return; this.#svg.style.display = ""; const sw = this.#strokeWidth; let pathD = ""; let arrowheadPoints = ""; if (this.#arrowStyle === "straight") { pathD = `M ${sx} ${sy} L ${ex} ${ey}`; } else if (this.#arrowStyle === "curved") { pathD = `M ${sx} ${sy} Q ${cx} ${cy} ${ex} ${ey}`; } else if (this.#arrowStyle === "sketchy") { // Jitter the control point for hand-drawn feel const jitter = () => (Math.random() - 0.5) * 12; const jcx = cx + jitter(); const jcy = cy + jitter(); pathD = `M ${sx + jitter() * 0.3} ${sy + jitter() * 0.3} Q ${jcx} ${jcy} ${ex} ${ey}`; } // Compute arrowhead at the end point // Get the tangent direction at the end of the path let dx: number, dy: number; if (this.#arrowStyle === "straight") { dx = ex - sx; dy = ey - sy; } else { // For curves, tangent at end = end - control dx = ex - cx; dy = ey - cy; } const len = Math.sqrt(dx * dx + dy * dy) || 1; const ux = dx / len; const uy = dy / len; const headLen = sw * 4; const headW = sw * 2.5; // Perpendicular const px = -uy; const py = ux; const p1x = ex - ux * headLen + px * headW; const p1y = ey - uy * headLen + py * headW; const p2x = ex - ux * headLen - px * headW; const p2y = ey - uy * headLen - py * headW; if (this.#arrowStyle === "sketchy") { const j = () => (Math.random() - 0.5) * 3; arrowheadPoints = `${p1x + j()},${p1y + j()} ${ex + j()},${ey + j()} ${p2x + j()},${p2y + j()}`; } else { arrowheadPoints = `${p1x},${p1y} ${ex},${ey} ${p2x},${p2y}`; } const dashAttr = gatedClosed ? 'stroke-dasharray="6 4"' : (this.#arrowStyle === "sketchy" ? 'stroke-dasharray="0"' : ""); const opacityAttr = gatedClosed ? ' opacity="0.4"' : ""; let svgContent = ` `; // Transform badge at midpoint if (this.#transform) { const badges: Record = { filter: "F", map: "M", pick: "P", count: "#", first: "1", last: "L" }; const badge = badges[this.#transform.type] || "T"; svgContent += ` ${badge} `; } this.#svg.innerHTML = svgContent; } } override createRenderRoot() { const root = super.createRenderRoot(); this.#svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); this.#svg.style.cssText = "position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none;"; (root as ShadowRoot).appendChild(this.#svg); this.#updateArrow(); return root; } static fromData(data: Record): FolkArrow { const arrow = document.createElement("folk-arrow") as FolkArrow; arrow.id = data.id; if (data.sourceId) arrow.sourceId = data.sourceId; if (data.targetId) arrow.targetId = data.targetId; if (data.color) arrow.color = data.color; if (data.strokeWidth) arrow.strokeWidth = data.strokeWidth; if (data.arrowStyle) arrow.arrowStyle = data.arrowStyle; if (data.sourcePort) arrow.sourcePort = data.sourcePort; if (data.targetPort) arrow.targetPort = data.targetPort; if (data.flowId) arrow.flowId = data.flowId; if (data.gate) arrow.gate = data.gate; if (data.transform) arrow.transform = data.transform; return arrow; } toJSON() { const json: Record = { type: "folk-arrow", id: this.id, sourceId: this.sourceId, targetId: this.targetId, color: this.#color, strokeWidth: this.#strokeWidth, arrowStyle: this.#arrowStyle, }; if (this.#sourcePort) json.sourcePort = this.#sourcePort; if (this.#targetPort) json.targetPort = this.#targetPort; if (this.#flowId) json.flowId = this.#flowId; if (this.#gate) json.gate = this.#gate; if (this.#transform) json.transform = this.#transform; return json; } applyData(data: Record): void { if (data.sourceId !== undefined && this.sourceId !== data.sourceId) this.sourceId = data.sourceId; if (data.targetId !== undefined && this.targetId !== data.targetId) this.targetId = data.targetId; if (data.color !== undefined && this.#color !== data.color) this.color = data.color; if (data.strokeWidth !== undefined && this.#strokeWidth !== data.strokeWidth) this.strokeWidth = data.strokeWidth; if (data.arrowStyle !== undefined && this.#arrowStyle !== data.arrowStyle) this.arrowStyle = data.arrowStyle as ArrowStyle; if (data.sourcePort !== undefined && this.#sourcePort !== data.sourcePort) this.sourcePort = data.sourcePort; if (data.targetPort !== undefined && this.#targetPort !== data.targetPort) this.targetPort = data.targetPort; if (data.flowId !== undefined && this.#flowId !== data.flowId) this.flowId = data.flowId; if (data.gate !== undefined) this.gate = data.gate; if (data.transform !== undefined) this.transform = data.transform; } }