diff --git a/modules/rflows/components/flows.css b/modules/rflows/components/flows.css index b247013..e8cf767 100644 --- a/modules/rflows/components/flows.css +++ b/modules/rflows/components/flows.css @@ -71,6 +71,120 @@ --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 3a072dd..a66a109 100644 --- a/modules/rflows/components/folk-flows-app.ts +++ b/modules/rflows/components/folk-flows-app.ts @@ -20,6 +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; @@ -197,6 +198,28 @@ 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 }, @@ -227,6 +250,11 @@ 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"; @@ -994,6 +1022,8 @@ class FolkFlowsApp extends HTMLElement { +
+ @@ -1049,6 +1079,7 @@ class FolkFlowsApp extends HTMLElement { + ${organicSvgDefs()} @@ -1567,6 +1598,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-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); } else if (action === "flow-picker") this.toggleFlowDropdown(); @@ -1868,6 +1900,9 @@ 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)); @@ -2554,6 +2589,12 @@ 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; @@ -4966,6 +5007,23 @@ 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 new file mode 100644 index 0000000..d9a70e7 --- /dev/null +++ b/modules/rflows/components/folk-flows-organic-renderer.ts @@ -0,0 +1,648 @@ +/** + * 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 + } + } +}