583 lines
26 KiB
TypeScript
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, "&").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<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);
|