feat(rflows): water-themed canvas visual overhaul — taps, vessels, pools
Replaces vertical faucet sources with horizontal side taps (pipe → rotary valve → angled nozzle → stream), rectangular tank funnels with tapered vessels (wide top → narrow drain spout, overflow pipes at max threshold), and card-style outcomes with U-shaped collection basins (status-colored water fill, ripple patterns, phase markers). Adds SVG defs for metallic pipe gradients, water surface shimmer, ripple patterns, overflow splash effects, and status-colored basin water fills. CSS animations for water shimmer, overflow pulse, basin transitions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3b3a642813
commit
f0cc50a060
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue