From 3cfec226a4c3b0702d3c3aa3c6ab39fd1bbe9168 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 23 Mar 2026 15:08:08 -0700 Subject: [PATCH] fix(rflows): proportional node sizing + remove organic visualization Nodes now scale with dollar values (sources by flowRate, funnels by maxCapacity, outcomes by fundingTarget). Removed unused organic/ mycorrhizal render mode including renderer, CSS, and all toggle logic. Co-Authored-By: Claude Opus 4.6 --- modules/rflows/components/flows.css | 114 --- modules/rflows/components/folk-flows-app.ts | 81 +-- .../components/folk-flows-organic-renderer.ts | 648 ------------------ 3 files changed, 17 insertions(+), 826 deletions(-) delete mode 100644 modules/rflows/components/folk-flows-organic-renderer.ts diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index e8cf767..b247013 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -71,120 +71,6 @@ --rflows-modal-border: #334155; } -/* ── Organic / Mycorrhizal mode overrides ────────────── */ -:host([data-render-mode="organic"]) { - /* Source node */ - --rflows-source-bg: #365314; - --rflows-source-border: #84cc16; - --rflows-source-rate: #a3e635; - - /* Edge colors — earth tones */ - --rflows-edge-inflow: #84cc16; - --rflows-edge-spending: #fbbf24; - --rflows-edge-overflow: #a3e635; - - /* Funnel zones */ - --rflows-zone-drain: #7f1d1d; - --rflows-zone-drain-opacity: 0.06; - --rflows-zone-healthy: #365314; - --rflows-zone-healthy-opacity: 0.06; - --rflows-zone-overflow: #a16207; - --rflows-zone-overflow-opacity: 0.05; - --rflows-fill-opacity: 0.35; - - /* Funnel labels */ - --rflows-label-inflow: #84cc16; - --rflows-label-spending: #fbbf24; - --rflows-label-overflow: #a3e635; - - /* Status colors — organic palette */ - --rflows-status-critical: #b91c1c; - --rflows-status-sustained: #a16207; - --rflows-status-overflow: #65a30d; - --rflows-status-thriving: #65a30d; - --rflows-sat-bar: #84cc16; - --rflows-sat-border: #d97706; - - /* Outcome / progress */ - --rflows-status-completed: #65a30d; - --rflows-status-blocked: #b91c1c; - --rflows-status-inprogress: #a16207; - --rflows-status-notstarted: #78716c; - --rflows-phase-unlocked: #84cc16; - - /* Score badge */ - --rflows-score-gold: #d97706; - --rflows-score-green: #65a30d; - --rflows-score-amber: #a16207; - --rflows-score-red: #b91c1c; - - /* Card value */ - --rflows-card-value: #d97706; - - /* Selection */ - --rflows-selected: #84cc16; - - /* Inline edit buttons */ - --rflows-btn-done: #65a30d; - --rflows-btn-delete: #b91c1c; - --rflows-btn-fund: #84cc16; - --rflows-btn-save: #65a30d; - - /* Sufficiency tooltip highlight */ - --rflows-sufficiency-highlight: #d97706; - - /* Edge drag handle */ - --rflows-drag-handle-fill: #365314; - --rflows-drag-handle-stroke: #4d7c0f; - - /* Modal border accent */ - --rflows-modal-border: #365314; -} - -/* Organic canvas background */ -:host([data-render-mode="organic"]) .flows-canvas-svg { - background-color: #0f1a0f; - background-image: none; -} - -/* Organic port styling */ -:host([data-render-mode="organic"]) .port-dot { - r: 8; - stroke: #365314; - stroke-width: 2.5; - filter: drop-shadow(0 0 3px currentColor); -} -:host([data-render-mode="organic"]) .port-group:hover .port-dot { - r: 10; - filter: drop-shadow(0 0 6px currentColor); -} - -/* Organic edge animation — sparse dot pattern, slower */ -:host([data-render-mode="organic"]) .org-hypha-path { - stroke-dasharray: 3 8; - animation: organicFlow 2.5s linear infinite; -} -@keyframes organicFlow { to { stroke-dashoffset: -22; } } - -/* Organic overflow bud pulse */ -:host([data-render-mode="organic"]) .org-overflow-bud--active { - animation: budPulse 2s ease-in-out infinite; -} -@keyframes budPulse { - 0%, 100% { ry: 13; opacity: 0.55; } - 50% { ry: 16; opacity: 0.75; } -} - -/* Organic spore terminus glow */ -:host([data-render-mode="organic"]) .org-spore-terminus { - filter: drop-shadow(0 0 3px currentColor); -} - -/* Organic legend dots */ -:host([data-render-mode="organic"]) .flows-canvas-legend-dot { - border-radius: 50%; -} - /* ── Base ────────────────────────────────────────────── */ .flows-landing, .flows-detail { font-family: system-ui, -apple-system, sans-serif; diff --git a/modules/rflows/components/folk-flows-app.ts b/modules/rflows/components/folk-flows-app.ts index a66a109..0d325c7 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -20,7 +20,7 @@ import { mapFlowToNodes } from "../lib/map-flow"; import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas"; import type { DocumentId } from "../../../shared/local-first/document"; import { FlowsLocalFirstClient } from "../local-first-client"; -import { OrganicRenderer, organicSvgDefs, type OrganicRendererContext } from "./folk-flows-organic-renderer"; + interface FlowSummary { id: string; @@ -199,27 +199,6 @@ class FolkFlowsApp extends HTMLElement { // Tour engine private _tour!: TourEngine; - // Render mode: mechanical (default) or organic (mycorrhizal) - private renderMode: "mechanical" | "organic" = "mechanical"; - private _organicRenderer: OrganicRenderer | null = null; - private get organicRenderer(): OrganicRenderer { - if (!this._organicRenderer) { - const ctx: OrganicRendererContext = { - getNodeSize: (n) => this.getNodeSize(n), - vesselWallInset: (yFrac, taper) => this.vesselWallInset(yFrac, taper), - computeVesselFillPath: (w, h, fill, taper) => this.computeVesselFillPath(w, h, fill, taper), - renderPortsSvg: (n) => this.renderPortsSvg(n), - renderSplitControl: (nid, at, allocs, cx, cy, tw) => this.renderSplitControl(nid, at, allocs, cx, cy, tw), - formatDollar: (a) => this.formatDollar(a), - esc: (s) => this.esc(s), - _currentFlowWidths: this._currentFlowWidths, - }; - this._organicRenderer = new OrganicRenderer(ctx); - } - // Keep flow widths reference current - (this._organicRenderer as any).ctx._currentFlowWidths = this._currentFlowWidths; - return this._organicRenderer; - } private static readonly TOUR_STEPS = [ { target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true }, { target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true }, @@ -250,11 +229,6 @@ class FolkFlowsApp extends HTMLElement { new MutationObserver(() => this._syncTheme()) .observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); - // Restore render mode preference - const savedMode = localStorage.getItem("rflows:render-mode"); - if (savedMode === "organic" || savedMode === "mechanical") this.renderMode = savedMode; - if (this.renderMode === "organic") this.setAttribute("data-render-mode", "organic"); - // Read view attribute, default to canvas (detail) view const viewAttr = this.getAttribute("view"); this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; @@ -1022,8 +996,6 @@ class FolkFlowsApp extends HTMLElement { -
- @@ -1079,8 +1051,7 @@ class FolkFlowsApp extends HTMLElement { - ${organicSvgDefs()} - + @@ -1236,16 +1207,25 @@ class FolkFlowsApp extends HTMLElement { private getNodeSize(n: FlowNode): { w: number; h: number } { if (n.type === "source") { - return { w: 260, h: 120 }; + const d = n.data as SourceNodeData; + const rate = d.flowRate || 0; + const w = Math.round(200 + Math.min(160, (rate / 20000) * 160)); + const h = Math.round(100 + Math.min(80, (rate / 20000) * 80)); + return { w, h }; } if (n.type === "funnel") { const d = n.data as FunnelNodeData; - const baseW = 260; const cap = d.maxCapacity || 9000; - const h = Math.round(220 + Math.min(200, (cap / 50000) * 200)); - return { w: baseW, h: Math.max(220, h) }; + const w = Math.round(200 + Math.min(160, (cap / 50000) * 160)); + const h = Math.round(200 + Math.min(220, (cap / 50000) * 220)); + return { w, h }; } - return { w: 260, h: 140 }; // basin pool + // outcome (basin pool) + const d = n.data as OutcomeNodeData; + const target = d.fundingTarget || 0; + const w = Math.round(200 + Math.min(160, (target / 50000) * 160)); + const h = Math.round(120 + Math.min(80, (target / 50000) * 80)); + return { w, h }; } // ─── Canvas event wiring ────────────────────────────── @@ -1598,8 +1578,7 @@ class FolkFlowsApp extends HTMLElement { else if (action === "quick-fund") this.quickFund(); else if (action === "share") this.shareState(); else if (action === "tour") this.startTour(); - else if (action === "toggle-render-mode") this.toggleRenderMode(); - else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } + else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); } else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } else if (action === "flow-picker") this.toggleFlowDropdown(); }); @@ -1900,9 +1879,6 @@ class FolkFlowsApp extends HTMLElement { private renderNodeSvg(n: FlowNode, satisfaction: Map): string { const sel = this.selectedNodeId === n.id; - if (this.renderMode === "organic") { - return this.organicRenderer.renderNodeSvg(n, sel, satisfaction.get(n.id)); - } if (n.type === "source") return this.renderSourceNodeSvg(n, sel); if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id)); return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id)); @@ -2589,12 +2565,6 @@ class FolkFlowsApp extends HTMLElement { fromSide?: "left" | "right", waypoint?: { x: number; y: number }, ): string { - if (this.renderMode === "organic") { - return this.organicRenderer.renderEdgePath( - x1, y1, x2, y2, color, strokeW, dashed, ghost, - label, fromId, toId, edgeType, fromSide, waypoint, - ); - } let d: string; let midX: number; let midY: number; @@ -5007,23 +4977,6 @@ class FolkFlowsApp extends HTMLElement { // ─── Simulation ─────────────────────────────────────── - private toggleRenderMode() { - this.renderMode = this.renderMode === "mechanical" ? "organic" : "mechanical"; - localStorage.setItem("rflows:render-mode", this.renderMode); - if (this.renderMode === "organic") { - this.setAttribute("data-render-mode", "organic"); - } else { - this.removeAttribute("data-render-mode"); - } - // Re-render toolbar button state - const btn = this.shadow.querySelector('[data-canvas-action="toggle-render-mode"]') as HTMLElement | null; - if (btn) { - btn.textContent = `🍄 ${this.renderMode === "organic" ? "Organic" : "Mechanical"}`; - btn.classList.toggle("flows-toolbar-btn--active", this.renderMode === "organic"); - } - this.drawCanvasContent(); - } - private toggleSimulation() { this.isSimulating = !this.isSimulating; const btn = this.shadow.getElementById("sim-btn"); diff --git a/modules/rflows/components/folk-flows-organic-renderer.ts b/modules/rflows/components/folk-flows-organic-renderer.ts deleted file mode 100644 index d9a70e7..0000000 --- a/modules/rflows/components/folk-flows-organic-renderer.ts +++ /dev/null @@ -1,648 +0,0 @@ -/** - * Organic / Mycorrhizal renderer for rFlows canvas. - * - * Same data, same port positions, same interactions — only the SVG shape - * strings and color palette change. Sources become sporangia, funnels - * become mycorrhizal junctions, outcomes become fruiting bodies, and edges - * become branching hyphae. - */ - -import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, PortKind } from "../lib/types"; - -/* ── Host context interface ───────────────────────────── */ - -export interface OrganicRendererContext { - getNodeSize(n: FlowNode): { w: number; h: number }; - vesselWallInset(yFrac: number, taperAtBottom: number): number; - computeVesselFillPath(w: number, h: number, fillPct: number, taperAtBottom: number): string; - renderPortsSvg(n: FlowNode): string; - renderSplitControl( - nodeId: string, allocType: string, - allocs: { targetId: string; percentage: number; color: string }[], - cx: number, cy: number, trackW: number, - ): string; - formatDollar(amount: number): string; - esc(s: string): string; - _currentFlowWidths: Map; -} - -/* ── Organic SVG defs (appended alongside mechanical defs) ── */ - -export function organicSvgDefs(): string { - return ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; -} - -/* ── OrganicRenderer class ────────────────────────────── */ - -export class OrganicRenderer { - constructor(private ctx: OrganicRendererContext) {} - - /* Deterministic noise from node ID + index → 0..1 */ - private nodeNoise(nodeId: string, index: number): number { - let h = 5381; - for (let i = 0; i < nodeId.length; i++) h = ((h << 5) + h) ^ nodeId.charCodeAt(i); - h ^= index * 2654435761; - return (h >>> 0) / 4294967296; - } - - /* Small wobble offset (±px) seeded by nodeId */ - private wobble(nodeId: string, idx: number, maxPx: number): number { - return (this.nodeNoise(nodeId, idx) - 0.5) * 2 * maxPx; - } - - /* ── Node dispatch ─────────────────────────────────── */ - - renderNodeSvg( - n: FlowNode, - selected: boolean, - satisfaction?: { actual: number; needed: number; ratio: number }, - ): string { - if (n.type === "source") return this.renderSourceSporangium(n, selected); - if (n.type === "funnel") return this.renderFunnelJunction(n, selected, satisfaction); - return this.renderOutcomeFruitingBody(n, selected, satisfaction); - } - - /* ── Source → Sporangium ───────────────────────────── */ - - private renderSourceSporangium(n: FlowNode, selected: boolean): string { - const d = n.data as SourceNodeData; - const s = this.ctx.getNodeSize(n); - const x = n.position.x, y = n.position.y, w = s.w, h = s.h; - - const cx = w * 0.5, cy = 38; - const rx = 36 + this.wobble(n.id, 0, 3); - const ry = 28 + this.wobble(n.id, 1, 2); - - // Irregular bulb via cubic bezier - const bulbPath = this.irregularEllipse(cx, cy, rx, ry, n.id); - - // Spore cap color encodes source type - const capColors: Record = { - card: "#60a5fa", safe_wallet: "#84cc16", ridentity: "#a78bfa", - metamask: "#fb923c", unconfigured: "#78716c", - }; - const capColor = capColors[d.sourceType] || "#78716c"; - const isConfigured = d.sourceType !== "unconfigured"; - - // Tendrils radiating downward from bulb - const fw = this.ctx._currentFlowWidths.get(n.id); - const streamW = fw ? Math.max(4, Math.round(fw.outflowWidthPx * 0.4)) : Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5)); - const tendrils = this.renderTendrils(cx, cy + ry, w, h, streamW, n.id, isConfigured); - - // Split control - const allocBar = d.targetAllocations && d.targetAllocations.length >= 2 - ? this.ctx.renderSplitControl(n.id, "source", d.targetAllocations, w / 2, h - 8, w - 40) - : ""; - - const selStroke = selected ? `stroke="var(--rflows-selected)" stroke-width="3"` : `stroke="#4d7c0f" stroke-width="1.5"`; - - return ` - - ${tendrils} - - - - - - ${this.ctx.esc(d.label)} - - $${d.flowRate.toLocaleString()}/mo - ${allocBar} - ${this.ctx.renderPortsSvg(n)} - `; - } - - /** Build an irregular ellipse path from cubic beziers */ - private irregularEllipse(cx: number, cy: number, rx: number, ry: number, nodeId: string): string { - const pts = 8; - const coords: { x: number; y: number }[] = []; - for (let i = 0; i < pts; i++) { - const angle = (Math.PI * 2 * i) / pts; - const wobbleR = 1 + this.nodeNoise(nodeId, i + 10) * 0.12 - 0.06; - coords.push({ - x: cx + Math.cos(angle) * rx * wobbleR, - y: cy + Math.sin(angle) * ry * wobbleR, - }); - } - let path = `M ${coords[0].x},${coords[0].y}`; - for (let i = 0; i < pts; i++) { - const curr = coords[i]; - const next = coords[(i + 1) % pts]; - const cpDist = 0.38; - const angle1 = Math.atan2(next.y - curr.y, next.x - curr.x) - Math.PI * 0.15; - const angle2 = Math.atan2(curr.y - next.y, curr.x - next.x) + Math.PI * 0.15; - const dist = Math.hypot(next.x - curr.x, next.y - curr.y); - const cp1x = curr.x + Math.cos(angle1) * dist * cpDist; - const cp1y = curr.y + Math.sin(angle1) * dist * cpDist; - const cp2x = next.x + Math.cos(angle2) * dist * cpDist; - const cp2y = next.y + Math.sin(angle2) * dist * cpDist; - path += ` C ${cp1x},${cp1y} ${cp2x},${cp2y} ${next.x},${next.y}`; - } - return path + " Z"; - } - - /** Render 3-5 tendrils from sporangium bottom */ - private renderTendrils( - cx: number, startY: number, w: number, h: number, - baseWidth: number, nodeId: string, active: boolean, - ): string { - const count = 3 + Math.floor(this.nodeNoise(nodeId, 50) * 3); // 3-5 - let svg = ""; - for (let i = 0; i < count; i++) { - const frac = (i + 0.5) / count; - const tx = w * 0.2 + w * 0.6 * frac + this.wobble(nodeId, 60 + i, 8); - const tw = Math.max(2, baseWidth * (0.5 + this.nodeNoise(nodeId, 70 + i) * 0.5)); - const endY = h - 2 + this.wobble(nodeId, 80 + i, 4); - const cp1y = startY + (endY - startY) * 0.3 + this.wobble(nodeId, 90 + i, 6); - const cp2y = startY + (endY - startY) * 0.7 + this.wobble(nodeId, 100 + i, 6); - svg += ``; - } - return svg; - } - - /* ── Funnel → Mycorrhizal Junction ─────────────────── */ - - private renderFunnelJunction( - n: FlowNode, selected: boolean, - sat?: { actual: number; needed: number; ratio: number }, - ): string { - const d = n.data as FunnelNodeData; - const s = this.ctx.getNodeSize(n); - const x = n.position.x, y = n.position.y, w = s.w, h = s.h; - const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); - - const isOverflow = d.currentValue > d.maxThreshold; - const isCritical = d.currentValue < d.minThreshold; - - // Reuse taper geometry with organic wobble - const drainW = 60; - const outflow = d.desiredOutflow || 0; - const taperAtBottom = (w - drainW) / 2; - - const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop; - const minFrac = d.minThreshold / (d.maxCapacity || 1); - const maxFrac = d.maxThreshold / (d.maxCapacity || 1); - const maxLineY = zoneTop + zoneH * (1 - maxFrac); - const pipeH = 22; - const pipeY = Math.round(maxLineY - pipeH / 2); - const pipeW = 28; - const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold - ? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)) - : 0; - - // Vessel outline with organic wobble - const vesselPath = this.organicVesselPath(w, h, zoneTop, zoneH, taperAtBottom, pipeY, pipeH, pipeW, n.id); - const clipId = `org-funnel-clip-${n.id}`; - - // Zone dimensions - const criticalPct = minFrac; - const sufficientPct = maxFrac - minFrac; - const overflowPct = Math.max(0, 1 - maxFrac); - const criticalH = zoneH * criticalPct; - const sufficientH = zoneH * sufficientPct; - const overflowH = zoneH * overflowPct; - - // Fill path - const fillPath = this.ctx.computeVesselFillPath(w, h, fillPct, taperAtBottom); - const totalFillH = zoneH * fillPct; - const fillY = zoneTop + zoneH - totalFillH; - - // Organic fill colors - const fillGrad = isCritical ? "url(#org-fill-rust)" - : isOverflow ? "url(#org-fill-green)" - : "url(#org-fill-amber)"; - - // Border color - const borderColor = isCritical ? "#b91c1c" : isOverflow ? "#65a30d" : "#a16207"; - const statusLabel = isCritical ? "Depleted" : isOverflow ? "Abundant" : "Growing"; - - // Threshold lines — bark ridge pattern - const minLineY = zoneTop + zoneH * (1 - minFrac); - const minYFrac = (minLineY - zoneTop) / zoneH; - const minInset = this.ctx.vesselWallInset(minYFrac, taperAtBottom); - const pipeYFrac = (maxLineY - zoneTop) / zoneH; - const maxInset = this.ctx.vesselWallInset(pipeYFrac, taperAtBottom); - - const thresholdLines = this.barkRidgeLines( - minInset, w - minInset, minLineY, "#b91c1c", "Min", n.id, 0, - ) + this.barkRidgeLines( - maxInset, w - maxInset, maxLineY, "#a16207", "Max", n.id, 20, - ); - - // Inflow pipe indicator - const fwFunnel = this.ctx._currentFlowWidths.get(n.id); - const inflowPipeW = fwFunnel ? Math.min(w - 20, Math.round(fwFunnel.inflowWidthPx)) : 0; - const inflowFillRatio = fwFunnel ? fwFunnel.inflowFillRatio : 0; - const inflowPipeX = (w - inflowPipeW) / 2; - const inflowPipeIndicator = inflowPipeW > 0 ? ` - - ` : ""; - - // Satisfaction bar - const satBarY = 50; - const satBarW = w - 48; - const satRatio = sat ? Math.min(sat.ratio, 1) : 0; - const satFillW = satBarW * satRatio; - const satLabel = sat ? `${this.ctx.formatDollar(sat.actual)} of ${this.ctx.formatDollar(sat.needed)}/mo` : ""; - - const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(101,163,13,0.4))" - : !isCritical ? "filter: drop-shadow(0 0 6px rgba(161,98,7,0.35))" : ""; - - // Organic overflow buds instead of rectangular pipes - const overflowBuds = ` - - `; - - const excess = Math.max(0, d.currentValue - d.maxThreshold); - const overflowLabel = isOverflow ? this.ctx.formatDollar(excess) : ""; - const inflowLabel = `${this.ctx.formatDollar(d.inflowRate)}/mo`; - - // Status badge colors - const statusBadgeBg = isCritical ? "rgba(185,28,28,0.15)" : isOverflow ? "rgba(101,163,13,0.15)" : "rgba(161,98,7,0.15)"; - const statusBadgeColor = isCritical ? "#fca5a5" : isOverflow ? "#a3e635" : "#fbbf24"; - - const drainInset = taperAtBottom; - - // Organic valve: rounded pill - const valveGrad = "url(#org-membrane-grad)"; - - return ` - - - - ${isOverflow ? `` : ""} - - - - - - ${fillPath ? `` : ""} - ${thresholdLines} - - ${inflowPipeIndicator} - ${overflowBuds} - - - - - - - ◁ ${this.ctx.formatDollar(outflow)}/mo ▷ - - - ${d.spendingAllocations.length >= 2 - ? this.ctx.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60)) - : ""} - ${d.overflowAllocations.length >= 2 - ? this.ctx.renderSplitControl(n.id, "overflow", d.overflowAllocations, w / 2, pipeY - 12, w - 40) - : ""} - - - ⇕ capacity - - - ↓ ${inflowLabel} - - -
- ${this.ctx.esc(d.label)} - ${statusLabel} -
-
- - ${satLabel} - - ${criticalH > 20 ? `DEPLETED` : ""} - ${sufficientH > 20 ? `GROWING` : ""} - ${overflowH > 20 ? `ABUNDANT` : ""} - - $${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()} - - ${this.ctx.formatDollar(outflow)}/mo ▾ - - ${isOverflow ? `${overflowLabel} - ${overflowLabel}` : ""} - ${this.ctx.renderPortsSvg(n)} -
`; - } - - /** Vessel outline with deterministic sine-wobble on the walls */ - private organicVesselPath( - w: number, h: number, zoneTop: number, zoneH: number, - taperAtBottom: number, pipeY: number, pipeH: number, pipeW: number, - nodeId: string, - ): string { - const r = 10; - const steps = 16; - const zoneBot = zoneTop + zoneH; - - const pipeTopFrac = Math.max(0, (pipeY - zoneTop) / zoneH); - const pipeBotFrac = Math.min(1, (pipeY + pipeH - zoneTop) / zoneH); - const rightInsetAtPipeTop = this.ctx.vesselWallInset(pipeTopFrac, taperAtBottom); - const rightInsetAtPipeBot = this.ctx.vesselWallInset(pipeBotFrac, taperAtBottom); - - // Right wall below pipe - const rightWallBelow: string[] = []; - rightWallBelow.push(`${w - rightInsetAtPipeBot + this.wobble(nodeId, 200, 2)},${pipeY + pipeH}`); - for (let i = 0; i <= steps; i++) { - const yf = i / steps; - const py = zoneTop + zoneH * yf; - if (py > pipeY + pipeH) { - const inset = this.ctx.vesselWallInset(yf, taperAtBottom); - const wb = this.wobble(nodeId, 210 + i, 3); - rightWallBelow.push(`${w - inset + wb},${py}`); - } - } - - // Left wall below pipe (reversed) - const leftWallBelow: string[] = []; - for (let i = 0; i <= steps; i++) { - const yf = i / steps; - const py = zoneTop + zoneH * yf; - if (py > pipeY + pipeH) { - const inset = this.ctx.vesselWallInset(yf, taperAtBottom); - const wb = this.wobble(nodeId, 230 + i, 3); - leftWallBelow.push(`${inset + wb},${py}`); - } - } - leftWallBelow.push(`${this.ctx.vesselWallInset(pipeBotFrac, taperAtBottom) + this.wobble(nodeId, 250, 2)},${pipeY + pipeH}`); - leftWallBelow.reverse(); - - return [ - `M ${r},0`, - `L ${w - r},0`, - `Q ${w},0 ${w},${r}`, - `L ${w},${pipeY}`, - // Organic bud notch (elliptical bulge instead of rectangle) - `C ${w + pipeW * 0.3},${pipeY} ${w + pipeW * 0.7},${pipeY} ${w + pipeW * 0.7},${pipeY + pipeH / 2}`, - `C ${w + pipeW * 0.7},${pipeY + pipeH} ${w + pipeW * 0.3},${pipeY + pipeH} ${w},${pipeY + pipeH}`, - ...rightWallBelow.map(p => `L ${p}`), - `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}`, - ...leftWallBelow.map(p => `L ${p}`), - // Left organic bud notch - `C ${-pipeW * 0.3},${pipeY + pipeH} ${-pipeW * 0.7},${pipeY + pipeH} ${-pipeW * 0.7},${pipeY + pipeH / 2}`, - `C ${-pipeW * 0.7},${pipeY} ${-pipeW * 0.3},${pipeY} 0,${pipeY}`, - `L 0,${r}`, - `Q 0,0 ${r},0`, - `Z`, - ].join(" "); - } - - /** Bark ridge threshold lines: small tick marks with wobble */ - private barkRidgeLines( - x1: number, x2: number, y: number, - color: string, label: string, nodeId: string, seed: number, - ): string { - const tickCount = Math.floor((x2 - x1) / 8); - let ticks = ""; - for (let i = 0; i < tickCount; i++) { - const tx = x1 + 4 + i * ((x2 - x1 - 8) / tickCount); - const ty = y + this.wobble(nodeId, seed + i, 1.5); - const th = 3 + this.nodeNoise(nodeId, seed + 50 + i) * 3; - ticks += ``; - } - return `${ticks} - ${label}`; - } - - /* ── Outcome → Fruiting Body ───────────────────────── */ - - private renderOutcomeFruitingBody( - n: FlowNode, selected: boolean, - sat?: { actual: number; needed: number; ratio: number }, - ): string { - const d = n.data as OutcomeNodeData; - const s = this.ctx.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: "#65a30d", blocked: "#b91c1c", "in-progress": "#a16207", "not-started": "#78716c", - }; - const statusColor = statusColors[d.status] || "#78716c"; - const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase()); - - // Basin water gradient by status (organic palette) - const waterGrad: Record = { - completed: "url(#org-fill-green)", blocked: "url(#org-fill-rust)", - "in-progress": "url(#org-fill-amber)", "not-started": "url(#org-fill-grey)", - }; - const waterFill = waterGrad[d.status] || "url(#org-fill-grey)"; - - // Basin shape — same U math with organic stroke - const wallDrop = h * 0.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`; - const clipId = `org-basin-clip-${n.id}`; - - // Water fill - const waterTop = h - (h - 10) * fillPct; - const waterRect = fillPct > 0 ? `` : ""; - - // Phase markers — spore rings instead of dots - let phaseMarkers = ""; - if (d.phases && d.phases.length > 0) { - 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; - const col = unlocked ? "#84cc16" : "#57534e"; - return ` - `; - }).join(""); - } - - // Overflow tendrils when overfunded - const overflowTendrils = isOverfunded ? this.renderOverflowTendrils(w, h, n.id) : ""; - - const dollarLabel = `${this.ctx.formatDollar(d.fundingReceived)} / ${this.ctx.formatDollar(d.fundingTarget)}`; - - 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 ` - - - - - - - - - - ${waterRect} - ${phaseMarkers} - - ${overflowTendrils} - - -
- ${this.ctx.esc(d.label)} - ${statusLabel} - ${phaseSeg} -
-
- - ${Math.round(fillPct * 100)}% - ${dollarLabel} - ${this.ctx.renderPortsSvg(n)} -
`; - } - - /** Overflow tendrils extending below basin when overfunded */ - private renderOverflowTendrils(w: number, h: number, nodeId: string): string { - let svg = ""; - for (let i = 0; i < 3; i++) { - const tx = w * 0.25 + w * 0.5 * (i / 2) + this.wobble(nodeId, 300 + i, 6); - const endY = h + 12 + this.nodeNoise(nodeId, 310 + i) * 10; - svg += ``; - } - return svg; - } - - /* ── Edge → Hypha ──────────────────────────────────── */ - - renderEdgePath( - x1: number, y1: number, x2: number, y2: number, - color: string, strokeW: number, dashed: boolean, ghost: boolean, - label: string, fromId: string, toId: string, edgeType: string, - fromSide?: "left" | "right", - waypoint?: { x: number; y: number }, - ): string { - // Reuse the exact same path math as mechanical mode - let d: string, midX: number, midY: number; - - if (waypoint) { - const cx1 = (4 * waypoint.x - x1 - x2) / 3; - const cy1 = (4 * waypoint.y - y1 - y2) / 3; - const c1x = x1 + (cx1 - x1) * 0.8; - const c1y = y1 + (cy1 - y1) * 0.8; - const c2x = x2 + (cx1 - x2) * 0.8; - const c2y = y2 + (cy1 - y2) * 0.8; - d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`; - midX = waypoint.x; - midY = waypoint.y; - } else if (fromSide) { - const burst = Math.max(100, strokeW * 8); - const outwardX = fromSide === "left" ? x1 - burst : x1 + burst; - d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`; - midX = (x1 + outwardX + x2) / 3; - midY = (y1 + y2) / 2; - } else { - const cy1v = y1 + (y2 - y1) * 0.4; - const cy2v = y1 + (y2 - y1) * 0.6; - d = `M ${x1} ${y1} C ${x1} ${cy1v}, ${x2} ${cy2v}, ${x2} ${y2}`; - midX = (x1 + x2) / 2; - midY = (y1 + y2) / 2; - } - - // Hit area (same as mechanical) - const hitPath = ``; - - // Organic color mapping — earth-tone versions - const hyphaColor = this.hyphaColor(edgeType); - - if (ghost) { - return ` - ${hitPath} - - - - ${label} - - `; - } - - const overflowMul = dashed ? 1.3 : 1; - const finalStrokeW = strokeW * overflowMul; - const labelW = Math.max(68, label.length * 7 + 12); - const halfW = labelW / 2; - const dragHandle = ``; - - // Spore dot at endpoint (replaces arrowhead marker) - const sporeR = Math.max(3, finalStrokeW * 0.4); - const sporeDot = ``; - - return ` - ${hitPath} - - - ${sporeDot} - ${dashed ? ` - - - ` : ""} - ${dragHandle} - - - ${label} - - `; - } - - /** Map edge type to earth-tone hypha color */ - private hyphaColor(edgeType: string): string { - switch (edgeType) { - case "overflow": return "#a3e635"; // lime green - case "spending": return "#fbbf24"; // amber - default: return "#84cc16"; // green - } - } -}