From f0cc50a060b2ce2853d5fc3b45dafbb037947967 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 18:55:10 -0700 Subject: [PATCH] =?UTF-8?q?feat(rflows):=20water-themed=20canvas=20visual?= =?UTF-8?q?=20overhaul=20=E2=80=94=20taps,=20vessels,=20pools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/rflows/components/flows.css | 30 +- modules/rflows/components/folk-flows-app.ts | 465 +++++++++++++------- modules/rflows/lib/presets.ts | 36 +- modules/rflows/lib/types.ts | 6 +- 4 files changed, 364 insertions(+), 173 deletions(-) diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index aeb3415..aa8e9a0 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -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 { diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index e0daa70..e949d64 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -949,6 +949,44 @@ class FolkFlowsApp extends HTMLElement { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -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 ` - - ${this.esc(d.label)} - - - + + + + ${this.esc(d.label)} + + + + + - - - $${d.flowRate.toLocaleString()}/mo + + + + + + $${d.flowRate.toLocaleString()}/mo ${allocBar} ${this.renderPortsSvg(n)} `; } + /** 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 = ` - - Min - - Max`; + + Min + + Max`; + + // Water surface shimmer line at fill level + const shimmerLine = fillPct > 0.01 ? `` : ""; + + // Overflow spill effects at pipe positions + const overflowSpill = isOverflow ? ` + + ` : ""; // 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 ` - + - ${isOverflow ? `` : ""} - + ${isOverflow ? `` : ""} + - + ${fillPath ? `` : ""} + ${shimmerLine} ${thresholdLines} + + ${overflowSpill} - + - ◁ ${this.formatDollar(outflow)}/mo ▷ @@ -1955,7 +2070,7 @@ class FolkFlowsApp extends HTMLElement { ${criticalH > 20 ? `
CRITICAL
` : ""} ${sufficientH > 20 ? `
SUFFICIENT
` : ""} ${overflowH > 20 ? `
OVERFLOW
` : ""} -
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}
+
$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}
${this.formatDollar(outflow)}/mo \u25BE
${isOverflow ? `
${overflowLabel}
${overflowLabel}
` : ""} @@ -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 = { 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 = { 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 ? `` : ""; + + // Ripple pattern on water surface + const ripple = fillPct > 0.05 ? `` : ""; + + // 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 `
`; + return ` + `; }).join(""); - phaseHtml = `
${phaseSegs}
-
${unlockedCount}/${d.phases.length} phases unlocked
`; } + // Overflow splash at rim when overfunded + const overflowSplash = isOverfunded ? ` + + ` : ""; + 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 = `${unlockedCount}/${d.phases.length} phases`; + } + return ` - - -
-
-
- ${this.esc(d.label)} - ${statusLabel} -
-
-
-
${Math.round(fillPct * 100)}% funded — ${dollarLabel}
-
-
-
- ${phaseHtml} -
+ + + + + + + + ${waterRect} + ${ripple} + ${phaseMarkers} + + ${overflowSplash} + + +
+ ${this.esc(d.label)} + ${statusLabel} + ${phaseSeg}
+ + ${Math.round(fillPct * 100)}% + ${dollarLabel} ${this.renderPortsSvg(n)} `; } @@ -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 = ` - ◁ ${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 += ` - - - ${t.label} ${this.formatDollar(t.value)}`; + + + ${t.label} ${this.formatDollar(t.value)}`; } } @@ -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 diff --git a/modules/rflows/lib/presets.ts b/modules/rflows/lib/presets.ts index 67285e7..5174726 100644 --- a/modules/rflows/lib/presets.ts +++ b/modules/rflows/lib/presets.ts @@ -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", diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts index 619bce4..9ab42ad 100644 --- a/modules/rflows/lib/types.ts +++ b/modules/rflows/lib/types.ts @@ -129,12 +129,12 @@ export interface PortDefinition { /** Single source of truth for port positions, colors, and connectivity rules. */ export const PORT_DEFS: Record = { 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: [