/** * 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 } } }