649 lines
32 KiB
TypeScript
649 lines
32 KiB
TypeScript
/**
|
|
* 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
|
|
}
|
|
}
|
|
}
|