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="left"] .port-arrow { /* horizontal arrow left handled inline */ }
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ } .port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
/* ── Funnel fill animation ─────────────────────────── */ /* ── Vessel fill animation (tapered path) ──────────── */
.funnel-fill-rect { transition: y 120ms ease-out, height 120ms ease-out; } .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 ──────────────────────── */ /* ── Simulation speed slider ──────────────────────── */
.flows-sim-speed { .flows-sim-speed {

View File

@ -949,6 +949,44 @@ class FolkFlowsApp extends HTMLElement {
<stop offset="40%" stop-color="#8892a0"/> <stop offset="40%" stop-color="#8892a0"/>
<stop offset="100%" stop-color="#626d7d"/> <stop offset="100%" stop-color="#626d7d"/>
</linearGradient> </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> </defs>
<g id="canvas-transform"> <g id="canvas-transform">
<g id="edge-layer"></g> <g id="edge-layer"></g>
@ -1095,16 +1133,16 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } { private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") { if (n.type === "source") {
return { w: 200, h: 160 }; return { w: 260, h: 120 };
} }
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const baseW = 280; const baseW = 260;
const cap = d.maxCapacity || 9000; const cap = d.maxCapacity || 9000;
const h = Math.round(200 + Math.min(200, (cap / 50000) * 200)); const h = Math.round(220 + Math.min(200, (cap / 50000) * 200));
return { w: baseW, h: Math.max(200, h) }; 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 ────────────────────────────── // ─── Canvas event wiring ──────────────────────────────
@ -1739,39 +1777,39 @@ class FolkFlowsApp extends HTMLElement {
const valveColor = valveColors[d.sourceType] || "#64748b"; const valveColor = valveColors[d.sourceType] || "#64748b";
const isConfigured = d.sourceType !== "unconfigured"; const isConfigured = d.sourceType !== "unconfigured";
// Pipe header dimensions // Horizontal pipe from left edge to valve
const pipeH = 22; const pipeH = 22;
const pipeY = 0; const pipeCY = 40; // vertical center of pipe
const pipeRx = 6; const pipeY = pipeCY - pipeH / 2;
// Valve body // Valve: circle at center
const valveR = 28; const valveR = 22;
const valveCx = w / 2; const valveCx = w * 0.5;
const valveCy = pipeY + pipeH + valveR + 4; const valveCy = pipeCY;
// Valve handle rotation: 45° configured, 90° unconfigured // Handle rotation: 0°=closed(up), maps flowRate to angle (max 90°=open/right)
const handleAngle = isConfigured ? 45 : 90; const maxRate = 50000;
const handleAngle = isConfigured ? Math.min(90, (d.flowRate / maxRate) * 90) : 0;
// Spigot: trapezoid below valve // Nozzle: trapezoid from valve right, angling 30° downward-right to x=w*0.75
const spigotTop = valveCy + valveR + 2; const nozzleStartX = valveCx + valveR + 2;
const spigotTopW = 24; const nozzleEndX = w * 0.75;
const spigotBotW = 14; const nozzleStartY = pipeCY;
const spigotH = 20; const nozzleEndY = pipeCY + (nozzleEndX - nozzleStartX) * Math.tan(30 * Math.PI / 180);
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`; 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 // Stream: rect from nozzle tip downward, width proportional to sqrt(flowRate/100)
const streamMaxW = w - 40; const streamW = Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5));
const streamW = Math.round(8 + Math.min(streamMaxW - 8, Math.sqrt(d.flowRate / 100) * (streamMaxW / 6))); const streamX = nozzleEndX;
const streamY = spigotTop + spigotH; const streamY = nozzleEndY + nozzleBotW;
const streamH = h - streamY; const streamH = h - streamY;
// Amount text // Allocation bar
const amountY = valveCy + valveR + spigotH + 8;
// Allocation bar as SVG rects
let allocBar = ""; let allocBar = "";
if (d.targetAllocations && d.targetAllocations.length > 0) { if (d.targetAllocations && d.targetAllocations.length > 0) {
const barY = h - 10; const barY = h - 8;
const barW = w - 40; const barW = w - 40;
const barX = 20; const barX = 20;
let cx = barX; 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})"> 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="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}"/> <!-- Horizontal pipe from left to valve -->
<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> <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}"/>
<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"/> <!-- Label on pipe -->
<g transform="rotate(${handleAngle},${valveCx},${valveCy})"> <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>
<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"/> <!-- 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> </g>
<path class="faucet-spigot" d="${spigotPath}" fill="url(#faucet-pipe-grad)" stroke="var(--rs-text-secondary)" stroke-width="1"/> <!-- Nozzle angling downward-right -->
<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}"/> <path class="source-nozzle" d="${nozzlePath}" fill="url(#pipe-metal-h)" stroke="var(--rs-text-secondary)" stroke-width="1"/>
<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> <!-- 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} ${allocBar}
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </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 { private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
@ -1811,27 +1888,88 @@ class FolkFlowsApp extends HTMLElement {
const fillColor = borderColorVar; const fillColor = borderColorVar;
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient"; const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient";
// Tank shape parameters // Vessel shape parameters
const r = 12; const r = 10;
const pipeW = 30; // overflow pipe extension from wall const drainW = 60; // narrow drain spout at bottom
const basePipeH = 24; // base pipe height
const taperStart = 0.80; // body tapers at 80% down
// Drain width proportional to outflow: wider drain = more outflow
const outflow = d.desiredOutflow || 0; const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 10000); const outflowRatio = Math.min(1, outflow / 10000);
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000) // taperAtBottom: how far walls inset at the very bottom (in px)
const insetPx = Math.round(w * taperInset); const taperAtBottom = (w - drainW) / 2;
const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`;
// Interior zone boundaries // Overflow pipe parameters — positioned at max threshold
const pipeW = 28;
const basePipeH = 22;
const zoneTop = 36; const zoneTop = 36;
const zoneBot = h - 6; const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop; 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 minFrac = d.minThreshold / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (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 criticalPct = minFrac;
const sufficientPct = maxFrac - minFrac; const sufficientPct = maxFrac - minFrac;
const overflowPct = Math.max(0, 1 - maxFrac); const overflowPct = Math.max(0, 1 - maxFrac);
@ -1839,53 +1977,30 @@ class FolkFlowsApp extends HTMLElement {
const sufficientH = zoneH * sufficientPct; const sufficientH = zoneH * sufficientPct;
const overflowH = zoneH * overflowPct; const overflowH = zoneH * overflowPct;
// Pipe position at max threshold line // Fill path (tapered polygon)
const maxLineY = zoneTop + zoneH * (1 - maxFrac); const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
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
const totalFillH = zoneH * fillPct; const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH; 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 minLineY = zoneTop + zoneH * (1 - minFrac);
const minYFrac = (minLineY - zoneTop) / zoneH;
const minInset = this.vesselWallInset(minYFrac, taperAtBottom);
const maxInset = this.vesselWallInset(pipeYFrac, taperAtBottom);
const thresholdLines = ` 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"/> <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="10" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text> <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="6" x2="${w - 6}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/> <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="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`; <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 // Inflow satisfaction bar
const satBarY = 50; const satBarY = 50;
@ -1901,39 +2016,39 @@ class FolkFlowsApp extends HTMLElement {
// Rate labels // Rate labels
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`; 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 excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : ""; 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 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"; 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}"> 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> <defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath> <clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
</defs> </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})"/>` : ""} ${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="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/> <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})"> <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 + 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 + 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 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} ${thresholdLines}
</g> </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="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}"/> <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="${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 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}"> <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"/> 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"> <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 ${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>` : ""} ${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>` : ""} ${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>` : ""} ${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> <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> ${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>` : ""} <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 s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h; 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 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 statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
const statusColor = statusColors[d.status] || "#64748b"; const statusColor = statusColors[d.status] || "#64748b";
const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase()); const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());
// Phase indicators // Basin water gradient by status
let phaseHtml = ""; 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) { if (d.phases && d.phases.length > 0) {
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length; phaseMarkers = d.phases.map((p) => {
const phaseSegs = d.phases.map((p, i) => { 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; 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(""); }).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)}`; 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})"> 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}"/> <defs>
<foreignObject x="0" y="0" width="${w}" height="${h}"> <clipPath id="${clipId}"><path d="${basinClosedPath}"/></clipPath>
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}"> </defs>
<div class="card-header" style="background:var(--rs-bg-surface-raised);padding:8px 12px;border-bottom:1px solid var(--rs-border)"> <!-- Basin outline -->
<div style="display:flex;align-items:center;justify-content:space-between"> <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: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> <span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
</div> ${phaseSeg}
</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>
</div> </div>
</foreignObject> </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)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
@ -2454,7 +2605,10 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop; const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
const maxFrac = d.maxThreshold / (d.maxCapacity || 1); const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac); 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 }; 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 s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type); const defs = this.getPortDefs(n.type);
return defs.map((p) => { return defs.map((p) => {
const cx = s.w * p.xFrac; let cx = s.w * p.xFrac;
const cy = s.h * p.yFrac; 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; let arrow: string;
const sideAttr = p.side ? ` data-port-side="${p.side}"` : ""; const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
if (p.side) { if (p.side) {
@ -2767,13 +2932,12 @@ class FolkFlowsApp extends HTMLElement {
if (node.type === "funnel") { if (node.type === "funnel") {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0; const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 10000); // Drain spout width for tapered vessel
const valveInset = 0.30 - outflowRatio * 0.18; const drainW = 60;
const valveInsetPx = Math.round(s.w * valveInset); const drainInset = (s.w - drainW) / 2;
const drainWidth = s.w - 2 * valveInsetPx;
overlay.innerHTML = ` 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"/> 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"> <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 ${this.formatDollar(outflow)}/mo
@ -3102,6 +3266,8 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36; const zoneTop = 36;
const zoneBot = s.h - 6; const zoneBot = s.h - 6;
const zoneH = zoneBot - zoneTop; const zoneH = zoneBot - zoneTop;
const drainW = 60;
const taperAtBottom = (s.w - drainW) / 2;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, { 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) { for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1); const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac); const markerY = zoneTop + zoneH * (1 - frac);
const yFrac = (markerY - zoneTop) / zoneH;
const inset = this.vesselWallInset(yFrac, taperAtBottom);
overlay.innerHTML += ` 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"/> <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 - 56}" y="${markerY - 9}" width="52" height="18" rx="4" style="fill:${t.color};cursor:ns-resize" data-threshold="${t.key}"/> <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 - 30}" y="${markerY + 4}" fill="white" font-size="9" text-anchor="middle" pointer-events="none">${t.label} ${this.formatDollar(t.value)}</text>`; <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"); const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return; 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; let didPatch = false;
for (const n of this.nodes) { for (const n of this.nodes) {
if (n.type !== "funnel") continue; if (n.type !== "funnel") continue;
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
const h = s.h; const w = s.w, h = s.h;
const zoneTop = 28;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const totalFillH = zoneH * fillPct; const drainW = 60;
const fillY = zoneTop + zoneH - totalFillH; const taperAtBottom = (w - drainW) / 2;
const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null; const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
if (fillRect) { const fillEl = nodeLayer.querySelector(`.funnel-fill-path[data-node-id="${n.id}"]`) as SVGPathElement | null;
fillRect.setAttribute("y", String(fillY)); if (fillEl && fillPath) {
fillRect.setAttribute("height", String(totalFillH)); fillEl.setAttribute("d", fillPath);
didPatch = true; didPatch = true;
} }
// Patch value text // Patch value text

View File

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