Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-12 18:55:24 -07:00
commit 1448909ffd
4 changed files with 364 additions and 173 deletions

View File

@ -842,8 +842,34 @@
.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ }
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
/* ── Funnel fill animation ─────────────────────────── */
.funnel-fill-rect { transition: y 120ms ease-out, height 120ms ease-out; }
/* ── Vessel fill animation (tapered path) ──────────── */
.funnel-fill-path { transition: d 120ms ease-out; }
/* ── Water surface shimmer ──────────────────────────── */
.water-surface-line { animation: water-shimmer 4s ease-in-out infinite; }
@keyframes water-shimmer {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
/* ── Overflow spill pulse ──────────────────────────── */
.overflow-spill-left { animation: overflow-pulse 1.5s ease-in-out infinite; }
.overflow-spill-right { animation: overflow-pulse 1.5s ease-in-out infinite 0.3s; }
@keyframes overflow-pulse {
0%, 100% { ry: 6; opacity: 0.4; }
50% { ry: 10; opacity: 0.7; }
}
/* ── Basin water fill transition ───────────────────── */
.basin-water-fill { transition: y 200ms ease-out, height 200ms ease-out; }
.basin-receiving .basin-water-fill { animation: basin-pulse 2s ease-in-out infinite; }
@keyframes basin-pulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
/* ── Basin ripple wave ─────────────────────────────── */
.basin-ripple { opacity: 0.7; }
/* ── Simulation speed slider ──────────────────────── */
.flows-sim-speed {

View File

@ -949,6 +949,44 @@ class FolkFlowsApp extends HTMLElement {
<stop offset="40%" stop-color="#8892a0"/>
<stop offset="100%" stop-color="#626d7d"/>
</linearGradient>
<linearGradient id="pipe-metal-h" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c0c8d4"/>
<stop offset="30%" stop-color="#9ca3af"/>
<stop offset="70%" stop-color="#6b7280"/>
<stop offset="100%" stop-color="#4b5563"/>
</linearGradient>
<linearGradient id="water-surface" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.15"/>
<stop offset="30%" stop-color="#93c5fd" stop-opacity="0.4"/>
<stop offset="50%" stop-color="#bfdbfe" stop-opacity="0.6"/>
<stop offset="70%" stop-color="#93c5fd" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity="0.15"/>
</linearGradient>
<pattern id="water-ripple" x="0" y="0" width="60" height="8" patternUnits="userSpaceOnUse">
<path d="M0 4 Q15 0 30 4 Q45 8 60 4" fill="none" stroke="rgba(147,197,253,0.3)" stroke-width="1.5">
<animateTransform attributeName="transform" type="translate" values="0,0;-60,0" dur="3s" repeatCount="indefinite"/>
</path>
</pattern>
<radialGradient id="overflow-splash" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#93c5fd" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#60a5fa" stop-opacity="0"/>
</radialGradient>
<linearGradient id="basin-water-blue" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="basin-water-green" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6ee7b7" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="basin-water-grey" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#94a3b8" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#64748b" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="basin-water-red" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
</linearGradient>
</defs>
<g id="canvas-transform">
<g id="edge-layer"></g>
@ -1095,16 +1133,16 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") {
return { w: 200, h: 160 };
return { w: 260, h: 120 };
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const baseW = 280;
const baseW = 260;
const cap = d.maxCapacity || 9000;
const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
return { w: baseW, h: Math.max(200, h) };
const h = Math.round(220 + Math.min(200, (cap / 50000) * 200));
return { w: baseW, h: Math.max(220, h) };
}
return { w: 220, h: 180 }; // outcome card
return { w: 260, h: 140 }; // basin pool
}
// ─── Canvas event wiring ──────────────────────────────
@ -1739,39 +1777,39 @@ class FolkFlowsApp extends HTMLElement {
const valveColor = valveColors[d.sourceType] || "#64748b";
const isConfigured = d.sourceType !== "unconfigured";
// Pipe header dimensions
// Horizontal pipe from left edge to valve
const pipeH = 22;
const pipeY = 0;
const pipeRx = 6;
const pipeCY = 40; // vertical center of pipe
const pipeY = pipeCY - pipeH / 2;
// Valve body
const valveR = 28;
const valveCx = w / 2;
const valveCy = pipeY + pipeH + valveR + 4;
// Valve: circle at center
const valveR = 22;
const valveCx = w * 0.5;
const valveCy = pipeCY;
// Valve handle rotation: 45° configured, 90° unconfigured
const handleAngle = isConfigured ? 45 : 90;
// Handle rotation: 0°=closed(up), maps flowRate to angle (max 90°=open/right)
const maxRate = 50000;
const handleAngle = isConfigured ? Math.min(90, (d.flowRate / maxRate) * 90) : 0;
// Spigot: trapezoid below valve
const spigotTop = valveCy + valveR + 2;
const spigotTopW = 24;
const spigotBotW = 14;
const spigotH = 20;
const spigotPath = `M ${valveCx - spigotTopW / 2},${spigotTop} L ${valveCx + spigotTopW / 2},${spigotTop} L ${valveCx + spigotBotW / 2},${spigotTop + spigotH} L ${valveCx - spigotBotW / 2},${spigotTop + spigotH} Z`;
// Nozzle: trapezoid from valve right, angling 30° downward-right to x=w*0.75
const nozzleStartX = valveCx + valveR + 2;
const nozzleEndX = w * 0.75;
const nozzleStartY = pipeCY;
const nozzleEndY = pipeCY + (nozzleEndX - nozzleStartX) * Math.tan(30 * Math.PI / 180);
const nozzleTopW = 12; // half-width at start
const nozzleBotW = 7; // half-width at end
const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`;
// Flow stream at bottom — width proportional to flowRate
const streamMaxW = w - 40;
const streamW = Math.round(8 + Math.min(streamMaxW - 8, Math.sqrt(d.flowRate / 100) * (streamMaxW / 6)));
const streamY = spigotTop + spigotH;
// Stream: rect from nozzle tip downward, width proportional to sqrt(flowRate/100)
const streamW = Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5));
const streamX = nozzleEndX;
const streamY = nozzleEndY + nozzleBotW;
const streamH = h - streamY;
// Amount text
const amountY = valveCy + valveR + spigotH + 8;
// Allocation bar as SVG rects
// Allocation bar
let allocBar = "";
if (d.targetAllocations && d.targetAllocations.length > 0) {
const barY = h - 10;
const barY = h - 8;
const barW = w - 40;
const barX = 20;
let cx = barX;
@ -1785,20 +1823,59 @@ class FolkFlowsApp extends HTMLElement {
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
<rect class="faucet-pipe" x="0" y="${pipeY}" width="${w}" height="${pipeH}" rx="${pipeRx}" fill="url(#faucet-pipe-grad)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
<text x="${w / 2}" y="${pipeY + pipeH / 2 + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
<circle class="faucet-valve" cx="${valveCx}" cy="${valveCy}" r="${valveR}" fill="${valveColor}" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-bg-surface)"}" stroke-width="${selected ? 2.5 : 1.5}" style="cursor:pointer"/>
<g transform="rotate(${handleAngle},${valveCx},${valveCy})">
<rect class="faucet-handle" x="${valveCx - 3}" y="${valveCy - valveR - 6}" width="6" height="${valveR * 2 + 12}" rx="3" fill="var(--rs-bg-surface)" opacity="0.7"/>
<!-- Horizontal pipe from left to valve -->
<rect class="source-pipe" x="0" y="${pipeY}" width="${valveCx - valveR - 2}" height="${pipeH}" rx="4" fill="url(#pipe-metal-h)" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-text-secondary)"}" stroke-width="${selected ? 2 : 1}"/>
<!-- Label on pipe -->
<text x="${(valveCx - valveR - 2) / 2}" y="${pipeCY + 1}" text-anchor="middle" dominant-baseline="central" fill="white" font-size="11" font-weight="600" pointer-events="none">${this.esc(d.label)}</text>
<!-- Rotary valve -->
<circle class="source-valve" cx="${valveCx}" cy="${valveCy}" r="${valveR}" fill="${valveColor}" stroke="${selected ? "var(--rflows-selected)" : "var(--rs-bg-surface)"}" stroke-width="${selected ? 2.5 : 1.5}" style="cursor:pointer"/>
<!-- Handle rotates 0-90° based on flowRate -->
<g transform="rotate(${-90 + handleAngle},${valveCx},${valveCy})">
<rect class="source-handle" x="${valveCx - 3}" y="${valveCy - valveR - 4}" width="6" height="${valveR + 4}" rx="3" fill="var(--rs-bg-surface)" opacity="0.8"/>
</g>
<path class="faucet-spigot" d="${spigotPath}" fill="url(#faucet-pipe-grad)" stroke="var(--rs-text-secondary)" stroke-width="1"/>
<rect class="faucet-stream" x="${valveCx - streamW / 2}" y="${streamY}" width="${streamW}" height="${Math.max(streamH, 4)}" rx="3" fill="#10b981" opacity="${isConfigured ? 0.45 : 0.15}"/>
<text x="${valveCx}" y="${amountY}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
<!-- Nozzle angling downward-right -->
<path class="source-nozzle" d="${nozzlePath}" fill="url(#pipe-metal-h)" stroke="var(--rs-text-secondary)" stroke-width="1"/>
<!-- Flow stream downward from nozzle tip -->
<rect class="source-stream" x="${streamX - streamW / 2}" y="${streamY}" width="${streamW}" height="${Math.max(streamH, 4)}" rx="${streamW / 2}" fill="#10b981" opacity="${isConfigured ? 0.5 : 0.15}"/>
<!-- Amount label -->
<text x="${valveCx}" y="${h - 18}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
${allocBar}
${this.renderPortsSvg(n)}
</g>`;
}
/** Compute the wall inset at a given Y fraction (0=top, 1=bottom) for the tapered vessel */
private vesselWallInset(yFrac: number, taperAtBottom: number): number {
return taperAtBottom * (yFrac * yFrac * 0.4 + yFrac * 0.6);
}
/** Compute the fill polygon path for a tapered vessel, tracing the walls from fillY to bottom */
private computeVesselFillPath(w: number, h: number, fillPct: number, taperAtBottom: number): string {
const zoneTop = 36;
const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop;
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
if (totalFillH <= 0) return "";
// Trace left wall downward from fillY, then right wall upward
const steps = 12;
const pts: string[] = [];
for (let i = 0; i <= steps; i++) {
const py = fillY + (zoneBot - fillY) * (i / steps);
const yf = (py - zoneTop) / zoneH; // 0-1 within zone
const inset = this.vesselWallInset(yf, taperAtBottom);
pts.push(`${inset},${py}`);
}
for (let i = steps; i >= 0; i--) {
const py = fillY + (zoneBot - fillY) * (i / steps);
const yf = (py - zoneTop) / zoneH;
const inset = this.vesselWallInset(yf, taperAtBottom);
pts.push(`${w - inset},${py}`);
}
return `M ${pts.join(" L ")} Z`;
}
private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
@ -1811,27 +1888,88 @@ class FolkFlowsApp extends HTMLElement {
const fillColor = borderColorVar;
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient";
// Tank shape parameters
const r = 12;
const pipeW = 30; // overflow pipe extension from wall
const basePipeH = 24; // base pipe height
const taperStart = 0.80; // body tapers at 80% down
// Drain width proportional to outflow: wider drain = more outflow
// Vessel shape parameters
const r = 10;
const drainW = 60; // narrow drain spout at bottom
const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 10000);
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`;
// taperAtBottom: how far walls inset at the very bottom (in px)
const taperAtBottom = (w - drainW) / 2;
// Interior zone boundaries
// Overflow pipe parameters — positioned at max threshold
const pipeW = 28;
const basePipeH = 22;
const zoneTop = 36;
const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop;
// Zone fractions for 3 zones: Critical (below min), Sufficient (min-max), Overflow (above max)
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
let pipeH = basePipeH;
let pipeY = Math.round(maxLineY - basePipeH / 2);
let excessRatio = 0;
if (isOverflow && d.maxCapacity > d.maxThreshold) {
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeH = basePipeH + Math.round(excessRatio * 16);
pipeY = Math.round(maxLineY - pipeH / 2);
}
// Wall inset at pipe Y position for pipe attachment
const pipeYFrac = (maxLineY - zoneTop) / zoneH;
const wallInsetAtPipe = this.vesselWallInset(pipeYFrac, taperAtBottom);
// Vessel outline: wide top, tapered walls to narrow drain spout
const steps = 16;
const leftWall: string[] = [];
const rightWall: string[] = [];
for (let i = 0; i <= steps; i++) {
const yf = i / steps;
const py = zoneTop + zoneH * yf;
const inset = this.vesselWallInset(yf, taperAtBottom);
leftWall.push(`${inset},${py}`);
rightWall.push(`${w - inset},${py}`);
}
const vesselPath = [
`M ${r},0`,
`L ${w - r},0`,
`Q ${w},0 ${w},${r}`,
// Right wall: straight to pipe notch, then taper
`L ${w},${pipeY}`,
`L ${w + pipeW},${pipeY}`,
`L ${w + pipeW},${pipeY + pipeH}`,
`L ${w},${pipeY + pipeH}`,
// Continue right wall tapering down
...rightWall.filter((_, i) => {
const py = zoneTop + zoneH * (i / steps);
return py > pipeY + pipeH;
}).map(p => `L ${p}`),
// Bottom: narrow drain spout with rounded corners
`L ${w - taperAtBottom + r},${zoneBot}`,
`Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`,
`L ${w - taperAtBottom},${h}`,
`L ${taperAtBottom},${h}`,
`L ${taperAtBottom},${h - r}`,
`Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`,
// Left wall tapering up
...leftWall.filter((_, i) => {
const py = zoneTop + zoneH * (i / steps);
return py > pipeY + pipeH;
}).reverse().map(p => `L ${p}`),
// Left pipe notch
`L 0,${pipeY + pipeH}`,
`L ${-pipeW},${pipeY + pipeH}`,
`L ${-pipeW},${pipeY}`,
`L 0,${pipeY}`,
// Back up left wall to top
`L 0,${r}`,
`Q 0,0 ${r},0`,
`Z`,
].join(" ");
const clipId = `funnel-clip-${n.id}`;
// Zone dimensions
const criticalPct = minFrac;
const sufficientPct = maxFrac - minFrac;
const overflowPct = Math.max(0, 1 - maxFrac);
@ -1839,53 +1977,30 @@ class FolkFlowsApp extends HTMLElement {
const sufficientH = zoneH * sufficientPct;
const overflowH = zoneH * overflowPct;
// Pipe position at max threshold line
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
let pipeH = basePipeH;
let pipeY = Math.round(maxLineY - basePipeH / 2);
let excessRatio = 0;
if (isOverflow && d.maxCapacity > d.maxThreshold) {
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeH = basePipeH + Math.round(excessRatio * 20);
pipeY = Math.round(maxLineY - pipeH / 2);
}
// Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom
const tankPath = [
`M ${r},0`,
`L ${w - r},0`,
`Q ${w},0 ${w},${r}`,
`L ${w},${pipeY}`,
`L ${w + pipeW},${pipeY}`,
`L ${w + pipeW},${pipeY + pipeH}`,
`L ${w},${pipeY + pipeH}`,
`L ${w},${taperY}`,
`Q ${w},${taperY + (h - taperY) * 0.3} ${w - insetPx},${h - r}`,
`Q ${w - insetPx},${h} ${w - insetPx - r},${h}`,
`L ${insetPx + r},${h}`,
`Q ${insetPx},${h} ${insetPx},${h - r}`,
`Q 0,${taperY + (h - taperY) * 0.3} 0,${taperY}`,
`L 0,${pipeY + pipeH}`,
`L ${-pipeW},${pipeY + pipeH}`,
`L ${-pipeW},${pipeY}`,
`L 0,${pipeY}`,
`L 0,${r}`,
`Q 0,0 ${r},0`,
`Z`,
].join(" ");
// Fill level
// Fill path (tapered polygon)
const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
// Threshold lines: only min and max (2 lines, 3 zones)
// Threshold lines with X endpoints computed from wall taper
const minLineY = zoneTop + zoneH * (1 - minFrac);
const minYFrac = (minLineY - zoneTop) / zoneH;
const minInset = this.vesselWallInset(minYFrac, taperAtBottom);
const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom);
const thresholdLines = `
<line class="threshold-line" x1="6" x2="${w - 6}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="10" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
<line class="threshold-line" x1="6" x2="${w - 6}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
<line class="threshold-line" x1="${minInset + 4}" x2="${w - minInset - 4}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="${minInset + 8}" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
<line class="threshold-line" x1="${maxInset + 4}" x2="${w - maxInset - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="${maxInset + 8}" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
// Water surface shimmer line at fill level
const shimmerLine = fillPct > 0.01 ? `<line class="water-surface-line" x1="${this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) + 2}" x2="${w - this.vesselWallInset((fillY - zoneTop) / zoneH, taperAtBottom) - 2}" y1="${fillY}" y2="${fillY}" stroke="url(#water-surface)" stroke-width="3"/>` : "";
// Overflow spill effects at pipe positions
const overflowSpill = isOverflow ? `
<ellipse class="overflow-spill-left" cx="${-pipeW - 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>
<ellipse class="overflow-spill-right" cx="${w + pipeW + 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>` : "";
// Inflow satisfaction bar
const satBarY = 50;
@ -1901,39 +2016,39 @@ class FolkFlowsApp extends HTMLElement {
// Rate labels
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
const baseRate = d.desiredOutflow || d.inflowRate;
let rateMultiplier: number;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const spendingRate = baseRate * rateMultiplier;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
// Status badge colors for HTML
// Status badge colors
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
// Drain spout inset for valve handle positioning
const drainInset = taperAtBottom;
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
</defs>
${isOverflow ? `<path d="${tankPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
${isOverflow ? `<path d="${vesselPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${vesselPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
<g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
<rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-pipeW}" y="${fillY}" width="${w + pipeW * 2}" height="${totalFillH}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>` : ""}
${shimmerLine}
${thresholdLines}
</g>
<!-- Overflow pipes at max threshold -->
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
${overflowSpill}
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
<!-- Drain valve handle at spout -->
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
<rect x="${insetPx - 8}" y="${h - 16}" width="${w - insetPx * 2 + 16}" height="18" rx="5"
<rect x="${drainInset - 8}" y="${h - 16}" width="${drainW + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo
@ -1955,7 +2070,7 @@ class FolkFlowsApp extends HTMLElement {
${criticalH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH + criticalH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#ef4444;opacity:0.5">CRITICAL</div>` : ""}
${sufficientH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">SUFFICIENT</div>` : ""}
${overflowH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">OVERFLOW</div>` : ""}
<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${insetPx + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:var(--rs-text-muted)">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${drainInset + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:var(--rs-text-muted)">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
<div style="position:absolute;bottom:10px;width:100%;text-align:center;font-size:12px;font-weight:600;color:#34d399">${this.formatDollar(outflow)}/mo \u25BE</div>
${isOverflow ? `<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;left:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>
<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;right:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>` : ""}
@ -1970,43 +2085,79 @@ class FolkFlowsApp extends HTMLElement {
const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
const isOverfunded = d.fundingReceived > d.fundingTarget && d.fundingTarget > 0;
const statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
const statusColor = statusColors[d.status] || "#64748b";
const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());
// Phase indicators
let phaseHtml = "";
// Basin water gradient by status
const waterGrad: Record<string, string> = { completed: "url(#basin-water-green)", blocked: "url(#basin-water-red)", "in-progress": "url(#basin-water-blue)", "not-started": "url(#basin-water-grey)" };
const waterFill = waterGrad[d.status] || "url(#basin-water-grey)";
// Basin shape: open-top U with rounded bottom
const wallDrop = h * 0.30; // straight sides go down 30%
const curveY = wallDrop;
const basinPath = `M 0,0 L 0,${curveY} Q 0,${h} ${w / 2},${h} Q ${w},${h} ${w},${curveY} L ${w},0`;
const basinClosedPath = `${basinPath} Z`; // closed for clip
const clipId = `basin-clip-${n.id}`;
// Water fill: rises from bottom, clipped to basin
const waterTop = h - (h - 10) * fillPct; // 10px margin at bottom
const waterRect = fillPct > 0 ? `<rect class="basin-water-fill" x="0" y="${waterTop}" width="${w}" height="${h - waterTop}" fill="${waterFill}"/>` : "";
// Ripple pattern on water surface
const ripple = fillPct > 0.05 ? `<rect x="0" y="${waterTop}" width="${w}" height="8" fill="url(#water-ripple)" class="basin-ripple"/>` : "";
// Phase markers: short horizontal lines on left basin wall
let phaseMarkers = "";
if (d.phases && d.phases.length > 0) {
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
const phaseSegs = d.phases.map((p, i) => {
phaseMarkers = d.phases.map((p) => {
const phaseFrac = d.fundingTarget > 0 ? Math.min(1, p.fundingThreshold / d.fundingTarget) : 0;
const markerY = h - (h - 10) * phaseFrac;
const unlocked = d.fundingReceived >= p.fundingThreshold;
return `<div style="flex:1;height:4px;border-radius:2px;background:${unlocked ? "#10b981" : "var(--rs-border)"}"></div>`;
return `<line x1="4" x2="18" y1="${markerY}" y2="${markerY}" stroke="${unlocked ? "#10b981" : "var(--rs-text-muted)"}" stroke-width="2" opacity="0.7"/>
<circle cx="22" cy="${markerY}" r="3" fill="${unlocked ? "#10b981" : "var(--rs-border)"}" stroke="none"/>`;
}).join("");
phaseHtml = `<div style="display:flex;gap:2px;margin:6px 0">${phaseSegs}</div>
<div style="font-size:10px;color:var(--rs-text-secondary);text-align:center">${unlockedCount}/${d.phases.length} phases unlocked</div>`;
}
// Overflow splash at rim when overfunded
const overflowSplash = isOverfunded ? `
<ellipse class="overflow-spill-left" cx="10" cy="0" rx="12" ry="6" fill="url(#overflow-splash)"/>
<ellipse class="overflow-spill-right" cx="${w - 10}" cy="0" rx="12" ry="6" fill="url(#overflow-splash)"/>` : "";
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
// Phase segments for header
let phaseSeg = "";
if (d.phases && d.phases.length > 0) {
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
phaseSeg = `<span style="font-size:9px;color:var(--rs-text-secondary)">${unlockedCount}/${d.phases.length} phases</span>`;
}
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
<div class="card-header" style="background:var(--rs-bg-surface-raised);padding:8px 12px;border-bottom:1px solid var(--rs-border)">
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
</div>
</div>
<div style="padding:8px 12px">
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:4px">${Math.round(fillPct * 100)}% funded ${dollarLabel}</div>
<div style="height:6px;background:var(--rs-border);border-radius:3px;overflow:hidden">
<div style="width:${fillPct * 100}%;height:100%;background:${statusColor};border-radius:3px;transition:width 0.3s"></div>
</div>
${phaseHtml}
</div>
<defs>
<clipPath id="${clipId}"><path d="${basinClosedPath}"/></clipPath>
</defs>
<!-- Basin outline -->
<path class="node-bg basin-outline" d="${basinPath}" fill="var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}" stroke-linecap="round"/>
<!-- Water fill clipped to basin -->
<g clip-path="url(#${clipId})">
${waterRect}
${ripple}
${phaseMarkers}
</g>
${overflowSplash}
<!-- Header above basin -->
<foreignObject x="-10" y="-32" width="${w + 20}" height="34">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
${phaseSeg}
</div>
</foreignObject>
<!-- Funding text centered in basin -->
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 4, h / 2 + 4)}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="12" font-weight="600" font-family="ui-monospace,monospace" pointer-events="none" opacity="0.9">${Math.round(fillPct * 100)}%</text>
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 18, h / 2 + 18)}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="10" pointer-events="none">${dollarLabel}</text>
${this.renderPortsSvg(n)}
</g>`;
}
@ -2454,7 +2605,10 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY };
// X position: fully outside the vessel walls (pipe extends outward)
const pipeW = 28;
const xPos = def.side === "left" ? node.position.x - pipeW : node.position.x + s.w + pipeW;
return { x: xPos, y: node.position.y + maxLineY };
}
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
@ -2471,8 +2625,19 @@ class FolkFlowsApp extends HTMLElement {
const s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type);
return defs.map((p) => {
const cx = s.w * p.xFrac;
const cy = s.h * p.yFrac;
let cx = s.w * p.xFrac;
let cy = s.h * p.yFrac;
// Funnel overflow ports: position at pipe ends (max threshold line)
if (n.type === "funnel" && p.kind === "overflow" && p.side) {
const d = n.data as FunnelNodeData;
const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
cy = zoneTop + zoneH * (1 - maxFrac);
const pipeW = 28;
cx = p.side === "left" ? -pipeW : s.w + pipeW;
}
let arrow: string;
const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
if (p.side) {
@ -2767,13 +2932,12 @@ class FolkFlowsApp extends HTMLElement {
if (node.type === "funnel") {
const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 10000);
const valveInset = 0.30 - outflowRatio * 0.18;
const valveInsetPx = Math.round(s.w * valveInset);
const drainWidth = s.w - 2 * valveInsetPx;
// Drain spout width for tapered vessel
const drainW = 60;
const drainInset = (s.w - drainW) / 2;
overlay.innerHTML = `
<rect class="valve-drag-handle" x="${valveInsetPx - 8}" y="${s.h - 16}" width="${drainWidth + 16}" height="18" rx="5"
<rect class="valve-drag-handle" x="${drainInset - 8}" y="${s.h - 16}" width="${drainW + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo
@ -3102,6 +3266,8 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36;
const zoneBot = s.h - 6;
const zoneH = zoneBot - zoneTop;
const drainW = 60;
const taperAtBottom = (s.w - drainW) / 2;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
@ -3111,10 +3277,12 @@ class FolkFlowsApp extends HTMLElement {
for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac);
const yFrac = (markerY - zoneTop) / zoneH;
const inset = this.vesselWallInset(yFrac, taperAtBottom);
overlay.innerHTML += `
<line class="threshold-marker" x1="8" x2="${s.w - 8}" y1="${markerY}" y2="${markerY}" style="stroke:${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
<rect class="threshold-handle" x="${s.w - 56}" y="${markerY - 9}" width="52" height="18" rx="4" style="fill:${t.color};cursor:ns-resize" data-threshold="${t.key}"/>
<text x="${s.w - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
<line class="threshold-marker" x1="${inset + 4}" x2="${s.w - inset - 4}" y1="${markerY}" y2="${markerY}" style="stroke:${t.color}" stroke-width="2" stroke-dasharray="4 2"/>
<rect class="threshold-handle" x="${s.w - inset - 56}" y="${markerY - 9}" width="52" height="18" rx="4" style="fill:${t.color};cursor:ns-resize" data-threshold="${t.key}"/>
<text x="${s.w - inset - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`;
}
}
@ -4493,23 +4661,20 @@ class FolkFlowsApp extends HTMLElement {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
// Try to patch fill rects in-place for smooth CSS transitions
// Try to patch fill paths in-place for smooth CSS transitions
let didPatch = false;
for (const n of this.nodes) {
if (n.type !== "funnel") continue;
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
const h = s.h;
const zoneTop = 28;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const w = s.w, h = s.h;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH;
const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null;
if (fillRect) {
fillRect.setAttribute("y", String(fillY));
fillRect.setAttribute("height", String(totalFillH));
const drainW = 60;
const taperAtBottom = (w - drainW) / 2;
const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
const fillEl = nodeLayer.querySelector(`.funnel-fill-path[data-node-id="${n.id}"]`) as SVGPathElement | null;
if (fillEl && fillPath) {
fillEl.setAttribute("d", fillPath);
didPatch = true;
}
// Patch value text

View File

@ -12,14 +12,14 @@ export const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc
export const demoNodes: FlowNode[] = [
// ── Sources (Y=-300) ──
{
id: "source-a", type: "source", position: { x: 440, y: -300 },
id: "source-a", type: "source", position: { x: 480, y: -300 },
data: {
label: "Grants & Donations", flowRate: 7500, sourceType: "card",
targetAllocations: [{ targetId: "bcrg", percentage: 100, color: "#10b981" }],
} as SourceNodeData,
},
{
id: "source-b", type: "source", position: { x: 880, y: -300 },
id: "source-b", type: "source", position: { x: 900, y: -300 },
data: {
label: "Membership Fees", flowRate: 7500, sourceType: "card",
targetAllocations: [{ targetId: "bcrg", percentage: 100, color: "#10b981" }],
@ -28,7 +28,7 @@ export const demoNodes: FlowNode[] = [
// ── BCRG central funnel (Y=0) ──
{
id: "bcrg", type: "funnel", position: { x: 630, y: 0 },
id: "bcrg", type: "funnel", position: { x: 660, y: 0 },
data: {
label: "BCRG", currentValue: 95000, desiredOutflow: 25000,
minThreshold: 25000, sufficientThreshold: 100000, maxThreshold: 150000,
@ -46,7 +46,7 @@ export const demoNodes: FlowNode[] = [
// ── Person funnels (Y=400) ──
{
id: "alice", type: "funnel", position: { x: 100, y: 400 },
id: "alice", type: "funnel", position: { x: 80, y: 400 },
data: {
label: "Alice", currentValue: 18000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@ -72,7 +72,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
id: "carol", type: "funnel", position: { x: 660, y: 400 },
id: "carol", type: "funnel", position: { x: 680, y: 400 },
data: {
label: "Carol", currentValue: 22000, desiredOutflow: 6000,
minThreshold: 6000, sufficientThreshold: 24000, maxThreshold: 36000,
@ -85,7 +85,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
id: "dave", type: "funnel", position: { x: 940, y: 400 },
id: "dave", type: "funnel", position: { x: 980, y: 400 },
data: {
label: "Dave", currentValue: 10000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@ -98,7 +98,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
id: "eve", type: "funnel", position: { x: 1220, y: 400 },
id: "eve", type: "funnel", position: { x: 1280, y: 400 },
data: {
label: "Eve", currentValue: 16000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@ -115,7 +115,7 @@ export const demoNodes: FlowNode[] = [
// ── Outcome nodes (Y=850) — 11 total ──
// Alice's outcomes
{ id: "alice-comms", type: "outcome", position: { x: -10, y: 850 },
{ id: "alice-comms", type: "outcome", position: { x: -20, y: 850 },
data: {
label: "Comms Strategy", description: "Community communications and outreach",
fundingReceived: 12000, fundingTarget: 12000, status: "completed",
@ -133,7 +133,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "alice-events", type: "outcome", position: { x: 210, y: 850 },
{ id: "alice-events", type: "outcome", position: { x: 260, y: 850 },
data: {
label: "Event Series", description: "Quarterly community gatherings",
fundingReceived: 6000, fundingTarget: 15000, status: "in-progress",
@ -150,7 +150,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Bob's outcomes
{ id: "bob-research", type: "outcome", position: { x: 320, y: 850 },
{ id: "bob-research", type: "outcome", position: { x: 400, y: 850 },
data: {
label: "Field Research", description: "Participatory action research in partner communities",
fundingReceived: 8000, fundingTarget: 20000, status: "in-progress",
@ -165,7 +165,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "bob-writing", type: "outcome", position: { x: 490, y: 850 },
{ id: "bob-writing", type: "outcome", position: { x: 680, y: 850 },
data: {
label: "Publications", description: "Research papers and policy briefs",
fundingReceived: 2000, fundingTarget: 10000, status: "not-started",
@ -180,7 +180,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Carol's outcomes
{ id: "carol-ops", type: "outcome", position: { x: 600, y: 850 },
{ id: "carol-ops", type: "outcome", position: { x: 820, y: 850 },
data: {
label: "Operations", description: "Day-to-day operational management",
fundingReceived: 18000, fundingTarget: 18000, status: "completed",
@ -198,7 +198,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "carol-infra", type: "outcome", position: { x: 760, y: 850 },
{ id: "carol-infra", type: "outcome", position: { x: 1100, y: 850 },
data: {
label: "Infrastructure", description: "Shared infrastructure and hosting",
fundingReceived: 10000, fundingTarget: 20000, status: "in-progress",
@ -215,7 +215,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Dave's outcomes
{ id: "dave-design", type: "outcome", position: { x: 880, y: 850 },
{ id: "dave-design", type: "outcome", position: { x: 1240, y: 850 },
data: {
label: "Design System", description: "Shared UI/UX design system",
fundingReceived: 15000, fundingTarget: 15000, status: "completed",
@ -233,7 +233,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "dave-prototypes", type: "outcome", position: { x: 1050, y: 850 },
{ id: "dave-prototypes", type: "outcome", position: { x: 1520, y: 850 },
data: {
label: "Prototypes", description: "Rapid prototyping of new tools",
fundingReceived: 3000, fundingTarget: 12000, status: "in-progress",
@ -251,7 +251,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Eve's outcomes
{ id: "eve-legal", type: "outcome", position: { x: 1140, y: 850 },
{ id: "eve-legal", type: "outcome", position: { x: 1660, y: 850 },
data: {
label: "Legal Framework", description: "Legal structure and agreements",
fundingReceived: 10000, fundingTarget: 10000, status: "completed",
@ -266,7 +266,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "eve-compliance", type: "outcome", position: { x: 1280, y: 850 },
{ id: "eve-compliance", type: "outcome", position: { x: 1940, y: 850 },
data: {
label: "Compliance", description: "Regulatory compliance and reporting",
fundingReceived: 4000, fundingTarget: 12000, status: "in-progress",
@ -282,7 +282,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
{ id: "eve-governance", type: "outcome", position: { x: 1430, y: 850 },
{ id: "eve-governance", type: "outcome", position: { x: 2220, y: 850 },
data: {
label: "Governance Model", description: "Governance framework and voting mechanisms",
fundingReceived: 1000, fundingTarget: 8000, status: "not-started",

View File

@ -129,12 +129,12 @@ export interface PortDefinition {
/** Single source of truth for port positions, colors, and connectivity rules. */
export const PORT_DEFS: Record<FlowNode["type"], PortDefinition[]> = {
source: [
{ kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
{ kind: "outflow", dir: "out", xFrac: 0.75, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
],
funnel: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },
{ kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "left" },
{ kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
{ kind: "overflow", dir: "out", xFrac: 0.08, yFrac: 0.08, color: "#f59e0b", connectsTo: ["inflow"], side: "left" },
{ kind: "overflow", dir: "out", xFrac: 0.92, yFrac: 0.08, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
{ kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] },
],
outcome: [