rspace-online/modules/rflows/components/folk-flow-river.ts

583 lines
26 KiB
TypeScript

/**
* <folk-flow-river> — 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<string, string>,
};
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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<string, number>();
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 `
<path d="${path}" fill="${b.color}" opacity="0.25"/>
<path d="${path}" fill="none" stroke="${b.color}" stroke-width="0.5" opacity="0.5"/>
<path d="${center}" fill="none" stroke="${b.color}" stroke-width="1.5" opacity="0.6" stroke-dasharray="6 8" class="flow-dash"/>
<text x="${midX}" y="${midY - 4}" text-anchor="middle" fill="${b.color}" font-size="9" font-weight="600" opacity="0.9">${b.label}</text>`;
}
function renderSource(s: SourceLayout): string {
const cx = s.x + s.w / 2;
const typeColor = COLORS.sourceType[s.sourceType] || COLORS.sourceType.unconfigured;
return `
<rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" rx="10" fill="${COLORS.surface}" stroke="${typeColor}" stroke-width="1.5" opacity="0.9"/>
<circle cx="${s.x + 16}" cy="${s.y + 16}" r="6" fill="${typeColor}" opacity="0.7"/>
<text x="${cx}" y="${s.y + 26}" text-anchor="middle" fill="${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
<text x="${cx}" y="${s.y + 44}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(s.flowRate)}/mo</text>
<circle cx="${s.x + s.w - 14}" cy="${s.y + 14}" r="10" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1" data-interactive="edit-rate" data-source-id="${s.id}" style="cursor:pointer" opacity="0.9"/>
<text x="${s.x + s.w - 14}" y="${s.y + 18}" text-anchor="middle" font-size="11" font-weight="700" fill="${typeColor}" style="pointer-events:none">$</text>`;
}
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 `
<defs><clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath></defs>
<path d="${vesselPath}" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1.5" opacity="0.9"/>
${fillPath ? `<g clip-path="url(#${clipId})"><path d="${fillPath}" fill="${fillColor}" opacity="0.45"/></g>` : ""}
<line x1="${ovEdges.left + 4}" y1="${ovY}" x2="${ovEdges.right - 4}" y2="${ovY}" stroke="${COLORS.overflow}" stroke-width="1.5" stroke-dasharray="5 4" opacity="0.6"/>
<text x="${cx}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${cx}" y="${f.y - 1}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${fmtDollars(val)} | drain ${fmtDollars(drain)}/mo</text>
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${f.w - 20}" height="3" rx="1.5" fill="${COLORS.surfaceRaised}"/>
<rect x="${f.x + 10}" y="${f.y + f.h + 6}" width="${(f.w - 20) * Math.min(1, f.fillLevel)}" height="3" rx="1.5" fill="${fillColor}"/>`;
}
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 `
<rect x="${o.x}" y="${o.y}" width="${o.w}" height="${o.h}" rx="10" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1"/>
<text x="${cx}" y="${o.y + 22}" text-anchor="middle" fill="${COLORS.text}" font-size="10" font-weight="600">${esc(o.label)}</text>
<rect x="${o.x + 8}" y="${o.y + 32}" width="${barW}" height="6" rx="3" fill="${COLORS.surfaceRaised}"/>
<rect x="${o.x + 8}" y="${o.y + 32}" width="${filledW}" height="6" rx="3" fill="${color}" opacity="0.7"/>
<text x="${cx}" y="${o.y + 54}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">${fmtDollars(o.data.fundingReceived)} / ${fmtDollars(o.data.fundingTarget)}</text>`;
}
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 `
<g transform="translate(${x}, ${y})">
<circle cx="24" cy="24" r="22" fill="${COLORS.surface}" stroke="${COLORS.surfaceRaised}" stroke-width="1.5"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="${COLORS.surfaceRaised}" stroke-width="3"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="${circ}" stroke-dashoffset="${circ * (1 - score)}" transform="rotate(-90 24 24)" stroke-linecap="round"/>
<text x="24" y="22" text-anchor="middle" fill="${color}" font-size="11" font-weight="700">${pct}%</text>
<text x="24" y="34" text-anchor="middle" fill="${COLORS.textMuted}" font-size="7">HEALTH</text>
</g>`;
}
// ─── Web Component ──────────────────────────────────────
class FolkFlowRiver extends HTMLElement {
private shadow: ShadowRoot;
private nodes: FlowNode[] = [];
private simulating = false;
private simTimer: ReturnType<typeof setInterval> | 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 = `
<label style="font-size:11px;color:var(--rs-text-secondary)">Flow Rate ($/mo)</label>
<input type="number" class="pop-rate" value="${data.flowRate}" min="0" step="100"/>
<label style="font-size:11px;color:var(--rs-text-secondary);margin-top:4px">Effective Date</label>
<input type="date" class="pop-date" value="${data.effectiveDate || ""}"/>
<div style="display:flex;gap:6px;margin-top:6px">
<button class="pop-apply">Apply</button>
<button class="pop-cancel">Cancel</button>
</div>`;
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 = `
<style>
:host { display: block; }
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid var(--rs-bg-surface-raised); max-height: 90vh; cursor: grab; }
.container.dragging { cursor: grabbing; user-select: none; }
svg { display: block; }
.controls { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; }
.controls button { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--rs-bg-surface-raised); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 12px; }
.controls button:hover { border-color: var(--rs-primary-hover); color: var(--rs-text-primary); }
.controls button.active { background: var(--rs-primary); border-color: var(--rs-primary-hover); color: #fff; }
.legend { position: absolute; bottom: 12px; left: 12px; background: var(--rs-glass-bg); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 8px 12px; font-size: 10px; color: var(--rs-text-secondary); }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
.amount-popover { position: absolute; background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); z-index: 100; min-width: 160px; }
.amount-popover input { background: var(--rs-bg-page, #0f172a); border: 1px solid var(--rs-bg-surface-raised, #334155); border-radius: 4px; padding: 4px 8px; color: var(--rs-text-primary, #f1f5f9); font-size: 13px; outline: none; }
.amount-popover input:focus { border-color: var(--rs-primary, #3b82f6); }
.amount-popover button { padding: 4px 10px; border-radius: 4px; border: 1px solid var(--rs-bg-surface-raised, #334155); background: var(--rs-bg-surface, #1e293b); color: var(--rs-text-secondary, #94a3b8); cursor: pointer; font-size: 11px; flex: 1; }
.amount-popover button.pop-apply { background: var(--rs-primary, #3b82f6); color: #fff; border-color: var(--rs-primary, #3b82f6); }
.amount-popover button:hover { opacity: 0.85; }
.flow-dash { animation: dashFlow 1s linear infinite; }
@keyframes dashFlow { to { stroke-dashoffset: -14; } }
</style>
<div class="container">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
${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)}
</svg>
<div class="controls">
<button class="${this.simulating ? "active" : ""}" data-action="toggle-sim">${this.simulating ? "Pause" : "Simulate"}</button>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:${COLORS.inflow}"></div> Inflow</div>
<div class="legend-item"><div class="legend-dot" style="background:${COLORS.spending}"></div> Spending</div>
<div class="legend-item"><div class="legend-dot" style="background:${COLORS.overflow}"></div> Overflow</div>
</div>
</div>`;
// 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);