/** * — Sankey-style SVG flow visualization. * Pure renderer: receives nodes via setNodes() or falls back to demo data. * Parent component (folk-flows-app) handles data fetching and mapping. * * Visual vocabulary: * Source → rounded rect with flow rate label + source-type badge * Funnel → trapezoid vessel with solid fill bar + overflow threshold line * Flow → Sankey-style colored band with $ label (width proportional to amount) * Outcome → rounded rect with horizontal progress bar */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types"; import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation"; import { demoNodes } from "../lib/presets"; // ─── Layout types ─────────────────────────────────────── interface RiverLayout { sources: SourceLayout[]; funnels: FunnelLayout[]; outcomes: OutcomeLayout[]; bands: BandLayout[]; width: number; height: number; } interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; w: number; h: number; sourceType: string; } interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; w: number; h: number; bw: number; fillLevel: number; overflowLevel: number; sufficiency: SufficiencyState; } interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; w: number; h: number; fillPercent: number; } interface BandLayout { id: string; sourceId: string; targetId: string; x1: number; y1: number; x2: number; y2: number; width: number; color: string; label: string; kind: "inflow" | "spending" | "overflow"; flowAmount: number; } // ─── Constants ─────────────────────────────────────────── const SOURCE_W = 140; const SOURCE_H = 60; const VESSEL_W = 200; const VESSEL_H = 140; const VESSEL_BW = 100; // bottom width of trapezoid const POOL_W = 120; const POOL_H = 65; const LAYER_GAP = 200; const H_GAP = 80; const MIN_BAND_W = 4; const MAX_BAND_W = 60; const PADDING = 100; const COLORS = { inflow: "#10b981", spending: "#8b5cf6", overflow: "#f59e0b", text: "var(--rs-text-primary)", textMuted: "var(--rs-text-secondary)", bg: "var(--rs-bg-page)", surface: "var(--rs-bg-surface)", surfaceRaised: "var(--rs-bg-surface-raised)", sourceType: { card: "#10b981", safe_wallet: "#8b5cf6", ridentity: "#3b82f6", metamask: "#f59e0b", unconfigured: "#64748b" } as Record, }; function esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmtDollars(n: number): string { return n >= 1000 ? `$${(n / 1000).toFixed(1)}k` : `$${Math.floor(n)}`; } /** Interpolate left/right edges of trapezoid at a given Y fraction (0=top, 1=bottom) */ function vesselEdgesAtY(x: number, topW: number, bottomW: number, yFrac: number): { left: number; right: number } { const cx = x + topW / 2; const halfAtY = topW / 2 + (bottomW / 2 - topW / 2) * yFrac; return { left: cx - halfAtY, right: cx + halfAtY }; } // ─── Layout engine ────────────────────────────────────── function computeLayout(nodes: FlowNode[]): RiverLayout { const funnelNodes = nodes.filter((n) => n.type === "funnel"); const outcomeNodes = nodes.filter((n) => n.type === "outcome"); const sourceNodes = nodes.filter((n) => n.type === "source"); // Build layers by Y-position proximity const funnelsByY = [...funnelNodes].sort((a, b) => a.position.y - b.position.y); const layers: FlowNode[][] = []; for (const n of funnelsByY) { const lastLayer = layers[layers.length - 1]; if (lastLayer && Math.abs(n.position.y - lastLayer[0].position.y) < 200) { lastLayer.push(n); } else { layers.push([n]); } } // Sort each layer by X layers.forEach((l) => l.sort((a, b) => a.position.x - b.position.x)); const funnelLayerMap = new Map(); layers.forEach((l, i) => l.forEach((n) => funnelLayerMap.set(n.id, i))); // Position sources const sourceStartY = PADDING; const sourceTotalW = sourceNodes.length * SOURCE_W + (sourceNodes.length - 1) * H_GAP; const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => { const data = n.data as SourceNodeData; return { id: n.id, label: data.label, flowRate: data.flowRate, x: -sourceTotalW / 2 + i * (SOURCE_W + H_GAP), y: sourceStartY, w: SOURCE_W, h: SOURCE_H, sourceType: data.sourceType || "unconfigured", }; }); // Position funnels const funnelStartY = sourceStartY + SOURCE_H + LAYER_GAP; const funnelLayouts: FunnelLayout[] = []; layers.forEach((layer, layerIdx) => { const totalW = layer.length * VESSEL_W + (layer.length - 1) * H_GAP; const layerY = funnelStartY + layerIdx * (VESSEL_H + LAYER_GAP); layer.forEach((n, i) => { const data = n.data as FunnelNodeData; const fillLevel = Math.min(1, data.currentValue / (data.capacity || 1)); const overflowLevel = data.overflowThreshold / (data.capacity || 1); funnelLayouts.push({ id: n.id, label: data.label, data, x: -totalW / 2 + i * (VESSEL_W + H_GAP), y: layerY, w: VESSEL_W, h: VESSEL_H, bw: VESSEL_BW, fillLevel, overflowLevel, sufficiency: computeSufficiencyState(data), }); }); }); // Position outcomes const maxLayerIdx = layers.length - 1; const outcomeY = funnelStartY + (maxLayerIdx + 1) * (VESSEL_H + LAYER_GAP); const outcomeTotalW = outcomeNodes.length * POOL_W + (outcomeNodes.length - 1) * (H_GAP / 2); const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => { const data = n.data as OutcomeNodeData; const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0; return { id: n.id, label: data.label, data, x: -outcomeTotalW / 2 + i * (POOL_W + H_GAP / 2), y: outcomeY, w: POOL_W, h: POOL_H, fillPercent, }; }); // Build flow bands const bands: BandLayout[] = []; const nodeCenter = (layouts: { id: string; x: number; w: number; y: number; h: number }[], id: string): { cx: number; top: number; bottom: number } | null => { const l = layouts.find((n) => n.id === id); return l ? { cx: l.x + l.w / 2, top: l.y, bottom: l.y + l.h } : null; }; const allLayouts = [...sourceLayouts.map((s) => ({ ...s })), ...funnelLayouts.map((f) => ({ ...f })), ...outcomeLayouts.map((o) => ({ ...o }))]; // Source → funnel bands const maxFlowRate = Math.max(1, ...sourceNodes.map((n) => (n.data as SourceNodeData).flowRate)); sourceNodes.forEach((sn) => { const data = sn.data as SourceNodeData; const src = nodeCenter(allLayouts, sn.id); data.targetAllocations?.forEach((alloc) => { const tgt = nodeCenter(allLayouts, alloc.targetId); if (!src || !tgt) return; const flowAmount = data.flowRate * (alloc.percentage / 100); const w = Math.max(MIN_BAND_W, (flowAmount / maxFlowRate) * MAX_BAND_W); bands.push({ id: `src-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, x1: src.cx, y1: src.bottom, x2: tgt.cx, y2: tgt.top, width: w, color: alloc.color || COLORS.inflow, label: `${fmtDollars(flowAmount)}/mo`, kind: "inflow", flowAmount, }); }); }); // Funnel overflow + spending bands funnelNodes.forEach((fn) => { const data = fn.data as FunnelNodeData; const src = funnelLayouts.find((f) => f.id === fn.id); if (!src) return; const srcCenter = src.x + src.w / 2; // Overflow bands (from sides) data.overflowAllocations?.forEach((alloc, i) => { const tgt = nodeCenter(allLayouts, alloc.targetId); if (!tgt) return; const excess = Math.max(0, data.currentValue - data.overflowThreshold); const flowAmount = excess * (alloc.percentage / 100); const rateAmount = data.inflowRate > data.drainRate ? (data.inflowRate - data.drainRate) * (alloc.percentage / 100) : 0; const displayAmount = flowAmount > 0 ? flowAmount : rateAmount; const w = Math.max(MIN_BAND_W, displayAmount > 0 ? Math.min(MAX_BAND_W, (displayAmount / maxFlowRate) * MAX_BAND_W) : MIN_BAND_W); const overflowFrac = 1 - src.overflowLevel; const lipY = src.y + overflowFrac * src.h; const side = i % 2 === 0 ? -1 : 1; const edges = vesselEdgesAtY(src.x, src.w, src.bw, overflowFrac); const lipX = side < 0 ? edges.left : edges.right; bands.push({ id: `ovf-${fn.id}-${alloc.targetId}`, sourceId: fn.id, targetId: alloc.targetId, x1: lipX, y1: lipY, x2: tgt.cx, y2: tgt.top, width: w, color: alloc.color || COLORS.overflow, label: displayAmount > 0 ? `${fmtDollars(displayAmount)}/mo` : `${alloc.percentage}%`, kind: "overflow", flowAmount: displayAmount, }); }); // Spending bands (from bottom) data.spendingAllocations?.forEach((alloc) => { const tgt = nodeCenter(allLayouts, alloc.targetId); if (!tgt) return; const flowAmount = data.drainRate * (alloc.percentage / 100); const w = Math.max(MIN_BAND_W, (flowAmount / maxFlowRate) * MAX_BAND_W); bands.push({ id: `spd-${fn.id}-${alloc.targetId}`, sourceId: fn.id, targetId: alloc.targetId, x1: srcCenter, y1: src.y + src.h, x2: tgt.cx, y2: tgt.top, width: w, color: alloc.color || COLORS.spending, label: `${fmtDollars(flowAmount)}/mo`, kind: "spending", flowAmount, }); }); }); // Normalize coordinates const allX = [...sourceLayouts.flatMap((s) => [s.x, s.x + s.w]), ...funnelLayouts.flatMap((f) => [f.x, f.x + f.w]), ...outcomeLayouts.flatMap((o) => [o.x, o.x + o.w])]; const allY = [...sourceLayouts.map((s) => s.y + s.h), ...funnelLayouts.map((f) => f.y + f.h), ...outcomeLayouts.map((o) => o.y + o.h + 30)]; const minX = Math.min(...allX, 0); const maxX = Math.max(...allX, 0); const maxY = Math.max(...allY, 400); const offsetX = -minX + PADDING; const offsetY = 0; sourceLayouts.forEach((s) => { s.x += offsetX; s.y += offsetY; }); funnelLayouts.forEach((f) => { f.x += offsetX; f.y += offsetY; }); outcomeLayouts.forEach((o) => { o.x += offsetX; o.y += offsetY; }); bands.forEach((b) => { b.x1 += offsetX; b.y1 += offsetY; b.x2 += offsetX; b.y2 += offsetY; }); return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, bands, width: maxX - minX + PADDING * 2, height: maxY + offsetY + PADDING, }; } // ─── SVG Rendering ────────────────────────────────────── function renderBand(b: BandLayout): string { const hw = b.width / 2; const dx = b.x2 - b.x1; const dy = b.y2 - b.y1; if (dy <= 0) return ""; // Cubic bezier ribbon — L-shaped path for horizontal displacement const hDisp = Math.abs(dx); const bendY = hDisp > 20 ? b.y1 + Math.min(dy * 0.3, 40) : b.y1 + dy * 0.4; const cp1y = b.y1 + dy * 0.15; const cp2y = b.y2 - dy * 0.15; // Left edge and right edge of ribbon const path = [ `M ${b.x1 - hw} ${b.y1}`, `C ${b.x1 - hw} ${cp1y}, ${b.x2 - hw} ${cp2y}, ${b.x2 - hw} ${b.y2}`, `L ${b.x2 + hw} ${b.y2}`, `C ${b.x2 + hw} ${cp2y}, ${b.x1 + hw} ${cp1y}, ${b.x1 + hw} ${b.y1}`, `Z`, ].join(" "); // Center-line for direction animation const center = `M ${b.x1} ${b.y1} C ${b.x1} ${cp1y}, ${b.x2} ${cp2y}, ${b.x2} ${b.y2}`; // Label at midpoint const midX = (b.x1 + b.x2) / 2; const midY = (b.y1 + b.y2) / 2; return ` ${b.label}`; } function renderSource(s: SourceLayout): string { const cx = s.x + s.w / 2; const typeColor = COLORS.sourceType[s.sourceType] || COLORS.sourceType.unconfigured; return ` ${esc(s.label)} ${fmtDollars(s.flowRate)}/mo $`; } function renderFunnel(f: FunnelLayout): string { const cx = f.x + f.w / 2; const tl = f.x, tr = f.x + f.w; const bl = cx - f.bw / 2, br = cx + f.bw / 2; // Vessel outline const vesselPath = `M ${tl} ${f.y} L ${bl} ${f.y + f.h} L ${br} ${f.y + f.h} L ${tr} ${f.y} Z`; const clipId = `vc-${f.id}`; // Fill bar (solid, no water animation) const fillTop = f.y + (1 - f.fillLevel) * f.h; const fillEdges = vesselEdgesAtY(f.x, f.w, f.bw, (fillTop - f.y) / f.h); const fillPath = f.fillLevel > 0.01 ? `M ${fillEdges.left} ${fillTop} L ${bl} ${f.y + f.h} L ${br} ${f.y + f.h} L ${fillEdges.right} ${fillTop} Z` : ""; // Overflow threshold line const ovFrac = 1 - f.overflowLevel; const ovY = f.y + ovFrac * f.h; const ovEdges = vesselEdgesAtY(f.x, f.w, f.bw, ovFrac); const isOverflowing = f.sufficiency === "overflowing"; const fillColor = isOverflowing ? COLORS.overflow : COLORS.inflow; // Value and drain labels const val = f.data.currentValue; const drain = f.data.drainRate; return ` ${fillPath ? `` : ""} ${esc(f.label)} ${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo `; } function renderOutcome(o: OutcomeLayout): string { const cx = o.x + o.w / 2; const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6"; const barW = o.w - 16; const filledW = barW * Math.min(1, o.fillPercent / 100); return ` ${esc(o.label)} ${fmtDollars(o.data.fundingReceived)} / ${fmtDollars(o.data.fundingTarget)}`; } function renderSufficiencyBadge(score: number, x: number, y: number): string { const pct = Math.round(score * 100); const color = pct >= 90 ? "#fbbf24" : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444"; const circ = 2 * Math.PI * 18; return ` ${pct}% HEALTH `; } // ─── Web Component ────────────────────────────────────── class FolkFlowRiver extends HTMLElement { private shadow: ShadowRoot; private nodes: FlowNode[] = []; private simulating = false; private simTimer: ReturnType | null = null; private dragging = false; private dragStartX = 0; private dragStartY = 0; private scrollStartX = 0; private scrollStartY = 0; private activePopover: HTMLElement | null = null; private renderScheduled = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } static get observedAttributes() { return ["simulate"]; } connectedCallback() { this.simulating = this.getAttribute("simulate") === "true"; if (this.nodes.length === 0) { this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))]; } this.render(); if (this.simulating) this.startSimulation(); } disconnectedCallback() { this.stopSimulation(); } attributeChangedCallback(name: string, _: string, newVal: string) { if (name === "simulate") { this.simulating = newVal === "true"; if (this.simulating) this.startSimulation(); else this.stopSimulation(); } } setNodes(nodes: FlowNode[]) { this.nodes = nodes; this.render(); } private scheduleRender() { if (this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderScheduled = false; this.render(); }); } private startSimulation() { if (this.simTimer) return; this.simTimer = setInterval(() => { this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG); this.render(); }, 500); } private stopSimulation() { if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; } } private showAmountPopover(sourceId: string, anchorX: number, anchorY: number) { this.closePopover(); const sourceNode = this.nodes.find((n) => n.id === sourceId); if (!sourceNode) return; const data = sourceNode.data as SourceNodeData; const popover = document.createElement("div"); popover.className = "amount-popover"; popover.style.left = `${anchorX}px`; popover.style.top = `${anchorY}px`; popover.innerHTML = `
`; const container = this.shadow.querySelector(".container") as HTMLElement; container.appendChild(popover); this.activePopover = popover; const rateInput = popover.querySelector(".pop-rate") as HTMLInputElement; const dateInput = popover.querySelector(".pop-date") as HTMLInputElement; rateInput.focus(); rateInput.select(); const apply = () => { const newRate = parseFloat(rateInput.value) || 0; const newDate = dateInput.value || undefined; this.updateSourceFlowRate(sourceId, Math.max(0, newRate), newDate); this.closePopover(); }; popover.querySelector(".pop-apply")!.addEventListener("click", apply); popover.querySelector(".pop-cancel")!.addEventListener("click", () => this.closePopover()); rateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); }); dateInput.addEventListener("keydown", (e) => { if (e.key === "Enter") apply(); if (e.key === "Escape") this.closePopover(); }); } private closePopover() { if (this.activePopover) { this.activePopover.remove(); this.activePopover = null; } } private updateSourceFlowRate(sourceId: string, flowRate: number, effectiveDate?: string) { this.nodes = this.nodes.map((n) => { if (n.id === sourceId && n.type === "source") { return { ...n, data: { ...n.data, flowRate, effectiveDate } as SourceNodeData }; } return n; }); this.dispatchEvent(new CustomEvent("flow-rate-change", { detail: { sourceId, flowRate, effectiveDate }, bubbles: true })); this.scheduleRender(); } private render() { const layout = computeLayout(this.nodes); const score = computeSystemSufficiency(this.nodes); this.shadow.innerHTML = `
${layout.bands.map(renderBand).join("")} ${layout.sources.map(renderSource).join("")} ${layout.funnels.map(renderFunnel).join("")} ${layout.outcomes.map(renderOutcome).join("")} ${renderSufficiencyBadge(score, layout.width - 70, 10)}
Inflow
Spending
Overflow
`; // Event: toggle simulation this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => { this.simulating = !this.simulating; if (this.simulating) this.startSimulation(); else this.stopSimulation(); this.render(); }); // Event delegation for interactive elements + drag-to-pan const container = this.shadow.querySelector(".container") as HTMLElement; if (!container) return; container.addEventListener("pointerdown", (e: PointerEvent) => { const target = e.target as Element; if (target.closest("button")) return; if (target.closest(".amount-popover")) return; const interactive = target.closest("[data-interactive]") as Element | null; if (interactive) { const action = interactive.getAttribute("data-interactive"); const sourceId = interactive.getAttribute("data-source-id"); if (action === "edit-rate" && sourceId) { const rect = container.getBoundingClientRect(); const popX = e.clientX - rect.left + container.scrollLeft + 10; const popY = e.clientY - rect.top + container.scrollTop + 10; this.showAmountPopover(sourceId, popX, popY); e.preventDefault(); return; } } this.closePopover(); // Start pan drag this.dragging = true; this.dragStartX = e.clientX; this.dragStartY = e.clientY; this.scrollStartX = container.scrollLeft; this.scrollStartY = container.scrollTop; container.classList.add("dragging"); container.setPointerCapture(e.pointerId); }); container.addEventListener("pointermove", (e: PointerEvent) => { if (!this.dragging) return; container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX); container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY); }); container.addEventListener("pointerup", (e: PointerEvent) => { this.dragging = false; container.classList.remove("dragging"); container.releasePointerCapture(e.pointerId); }); // Auto-center on initial render container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2; container.scrollTop = 0; } } customElements.define("folk-flow-river", FolkFlowRiver);