Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-20 23:01:32 -07:00
commit d3cad4dc1c
3 changed files with 820 additions and 0 deletions

View File

@ -71,6 +71,120 @@
--rflows-modal-border: #334155; --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 ────────────────────────────────────────────── */ /* ── Base ────────────────────────────────────────────── */
.flows-landing, .flows-detail { .flows-landing, .flows-detail {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;

View File

@ -20,6 +20,7 @@ import { mapFlowToNodes } from "../lib/map-flow";
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas"; import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { FlowsLocalFirstClient } from "../local-first-client"; import { FlowsLocalFirstClient } from "../local-first-client";
import { OrganicRenderer, organicSvgDefs, type OrganicRendererContext } from "./folk-flows-organic-renderer";
interface FlowSummary { interface FlowSummary {
id: string; id: string;
@ -197,6 +198,28 @@ class FolkFlowsApp extends HTMLElement {
// Tour engine // Tour engine
private _tour!: TourEngine; 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 = [ 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-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 }, { 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()) new MutationObserver(() => this._syncTheme())
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); .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 // Read view attribute, default to canvas (detail) view
const viewAttr = this.getAttribute("view"); const viewAttr = this.getAttribute("view");
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail"; this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
@ -994,6 +1022,8 @@ class FolkFlowsApp extends HTMLElement {
<button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button> <button class="flows-toolbar-btn ${this.analyticsOpen ? "flows-toolbar-btn--active" : ""}" data-canvas-action="analytics">📊 Analytics</button>
<button class="flows-toolbar-btn" data-canvas-action="share">🔗 Share</button> <button class="flows-toolbar-btn" data-canvas-action="share">🔗 Share</button>
<button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button> <button class="flows-toolbar-btn" data-canvas-action="tour">🎓 Tour</button>
<div class="flows-toolbar-sep"></div>
<button class="flows-toolbar-btn ${this.renderMode === "organic" ? "flows-toolbar-btn--active" : ""}" data-canvas-action="toggle-render-mode" title="Toggle organic/mechanical view">🍄 ${this.renderMode === "organic" ? "Organic" : "Mechanical"}</button>
</div> </div>
<svg class="flows-canvas-svg" id="flow-canvas"> <svg class="flows-canvas-svg" id="flow-canvas">
<defs> <defs>
@ -1049,6 +1079,7 @@ class FolkFlowsApp extends HTMLElement {
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/> <stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/> <stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
</linearGradient> </linearGradient>
${organicSvgDefs()}
</defs> </defs>
<g id="canvas-transform"> <g id="canvas-transform">
<g id="edge-layer"></g> <g id="edge-layer"></g>
@ -1567,6 +1598,7 @@ class FolkFlowsApp extends HTMLElement {
else if (action === "quick-fund") this.quickFund(); else if (action === "quick-fund") this.quickFund();
else if (action === "share") this.shareState(); else if (action === "share") this.shareState();
else if (action === "tour") this.startTour(); 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 === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
else if (action === "flow-picker") this.toggleFlowDropdown(); else if (action === "flow-picker") this.toggleFlowDropdown();
@ -1868,6 +1900,9 @@ class FolkFlowsApp extends HTMLElement {
private renderNodeSvg(n: FlowNode, satisfaction: Map<string, { actual: number; needed: number; ratio: number }>): string { private renderNodeSvg(n: FlowNode, satisfaction: Map<string, { actual: number; needed: number; ratio: number }>): string {
const sel = this.selectedNodeId === n.id; 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 === "source") return this.renderSourceNodeSvg(n, sel);
if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id)); if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id));
return this.renderOutcomeNodeSvg(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", fromSide?: "left" | "right",
waypoint?: { x: number; y: number }, waypoint?: { x: number; y: number },
): string { ): 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 d: string;
let midX: number; let midX: number;
let midY: number; let midY: number;
@ -4966,6 +5007,23 @@ class FolkFlowsApp extends HTMLElement {
// ─── Simulation ─────────────────────────────────────── // ─── 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() { private toggleSimulation() {
this.isSimulating = !this.isSimulating; this.isSimulating = !this.isSimulating;
const btn = this.shadow.getElementById("sim-btn"); const btn = this.shadow.getElementById("sim-btn");

View File

@ -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<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number }>;
}
/* ── Organic SVG defs (appended alongside mechanical defs) ── */
export function organicSvgDefs(): string {
return `
<!-- Organic / mycorrhizal gradients & filters -->
<radialGradient id="org-bulb-grad" cx="45%" cy="40%" r="55%">
<stop offset="0%" stop-color="#fef3c7"/>
<stop offset="40%" stop-color="#d97706" stop-opacity="0.85"/>
<stop offset="100%" stop-color="#365314" stop-opacity="0.95"/>
</radialGradient>
<linearGradient id="org-membrane-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4d7c0f"/>
<stop offset="50%" stop-color="#365314"/>
<stop offset="100%" stop-color="#1a2e05"/>
</linearGradient>
<linearGradient id="org-fill-amber" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#92400e" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="org-fill-green" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#84cc16" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#365314" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="org-fill-grey" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#a8a29e" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#57534e" stop-opacity="0.3"/>
</linearGradient>
<linearGradient id="org-fill-rust" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#dc2626" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#7f1d1d" stop-opacity="0.35"/>
</linearGradient>
<filter id="org-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<radialGradient id="org-spore-grad" cx="50%" cy="35%" r="60%">
<stop offset="0%" stop-color="#fef9c3"/>
<stop offset="100%" stop-color="#a16207" stop-opacity="0.7"/>
</radialGradient>
<linearGradient id="org-fruiting-cap" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#92400e"/>
<stop offset="60%" stop-color="#78350f"/>
<stop offset="100%" stop-color="#451a03"/>
</linearGradient>
`;
}
/* ── 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<string, string> = {
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 `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="transparent" stroke="none"/>
${tendrils}
<!-- Sporangium bulb -->
<path d="${bulbPath}" fill="url(#org-bulb-grad)" ${selStroke} style="filter:url(#org-glow)"/>
<!-- Spore cap -->
<ellipse cx="${cx}" cy="${cy - ry + 6}" rx="10" ry="6" fill="${capColor}" opacity="0.85" stroke="#1a2e05" stroke-width="0.8"/>
<!-- Label -->
<text x="${cx}" y="${cy + 2}" text-anchor="middle" dominant-baseline="central" fill="#fef3c7" font-size="11" font-weight="600" pointer-events="none">${this.ctx.esc(d.label)}</text>
<!-- Amount -->
<text x="${cx}" y="${h - 18}" text-anchor="middle" fill="#fde68a" font-size="13" font-weight="700" font-family="ui-monospace,monospace" pointer-events="none">$${d.flowRate.toLocaleString()}/mo</text>
${allocBar}
${this.ctx.renderPortsSvg(n)}
</g>`;
}
/** 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 += `<path d="M ${cx},${startY} C ${cx + this.wobble(nodeId, 110 + i, 10)},${cp1y} ${tx + this.wobble(nodeId, 120 + i, 8)},${cp2y} ${tx},${endY}"
fill="none" stroke="#84cc16" stroke-width="${tw}" stroke-linecap="round" opacity="${active ? 0.45 : 0.12}"/>`;
}
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 ? `
<rect x="${inflowPipeX}" y="26" width="${inflowPipeW}" height="6" rx="3" fill="#365314" opacity="0.3"/>
<rect x="${inflowPipeX}" y="26" width="${Math.round(inflowPipeW * inflowFillRatio)}" height="6" rx="3" fill="#84cc16" opacity="0.6"/>` : "";
// 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 = `
<ellipse class="org-overflow-bud ${isOverflow ? "org-overflow-bud--active" : ""}" cx="${-pipeW * 0.6}" cy="${pipeY + pipeH / 2}" rx="${pipeW * 0.45}" ry="${pipeH * 0.5 + 2}" fill="${isOverflow ? "#84cc16" : "#365314"}" opacity="${isOverflow ? 0.65 : 0.2}" stroke="#4d7c0f" stroke-width="1"/>
<ellipse class="org-overflow-bud ${isOverflow ? "org-overflow-bud--active" : ""}" cx="${w + pipeW * 0.6}" cy="${pipeY + pipeH / 2}" rx="${pipeW * 0.45}" ry="${pipeH * 0.5 + 2}" fill="${isOverflow ? "#84cc16" : "#365314"}" opacity="${isOverflow ? 0.65 : 0.2}" stroke="#4d7c0f" stroke-width="1"/>`;
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 `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${vesselPath}"/></clipPath>
</defs>
${isOverflow ? `<path d="${vesselPath}" fill="none" stroke="#65a30d" stroke-width="2.5" opacity="0.35" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${vesselPath}" fill="#1a2e05" stroke="${selected ? "var(--rflows-selected)" : borderColor}" stroke-width="${selected ? 3.5 : 2}" stroke-linejoin="round"/>
<g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" fill="#7f1d1d" opacity="0.06"/>
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" fill="#365314" opacity="0.06"/>
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" fill="#a16207" opacity="0.05"/>
${fillPath ? `<path class="funnel-fill-path" data-node-id="${n.id}" d="${fillPath}" fill="${fillGrad}" opacity="0.35"/>` : ""}
${thresholdLines}
</g>
${inflowPipeIndicator}
${overflowBuds}
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" fill="#365314" opacity="0.3"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" fill="#84cc16" opacity="0.6"/>
<!-- Organic valve handle (pill shape) -->
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
<rect x="${drainInset - 6}" y="${h - 14}" width="${drainW + 12}" height="16" rx="8"
fill="${valveGrad}" style="cursor:ew-resize;stroke:#84cc16;stroke-width:1"/>
<text x="${w / 2}" y="${h - 3}" text-anchor="middle" fill="#fde68a" font-size="11" font-weight="600" pointer-events="none">
${this.ctx.formatDollar(outflow)}/mo
</text>
</g>
${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)
: ""}
<g class="funnel-height-handle" data-handle="height" data-node-id="${n.id}">
<rect x="${w / 2 - 28}" y="${h + 4}" width="56" height="12" rx="6"
fill="#365314" style="cursor:ns-resize;stroke:#4d7c0f;stroke-width:1"/>
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" fill="#a3e635" font-size="9" font-weight="500" pointer-events="none"> capacity</text>
</g>
<!-- Inflow label -->
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#84cc16" font-size="12" font-weight="500" opacity="0.9" pointer-events="none"> ${inflowLabel}</text>
<!-- Node label + status badge -->
<foreignObject x="0" y="0" width="${w}" height="32" class="funnel-overlay">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px 0;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<span style="font-size:14px;font-weight:600;color:#fef3c7">${this.ctx.esc(d.label)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
</div>
</foreignObject>
<!-- Satisfaction label -->
<text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="#a8a29e" font-size="10" pointer-events="none">${satLabel}</text>
<!-- Zone labels -->
${criticalH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH + criticalH / 2 + 4}" text-anchor="middle" fill="#fca5a5" font-size="10" font-weight="600" opacity="0.4" pointer-events="none">DEPLETED</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH / 2 + 4}" text-anchor="middle" fill="#fbbf24" font-size="10" font-weight="600" opacity="0.4" pointer-events="none">GROWING</text>` : ""}
${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#a3e635" font-size="10" font-weight="600" opacity="0.4" pointer-events="none">ABUNDANT</text>` : ""}
<!-- Value text -->
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="#d6d3d1" font-size="13" font-weight="500" pointer-events="none">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</text>
<!-- Outflow label -->
<text x="${w / 2}" y="${h + 20}" text-anchor="middle" fill="#84cc16" font-size="12" font-weight="600" pointer-events="none">${this.ctx.formatDollar(outflow)}/mo </text>
<!-- Overflow labels -->
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#a3e635" font-size="11" font-weight="500" opacity="0.7" pointer-events="none">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#a3e635" font-size="11" font-weight="500" opacity="0.7" pointer-events="none">${overflowLabel}</text>` : ""}
${this.ctx.renderPortsSvg(n)}
</g>`;
}
/** 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 += `<line x1="${tx}" x2="${tx}" y1="${ty - th / 2}" y2="${ty + th / 2}" stroke="${color}" stroke-width="1.5" opacity="0.5" stroke-linecap="round"/>`;
}
return `${ticks}
<text x="${x1 + 4}" y="${y - 5}" fill="${color}" font-size="10" font-weight="500" opacity="0.7">${label}</text>`;
}
/* ── 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<string, string> = {
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<string, string> = {
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 ? `<rect class="basin-water-fill" x="0" y="${waterTop}" width="${w}" height="${h - waterTop}" fill="${waterFill}"/>` : "";
// 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 `<circle cx="14" cy="${markerY}" r="5" fill="none" stroke="${col}" stroke-width="1.5" opacity="0.6"/>
<circle cx="14" cy="${markerY}" r="2.5" fill="${col}" opacity="${unlocked ? 0.8 : 0.3}"/>`;
}).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 = `<span style="font-size:9px;color:#a8a29e">${unlockedCount}/${d.phases.length} phases</span>`;
}
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" data-collab-id="node:${n.id}" transform="translate(${x},${y})">
<defs>
<clipPath id="${clipId}"><path d="${basinClosedPath}"/></clipPath>
</defs>
<!-- Fruiting body cap (mushroom cap above basin) -->
<ellipse cx="${w / 2}" cy="-8" rx="${w * 0.35}" ry="14" fill="url(#org-fruiting-cap)" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 2.5 : 1.5}" opacity="0.8"/>
<!-- Basin outline (organic stroke) -->
<path class="node-bg basin-outline" d="${basinPath}" fill="#1a2e05" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Water fill clipped to basin -->
<g clip-path="url(#${clipId})">
${waterRect}
${phaseMarkers}
</g>
${overflowTendrils}
<!-- Header above basin -->
<foreignObject x="-10" y="-40" width="${w + 20}" height="34">
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<span style="font-size:13px;font-weight:600;color:#fef3c7">${this.ctx.esc(d.label)}</span>
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
${phaseSeg}
</div>
</foreignObject>
<!-- Funding text -->
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 4, h / 2 + 4)}" text-anchor="middle" fill="#fef3c7" font-size="12" font-weight="600" font-family="ui-monospace,monospace" pointer-events="none" opacity="0.9">${Math.round(fillPct * 100)}%</text>
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 18, h / 2 + 18)}" text-anchor="middle" fill="#a8a29e" font-size="10" pointer-events="none">${dollarLabel}</text>
${this.ctx.renderPortsSvg(n)}
</g>`;
}
/** 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 += `<path d="M ${tx},${h} Q ${tx + this.wobble(nodeId, 320 + i, 8)},${h + (endY - h) * 0.5} ${tx + this.wobble(nodeId, 330 + i, 10)},${endY}"
fill="none" stroke="#84cc16" stroke-width="2" stroke-linecap="round" opacity="0.4"/>`;
}
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 = `<path d="${d}" fill="none" stroke="transparent" stroke-width="${Math.max(12, strokeW * 3)}" class="edge-hit-area" style="cursor:pointer"/>`;
// Organic color mapping — earth-tone versions
const hyphaColor = this.hyphaColor(edgeType);
if (ghost) {
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="${hyphaColor}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="3 8" stroke-linecap="round" class="edge-ghost"/>
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1a2e05" stroke="#365314" stroke-width="1" opacity="0.5"/>
<text x="0" y="5" fill="${hyphaColor}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
</g>
</g>`;
}
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 = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
// Spore dot at endpoint (replaces arrowhead marker)
const sporeR = Math.max(3, finalStrokeW * 0.4);
const sporeDot = `<circle cx="${x2}" cy="${y2}" r="${sporeR}" fill="${hyphaColor}" opacity="0.7" class="org-spore-terminus"/>`;
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" stroke="#d97706" stroke-width="${finalStrokeW * 2}" stroke-opacity="0.06" class="edge-glow"/>
<path d="${d}" fill="none" stroke="${hyphaColor}" stroke-width="${finalStrokeW}" stroke-opacity="0.75" stroke-linecap="round" stroke-dasharray="3 8" class="org-hypha-path"/>
${sporeDot}
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.5)}" fill="${hyphaColor}" opacity="0.35" class="edge-splash">
<animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.5)};${Math.max(7, finalStrokeW * 0.8)};${Math.max(4, finalStrokeW * 0.5)}" dur="1.8s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.35;0.15;0.35" dur="1.8s" repeatCount="indefinite"/>
</circle>` : ""}
${dragHandle}
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" fill="#1a2e05" stroke="#365314" stroke-width="1" opacity="0.9"/>
<text x="0" y="5" fill="${hyphaColor}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
</g>
</g>`;
}
/** 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
}
}
}