Merge branch 'dev'
This commit is contained in:
commit
cc22d82423
|
|
@ -71,120 +71,6 @@
|
||||||
--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;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +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;
|
||||||
|
|
@ -199,27 +199,6 @@ 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 },
|
||||||
|
|
@ -250,11 +229,6 @@ 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";
|
||||||
|
|
@ -1022,8 +996,6 @@ 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>
|
||||||
|
|
@ -1079,8 +1051,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>
|
||||||
<g id="wire-layer"></g>
|
<g id="wire-layer"></g>
|
||||||
|
|
@ -1236,16 +1207,25 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
|
|
||||||
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
||||||
if (n.type === "source") {
|
if (n.type === "source") {
|
||||||
return { w: 260, h: 120 };
|
const d = n.data as SourceNodeData;
|
||||||
|
const rate = d.flowRate || 0;
|
||||||
|
const w = Math.round(200 + Math.min(160, (rate / 20000) * 160));
|
||||||
|
const h = Math.round(100 + Math.min(80, (rate / 20000) * 80));
|
||||||
|
return { w, h };
|
||||||
}
|
}
|
||||||
if (n.type === "funnel") {
|
if (n.type === "funnel") {
|
||||||
const d = n.data as FunnelNodeData;
|
const d = n.data as FunnelNodeData;
|
||||||
const baseW = 260;
|
|
||||||
const cap = d.maxCapacity || 9000;
|
const cap = d.maxCapacity || 9000;
|
||||||
const h = Math.round(220 + Math.min(200, (cap / 50000) * 200));
|
const w = Math.round(200 + Math.min(160, (cap / 50000) * 160));
|
||||||
return { w: baseW, h: Math.max(220, h) };
|
const h = Math.round(200 + Math.min(220, (cap / 50000) * 220));
|
||||||
|
return { w, h };
|
||||||
}
|
}
|
||||||
return { w: 260, h: 140 }; // basin pool
|
// outcome (basin pool)
|
||||||
|
const d = n.data as OutcomeNodeData;
|
||||||
|
const target = d.fundingTarget || 0;
|
||||||
|
const w = Math.round(200 + Math.min(160, (target / 50000) * 160));
|
||||||
|
const h = Math.round(120 + Math.min(80, (target / 50000) * 80));
|
||||||
|
return { w, h };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Canvas event wiring ──────────────────────────────
|
// ─── Canvas event wiring ──────────────────────────────
|
||||||
|
|
@ -1598,8 +1578,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();
|
||||||
});
|
});
|
||||||
|
|
@ -1900,9 +1879,6 @@ 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));
|
||||||
|
|
@ -2589,12 +2565,6 @@ 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;
|
||||||
|
|
@ -5007,23 +4977,6 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -1,648 +0,0 @@
|
||||||
/**
|
|
||||||
* 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