feat(rflows): add organic/mycorrhizal view mode toggle
Adds a toggleable alternative rendering mode for the rFlows canvas. Sources become sporangia, funnels become mycorrhizal junctions, outcomes become fruiting bodies, and edges become branching hyphae with earth-tone aesthetics. Same data, same interactions, same ports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f5b455f83c
commit
31fe552755
|
|
@ -71,6 +71,120 @@
|
|||
--rflows-modal-border: #334155;
|
||||
}
|
||||
|
||||
/* ── Organic / Mycorrhizal mode overrides ────────────── */
|
||||
:host([data-render-mode="organic"]) {
|
||||
/* Source node */
|
||||
--rflows-source-bg: #365314;
|
||||
--rflows-source-border: #84cc16;
|
||||
--rflows-source-rate: #a3e635;
|
||||
|
||||
/* Edge colors — earth tones */
|
||||
--rflows-edge-inflow: #84cc16;
|
||||
--rflows-edge-spending: #fbbf24;
|
||||
--rflows-edge-overflow: #a3e635;
|
||||
|
||||
/* Funnel zones */
|
||||
--rflows-zone-drain: #7f1d1d;
|
||||
--rflows-zone-drain-opacity: 0.06;
|
||||
--rflows-zone-healthy: #365314;
|
||||
--rflows-zone-healthy-opacity: 0.06;
|
||||
--rflows-zone-overflow: #a16207;
|
||||
--rflows-zone-overflow-opacity: 0.05;
|
||||
--rflows-fill-opacity: 0.35;
|
||||
|
||||
/* Funnel labels */
|
||||
--rflows-label-inflow: #84cc16;
|
||||
--rflows-label-spending: #fbbf24;
|
||||
--rflows-label-overflow: #a3e635;
|
||||
|
||||
/* Status colors — organic palette */
|
||||
--rflows-status-critical: #b91c1c;
|
||||
--rflows-status-sustained: #a16207;
|
||||
--rflows-status-overflow: #65a30d;
|
||||
--rflows-status-thriving: #65a30d;
|
||||
--rflows-sat-bar: #84cc16;
|
||||
--rflows-sat-border: #d97706;
|
||||
|
||||
/* Outcome / progress */
|
||||
--rflows-status-completed: #65a30d;
|
||||
--rflows-status-blocked: #b91c1c;
|
||||
--rflows-status-inprogress: #a16207;
|
||||
--rflows-status-notstarted: #78716c;
|
||||
--rflows-phase-unlocked: #84cc16;
|
||||
|
||||
/* Score badge */
|
||||
--rflows-score-gold: #d97706;
|
||||
--rflows-score-green: #65a30d;
|
||||
--rflows-score-amber: #a16207;
|
||||
--rflows-score-red: #b91c1c;
|
||||
|
||||
/* Card value */
|
||||
--rflows-card-value: #d97706;
|
||||
|
||||
/* Selection */
|
||||
--rflows-selected: #84cc16;
|
||||
|
||||
/* Inline edit buttons */
|
||||
--rflows-btn-done: #65a30d;
|
||||
--rflows-btn-delete: #b91c1c;
|
||||
--rflows-btn-fund: #84cc16;
|
||||
--rflows-btn-save: #65a30d;
|
||||
|
||||
/* Sufficiency tooltip highlight */
|
||||
--rflows-sufficiency-highlight: #d97706;
|
||||
|
||||
/* Edge drag handle */
|
||||
--rflows-drag-handle-fill: #365314;
|
||||
--rflows-drag-handle-stroke: #4d7c0f;
|
||||
|
||||
/* Modal border accent */
|
||||
--rflows-modal-border: #365314;
|
||||
}
|
||||
|
||||
/* Organic canvas background */
|
||||
:host([data-render-mode="organic"]) .flows-canvas-svg {
|
||||
background-color: #0f1a0f;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
/* Organic port styling */
|
||||
:host([data-render-mode="organic"]) .port-dot {
|
||||
r: 8;
|
||||
stroke: #365314;
|
||||
stroke-width: 2.5;
|
||||
filter: drop-shadow(0 0 3px currentColor);
|
||||
}
|
||||
:host([data-render-mode="organic"]) .port-group:hover .port-dot {
|
||||
r: 10;
|
||||
filter: drop-shadow(0 0 6px currentColor);
|
||||
}
|
||||
|
||||
/* Organic edge animation — sparse dot pattern, slower */
|
||||
:host([data-render-mode="organic"]) .org-hypha-path {
|
||||
stroke-dasharray: 3 8;
|
||||
animation: organicFlow 2.5s linear infinite;
|
||||
}
|
||||
@keyframes organicFlow { to { stroke-dashoffset: -22; } }
|
||||
|
||||
/* Organic overflow bud pulse */
|
||||
:host([data-render-mode="organic"]) .org-overflow-bud--active {
|
||||
animation: budPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes budPulse {
|
||||
0%, 100% { ry: 13; opacity: 0.55; }
|
||||
50% { ry: 16; opacity: 0.75; }
|
||||
}
|
||||
|
||||
/* Organic spore terminus glow */
|
||||
:host([data-render-mode="organic"]) .org-spore-terminus {
|
||||
filter: drop-shadow(0 0 3px currentColor);
|
||||
}
|
||||
|
||||
/* Organic legend dots */
|
||||
:host([data-render-mode="organic"]) .flows-canvas-legend-dot {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ── Base ────────────────────────────────────────────── */
|
||||
.flows-landing, .flows-detail {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { mapFlowToNodes } from "../lib/map-flow";
|
|||
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
|
||||
import type { DocumentId } from "../../../shared/local-first/document";
|
||||
import { FlowsLocalFirstClient } from "../local-first-client";
|
||||
import { OrganicRenderer, organicSvgDefs, type OrganicRendererContext } from "./folk-flows-organic-renderer";
|
||||
|
||||
interface FlowSummary {
|
||||
id: string;
|
||||
|
|
@ -197,6 +198,28 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
// Tour engine
|
||||
private _tour!: TourEngine;
|
||||
|
||||
// Render mode: mechanical (default) or organic (mycorrhizal)
|
||||
private renderMode: "mechanical" | "organic" = "mechanical";
|
||||
private _organicRenderer: OrganicRenderer | null = null;
|
||||
private get organicRenderer(): OrganicRenderer {
|
||||
if (!this._organicRenderer) {
|
||||
const ctx: OrganicRendererContext = {
|
||||
getNodeSize: (n) => this.getNodeSize(n),
|
||||
vesselWallInset: (yFrac, taper) => this.vesselWallInset(yFrac, taper),
|
||||
computeVesselFillPath: (w, h, fill, taper) => this.computeVesselFillPath(w, h, fill, taper),
|
||||
renderPortsSvg: (n) => this.renderPortsSvg(n),
|
||||
renderSplitControl: (nid, at, allocs, cx, cy, tw) => this.renderSplitControl(nid, at, allocs, cx, cy, tw),
|
||||
formatDollar: (a) => this.formatDollar(a),
|
||||
esc: (s) => this.esc(s),
|
||||
_currentFlowWidths: this._currentFlowWidths,
|
||||
};
|
||||
this._organicRenderer = new OrganicRenderer(ctx);
|
||||
}
|
||||
// Keep flow widths reference current
|
||||
(this._organicRenderer as any).ctx._currentFlowWidths = this._currentFlowWidths;
|
||||
return this._organicRenderer;
|
||||
}
|
||||
private static readonly TOUR_STEPS = [
|
||||
{ target: '[data-canvas-action="add-source"]', title: "Add a Source", message: "Sources represent inflows of resources. Click the + Source button to add one.", advanceOnClick: true },
|
||||
{ target: '[data-canvas-action="add-funnel"]', title: "Add a Funnel", message: "Funnels allocate resources between spending and overflow. Click + Funnel to add one.", advanceOnClick: true },
|
||||
|
|
@ -227,6 +250,11 @@ class FolkFlowsApp extends HTMLElement {
|
|||
new MutationObserver(() => this._syncTheme())
|
||||
.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
||||
|
||||
// Restore render mode preference
|
||||
const savedMode = localStorage.getItem("rflows:render-mode");
|
||||
if (savedMode === "organic" || savedMode === "mechanical") this.renderMode = savedMode;
|
||||
if (this.renderMode === "organic") this.setAttribute("data-render-mode", "organic");
|
||||
|
||||
// Read view attribute, default to canvas (detail) view
|
||||
const viewAttr = this.getAttribute("view");
|
||||
this.view = viewAttr === "budgets" ? "budgets" : viewAttr === "mortgage" ? "mortgage" : "detail";
|
||||
|
|
@ -994,6 +1022,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<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="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>
|
||||
<svg class="flows-canvas-svg" id="flow-canvas">
|
||||
<defs>
|
||||
|
|
@ -1049,6 +1079,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<stop offset="0%" stop-color="#fca5a5" stop-opacity="0.6"/>
|
||||
<stop offset="100%" stop-color="#ef4444" stop-opacity="0.35"/>
|
||||
</linearGradient>
|
||||
${organicSvgDefs()}
|
||||
</defs>
|
||||
<g id="canvas-transform">
|
||||
<g id="edge-layer"></g>
|
||||
|
|
@ -1567,6 +1598,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
else if (action === "quick-fund") this.quickFund();
|
||||
else if (action === "share") this.shareState();
|
||||
else if (action === "tour") this.startTour();
|
||||
else if (action === "toggle-render-mode") this.toggleRenderMode();
|
||||
else if (action === "zoom-in") { this.canvasZoom = Math.min(4, this.canvasZoom * 1.2); this.updateCanvasTransform(); }
|
||||
else if (action === "zoom-out") { this.canvasZoom = Math.max(0.1, this.canvasZoom * 0.8); this.updateCanvasTransform(); }
|
||||
else if (action === "flow-picker") this.toggleFlowDropdown();
|
||||
|
|
@ -1868,6 +1900,9 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private renderNodeSvg(n: FlowNode, satisfaction: Map<string, { actual: number; needed: number; ratio: number }>): string {
|
||||
const sel = this.selectedNodeId === n.id;
|
||||
if (this.renderMode === "organic") {
|
||||
return this.organicRenderer.renderNodeSvg(n, sel, satisfaction.get(n.id));
|
||||
}
|
||||
if (n.type === "source") return this.renderSourceNodeSvg(n, sel);
|
||||
if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id));
|
||||
return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id));
|
||||
|
|
@ -2554,6 +2589,12 @@ class FolkFlowsApp extends HTMLElement {
|
|||
fromSide?: "left" | "right",
|
||||
waypoint?: { x: number; y: number },
|
||||
): string {
|
||||
if (this.renderMode === "organic") {
|
||||
return this.organicRenderer.renderEdgePath(
|
||||
x1, y1, x2, y2, color, strokeW, dashed, ghost,
|
||||
label, fromId, toId, edgeType, fromSide, waypoint,
|
||||
);
|
||||
}
|
||||
let d: string;
|
||||
let midX: number;
|
||||
let midY: number;
|
||||
|
|
@ -4966,6 +5007,23 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
// ─── Simulation ───────────────────────────────────────
|
||||
|
||||
private toggleRenderMode() {
|
||||
this.renderMode = this.renderMode === "mechanical" ? "organic" : "mechanical";
|
||||
localStorage.setItem("rflows:render-mode", this.renderMode);
|
||||
if (this.renderMode === "organic") {
|
||||
this.setAttribute("data-render-mode", "organic");
|
||||
} else {
|
||||
this.removeAttribute("data-render-mode");
|
||||
}
|
||||
// Re-render toolbar button state
|
||||
const btn = this.shadow.querySelector('[data-canvas-action="toggle-render-mode"]') as HTMLElement | null;
|
||||
if (btn) {
|
||||
btn.textContent = `🍄 ${this.renderMode === "organic" ? "Organic" : "Mechanical"}`;
|
||||
btn.classList.toggle("flows-toolbar-btn--active", this.renderMode === "organic");
|
||||
}
|
||||
this.drawCanvasContent();
|
||||
}
|
||||
|
||||
private toggleSimulation() {
|
||||
this.isSimulating = !this.isSimulating;
|
||||
const btn = this.shadow.getElementById("sim-btn");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue