feat: Sankey-proportional edges + node satisfaction bars in rFunds diagram
Edge widths now reflect actual dollar flow (source rates, overflow excess, spending drain) instead of just allocation percentages. Zero-flow paths render as ghost edges. Edge labels show dollar amounts alongside percentages. Funnel nodes display an inflow satisfaction bar showing how much of their expected inflow is actually arriving. Outcome progress bars enhanced to 8px with dollar labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74a5142349
commit
e644797001
|
|
@ -648,7 +648,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
|
|
||||||
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
||||||
if (n.type === "source") return { w: 200, h: 60 };
|
if (n.type === "source") return { w: 200, h: 60 };
|
||||||
if (n.type === "funnel") return { w: 220, h: 160 };
|
if (n.type === "funnel") return { w: 220, h: 180 };
|
||||||
return { w: 200, h: 100 }; // outcome
|
return { w: 200, h: 100 }; // outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -916,17 +916,65 @@ class FolkFundsApp extends HTMLElement {
|
||||||
document.addEventListener("keydown", this._boundKeyDown);
|
document.addEventListener("keydown", this._boundKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Inflow satisfaction computation ─────────────────
|
||||||
|
|
||||||
|
private computeInflowSatisfaction(): Map<string, { actual: number; needed: number; ratio: number }> {
|
||||||
|
const result = new Map<string, { actual: number; needed: number; ratio: number }>();
|
||||||
|
|
||||||
|
for (const n of this.nodes) {
|
||||||
|
if (n.type === "funnel") {
|
||||||
|
const d = n.data as FunnelNodeData;
|
||||||
|
const needed = d.inflowRate || 1;
|
||||||
|
let actual = 0;
|
||||||
|
// Sum source→funnel allocations
|
||||||
|
for (const src of this.nodes) {
|
||||||
|
if (src.type === "source") {
|
||||||
|
const sd = src.data as SourceNodeData;
|
||||||
|
for (const alloc of sd.targetAllocations) {
|
||||||
|
if (alloc.targetId === n.id) actual += sd.flowRate * (alloc.percentage / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sum overflow from parent funnels
|
||||||
|
if (src.type === "funnel" && src.id !== n.id) {
|
||||||
|
const fd = src.data as FunnelNodeData;
|
||||||
|
const excess = Math.max(0, fd.currentValue - fd.maxThreshold);
|
||||||
|
for (const alloc of fd.overflowAllocations) {
|
||||||
|
if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sum overflow from parent outcomes
|
||||||
|
if (src.type === "outcome") {
|
||||||
|
const od = src.data as OutcomeNodeData;
|
||||||
|
const excess = Math.max(0, od.fundingReceived - od.fundingTarget);
|
||||||
|
for (const alloc of (od.overflowAllocations || [])) {
|
||||||
|
if (alloc.targetId === n.id) actual += excess * (alloc.percentage / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
|
||||||
|
}
|
||||||
|
if (n.type === "outcome") {
|
||||||
|
const d = n.data as OutcomeNodeData;
|
||||||
|
const needed = Math.max(d.fundingTarget, 1);
|
||||||
|
const actual = d.fundingReceived;
|
||||||
|
result.set(n.id, { actual, needed, ratio: Math.min(actual / needed, 2) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Node SVG rendering ───────────────────────────────
|
// ─── Node SVG rendering ───────────────────────────────
|
||||||
|
|
||||||
private renderAllNodes(): string {
|
private renderAllNodes(): string {
|
||||||
return this.nodes.map((n) => this.renderNodeSvg(n)).join("");
|
const satisfaction = this.computeInflowSatisfaction();
|
||||||
|
return this.nodes.map((n) => this.renderNodeSvg(n, satisfaction)).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderNodeSvg(n: FlowNode): 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 (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);
|
if (n.type === "funnel") return this.renderFunnelNodeSvg(n, sel, satisfaction.get(n.id));
|
||||||
return this.renderOutcomeNodeSvg(n, sel);
|
return this.renderOutcomeNodeSvg(n, sel, satisfaction.get(n.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
|
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
|
||||||
|
|
@ -944,9 +992,9 @@ class FolkFundsApp extends HTMLElement {
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderFunnelNodeSvg(n: FlowNode, selected: boolean): string {
|
private renderFunnelNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
|
||||||
const d = n.data as FunnelNodeData;
|
const d = n.data as FunnelNodeData;
|
||||||
const x = n.position.x, y = n.position.y, w = 220, h = 160;
|
const x = n.position.x, y = n.position.y, w = 220, h = 180;
|
||||||
const sufficiency = computeSufficiencyState(d);
|
const sufficiency = computeSufficiencyState(d);
|
||||||
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
|
const isSufficient = sufficiency === "sufficient" || sufficiency === "abundant";
|
||||||
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
||||||
|
|
@ -960,9 +1008,18 @@ class FolkFundsApp extends HTMLElement {
|
||||||
: sufficiency === "sufficient" ? "Sufficient"
|
: sufficiency === "sufficient" ? "Sufficient"
|
||||||
: d.currentValue < d.minThreshold ? "Critical" : "Seeking";
|
: d.currentValue < d.minThreshold ? "Critical" : "Seeking";
|
||||||
|
|
||||||
|
// Inflow satisfaction bar
|
||||||
|
const satBarY = 28;
|
||||||
|
const satBarW = w - 20;
|
||||||
|
const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
|
||||||
|
const satOverflow = sat ? sat.ratio > 1 : false;
|
||||||
|
const satFillW = satBarW * satRatio;
|
||||||
|
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
|
||||||
|
const satBarBorder = satOverflow ? `stroke="#fbbf24" stroke-width="1"` : "";
|
||||||
|
|
||||||
// 3-zone background: drain (red), healthy (blue), overflow (amber)
|
// 3-zone background: drain (red), healthy (blue), overflow (amber)
|
||||||
const zoneH = h - 56; // area for zones (below header, above value text)
|
const zoneY = 52;
|
||||||
const zoneY = 32;
|
const zoneH = h - 76;
|
||||||
const drainPct = d.minThreshold / (d.maxCapacity || 1);
|
const drainPct = d.minThreshold / (d.maxCapacity || 1);
|
||||||
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
|
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
|
||||||
const overflowPct = 1 - drainPct - healthyPct;
|
const overflowPct = 1 - drainPct - healthyPct;
|
||||||
|
|
@ -979,12 +1036,15 @@ class FolkFundsApp extends HTMLElement {
|
||||||
return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
return `<g class="flow-node${glowClass} ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||||
${isSufficient ? `<rect x="-3" y="-3" width="${w + 6}" height="${h + 6}" rx="14" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4"/>` : ""}
|
${isSufficient ? `<rect x="-3" y="-3" width="${w + 6}" height="${h + 6}" rx="14" fill="none" stroke="#fbbf24" stroke-width="2" opacity="0.4"/>` : ""}
|
||||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="10" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
|
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="10" fill="#1e293b" stroke="${selected ? "#6366f1" : borderColor}" stroke-width="${selected ? 3 : 2}"/>
|
||||||
|
<text x="10" y="18" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
||||||
|
<text x="${w - 10}" y="18" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? 'sufficiency-glow' : ''}">${statusLabel}</text>
|
||||||
|
<rect x="10" y="${satBarY}" width="${satBarW}" height="6" rx="3" fill="#334155" opacity="0.3" class="satisfaction-bar-bg"/>
|
||||||
|
<rect x="10" y="${satBarY}" width="${satFillW}" height="6" rx="3" fill="#10b981" class="satisfaction-bar-fill" ${satBarBorder}/>
|
||||||
|
<text x="${w / 2}" y="${satBarY + 16}" text-anchor="middle" fill="#64748b" font-size="9">${satLabel}</text>
|
||||||
<rect x="2" y="${zoneY + overflowH + healthyH}" width="${w - 4}" height="${drainH}" fill="#ef4444" opacity="0.08" rx="0"/>
|
<rect x="2" y="${zoneY + overflowH + healthyH}" width="${w - 4}" height="${drainH}" fill="#ef4444" opacity="0.08" rx="0"/>
|
||||||
<rect x="2" y="${zoneY + overflowH}" width="${w - 4}" height="${healthyH}" fill="#0ea5e9" opacity="0.06" rx="0"/>
|
<rect x="2" y="${zoneY + overflowH}" width="${w - 4}" height="${healthyH}" fill="#0ea5e9" opacity="0.06" rx="0"/>
|
||||||
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
|
<rect x="2" y="${zoneY}" width="${w - 4}" height="${overflowH}" fill="#f59e0b" opacity="0.06" rx="0"/>
|
||||||
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
<rect x="2" y="${fillY}" width="${w - 4}" height="${totalFillH}" fill="${fillColor}" opacity="0.25"/>
|
||||||
<text x="10" y="22" fill="#e2e8f0" font-size="13" font-weight="600">${this.esc(d.label)}</text>
|
|
||||||
<text x="${w - 10}" y="22" text-anchor="end" fill="${borderColor}" font-size="10" font-weight="500" class="${isSufficient ? 'sufficiency-glow' : ''}">${statusLabel}</text>
|
|
||||||
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
<text x="${w / 2}" y="${h - 24}" text-anchor="middle" fill="#94a3b8" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</text>
|
||||||
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
|
<rect x="8" y="${h - 10}" width="${w - 16}" height="4" rx="2" fill="#334155"/>
|
||||||
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
|
<rect x="8" y="${h - 10}" width="${(w - 16) * fillPct}" height="4" rx="2" fill="${fillColor}"/>
|
||||||
|
|
@ -992,7 +1052,7 @@ class FolkFundsApp extends HTMLElement {
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean): string {
|
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
|
||||||
const d = n.data as OutcomeNodeData;
|
const d = n.data as OutcomeNodeData;
|
||||||
const x = n.position.x, y = n.position.y, w = 200, h = 100;
|
const x = n.position.x, y = n.position.y, w = 200, h = 100;
|
||||||
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
|
const fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
|
||||||
|
|
@ -1005,18 +1065,24 @@ class FolkFundsApp extends HTMLElement {
|
||||||
const phaseW = (w - 20) / d.phases.length;
|
const phaseW = (w - 20) / d.phases.length;
|
||||||
phaseBars = d.phases.map((p, i) => {
|
phaseBars = d.phases.map((p, i) => {
|
||||||
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
||||||
return `<rect x="${10 + i * phaseW}" y="62" width="${phaseW - 2}" height="6" rx="2" fill="${unlocked ? "#10b981" : "#334155"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
|
return `<rect x="${10 + i * phaseW}" y="65" width="${phaseW - 2}" height="6" rx="2" fill="${unlocked ? "#10b981" : "#334155"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
phaseBars += `<text x="${w / 2}" y="80" text-anchor="middle" fill="#64748b" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
|
phaseBars += `<text x="${w / 2}" y="83" text-anchor="middle" fill="#64748b" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced progress bar (8px height, green funded portion + grey gap)
|
||||||
|
const barW = w - 20;
|
||||||
|
const barY = 34;
|
||||||
|
const barH = 8;
|
||||||
|
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
|
||||||
|
|
||||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="#1e293b" stroke="${selected ? "#6366f1" : statusColor}" stroke-width="${selected ? 3 : 1.5}"/>
|
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="8" fill="#1e293b" stroke="${selected ? "#6366f1" : statusColor}" stroke-width="${selected ? 3 : 1.5}"/>
|
||||||
<circle cx="14" cy="18" r="5" fill="${statusColor}" opacity="0.7"/>
|
<circle cx="14" cy="18" r="5" fill="${statusColor}" opacity="0.7"/>
|
||||||
<text x="26" y="22" fill="#e2e8f0" font-size="12" font-weight="600">${this.esc(d.label)}</text>
|
<text x="26" y="22" fill="#e2e8f0" font-size="12" font-weight="600">${this.esc(d.label)}</text>
|
||||||
<rect x="10" y="34" width="${w - 20}" height="5" rx="2.5" fill="#334155"/>
|
<rect x="10" y="${barY}" width="${barW}" height="${barH}" rx="4" fill="#334155" class="satisfaction-bar-bg"/>
|
||||||
<rect x="10" y="34" width="${(w - 20) * fillPct}" height="5" rx="2.5" fill="${statusColor}" opacity="0.8"/>
|
<rect x="10" y="${barY}" width="${barW * fillPct}" height="${barH}" rx="4" fill="${statusColor}" opacity="0.8" class="satisfaction-bar-fill"/>
|
||||||
<text x="${w / 2}" y="52" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% — $${Math.floor(d.fundingReceived).toLocaleString()}</text>
|
<text x="${w / 2}" y="${barY + barH + 12}" text-anchor="middle" fill="#94a3b8" font-size="10">${Math.round(fillPct * 100)}% — ${dollarLabel}</text>
|
||||||
${phaseBars}
|
${phaseBars}
|
||||||
${this.renderPortsSvg(n)}
|
${this.renderPortsSvg(n)}
|
||||||
</g>`;
|
</g>`;
|
||||||
|
|
@ -1037,54 +1103,74 @@ class FolkFundsApp extends HTMLElement {
|
||||||
|
|
||||||
// ─── Edge rendering ───────────────────────────────────
|
// ─── Edge rendering ───────────────────────────────────
|
||||||
|
|
||||||
|
private formatDollar(amount: number): string {
|
||||||
|
if (amount >= 1_000_000) return `$${(amount / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (amount >= 1_000) return `$${(amount / 1_000).toFixed(1)}k`;
|
||||||
|
return `$${Math.round(amount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private renderAllEdges(): string {
|
private renderAllEdges(): string {
|
||||||
let html = "";
|
// First pass: compute actual dollar flow per edge
|
||||||
// Find max flow rate for Sankey width scaling
|
interface EdgeInfo {
|
||||||
const maxFlow = Math.max(1, ...this.nodes.filter((n) => n.type === "source").map((n) => (n.data as SourceNodeData).flowRate));
|
fromNode: FlowNode;
|
||||||
|
toNode: FlowNode;
|
||||||
|
fromPort: PortKind;
|
||||||
|
color: string;
|
||||||
|
flowAmount: number;
|
||||||
|
pct: number;
|
||||||
|
dashed: boolean;
|
||||||
|
fromId: string;
|
||||||
|
toId: string;
|
||||||
|
edgeType: string;
|
||||||
|
}
|
||||||
|
const edges: EdgeInfo[] = [];
|
||||||
|
|
||||||
for (const n of this.nodes) {
|
for (const n of this.nodes) {
|
||||||
if (n.type === "source") {
|
if (n.type === "source") {
|
||||||
const d = n.data as SourceNodeData;
|
const d = n.data as SourceNodeData;
|
||||||
const from = this.getPortPosition(n, "outflow");
|
|
||||||
for (const alloc of d.targetAllocations) {
|
for (const alloc of d.targetAllocations) {
|
||||||
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const flowAmount = d.flowRate * (alloc.percentage / 100);
|
||||||
const strokeW = Math.max(2, (d.flowRate / maxFlow) * (alloc.percentage / 100) * 12);
|
edges.push({
|
||||||
html += this.renderEdgePath(
|
fromNode: n, toNode: target, fromPort: "outflow",
|
||||||
from.x, from.y, to.x, to.y,
|
color: alloc.color || "#10b981", flowAmount,
|
||||||
alloc.color || "#10b981", strokeW, false,
|
pct: alloc.percentage, dashed: false,
|
||||||
alloc.percentage, n.id, alloc.targetId, "source",
|
fromId: n.id, toId: alloc.targetId, edgeType: "source",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (n.type === "funnel") {
|
if (n.type === "funnel") {
|
||||||
const d = n.data as FunnelNodeData;
|
const d = n.data as FunnelNodeData;
|
||||||
// Overflow edges — from overflow port
|
// Overflow edges — actual excess flow
|
||||||
for (const alloc of d.overflowAllocations) {
|
for (const alloc of d.overflowAllocations) {
|
||||||
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "overflow");
|
const excess = Math.max(0, d.currentValue - d.maxThreshold);
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const flowAmount = excess * (alloc.percentage / 100);
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 10);
|
edges.push({
|
||||||
html += this.renderEdgePath(
|
fromNode: n, toNode: target, fromPort: "overflow",
|
||||||
from.x, from.y, to.x, to.y,
|
color: alloc.color || "#f59e0b", flowAmount,
|
||||||
alloc.color || "#f59e0b", strokeW, true,
|
pct: alloc.percentage, dashed: true,
|
||||||
alloc.percentage, n.id, alloc.targetId, "overflow",
|
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
// Spending edges — from spending port
|
// Spending edges — rate-based drain
|
||||||
for (const alloc of d.spendingAllocations) {
|
for (const alloc of d.spendingAllocations) {
|
||||||
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "spending");
|
let rateMultiplier: number;
|
||||||
const to = this.getPortPosition(target, "inflow");
|
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
|
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
|
||||||
html += this.renderEdgePath(
|
else rateMultiplier = 0.1;
|
||||||
from.x, from.y, to.x, to.y,
|
const drain = d.inflowRate * rateMultiplier;
|
||||||
alloc.color || "#8b5cf6", strokeW, false,
|
const flowAmount = drain * (alloc.percentage / 100);
|
||||||
alloc.percentage, n.id, alloc.targetId, "spending",
|
edges.push({
|
||||||
);
|
fromNode: n, toNode: target, fromPort: "spending",
|
||||||
|
color: alloc.color || "#8b5cf6", flowAmount,
|
||||||
|
pct: alloc.percentage, dashed: false,
|
||||||
|
fromId: n.id, toId: alloc.targetId, edgeType: "spending",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Outcome overflow edges
|
// Outcome overflow edges
|
||||||
|
|
@ -1094,44 +1180,84 @@ class FolkFundsApp extends HTMLElement {
|
||||||
for (const alloc of allocs) {
|
for (const alloc of allocs) {
|
||||||
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
const target = this.nodes.find((t) => t.id === alloc.targetId);
|
||||||
if (!target) continue;
|
if (!target) continue;
|
||||||
const from = this.getPortPosition(n, "overflow");
|
const excess = Math.max(0, d.fundingReceived - d.fundingTarget);
|
||||||
const to = this.getPortPosition(target, "inflow");
|
const flowAmount = excess * (alloc.percentage / 100);
|
||||||
const strokeW = Math.max(1.5, (alloc.percentage / 100) * 8);
|
edges.push({
|
||||||
html += this.renderEdgePath(
|
fromNode: n, toNode: target, fromPort: "overflow",
|
||||||
from.x, from.y, to.x, to.y,
|
color: alloc.color || "#f59e0b", flowAmount,
|
||||||
alloc.color || "#f59e0b", strokeW, true,
|
pct: alloc.percentage, dashed: true,
|
||||||
alloc.percentage, n.id, alloc.targetId, "overflow",
|
fromId: n.id, toId: alloc.targetId, edgeType: "overflow",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find max flow amount for width normalization
|
||||||
|
const maxFlowAmount = Math.max(1, ...edges.map((e) => e.flowAmount));
|
||||||
|
|
||||||
|
// Second pass: render edges with normalized widths
|
||||||
|
let html = "";
|
||||||
|
for (const e of edges) {
|
||||||
|
const from = this.getPortPosition(e.fromNode, e.fromPort);
|
||||||
|
const to = this.getPortPosition(e.toNode, "inflow");
|
||||||
|
const isGhost = e.flowAmount === 0;
|
||||||
|
const strokeW = isGhost ? 1 : Math.max(1.5, (e.flowAmount / maxFlowAmount) * 14);
|
||||||
|
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
|
||||||
|
html += this.renderEdgePath(
|
||||||
|
from.x, from.y, to.x, to.y,
|
||||||
|
e.color, strokeW, e.dashed, isGhost,
|
||||||
|
label, e.fromId, e.toId, e.edgeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderEdgePath(
|
private renderEdgePath(
|
||||||
x1: number, y1: number, x2: number, y2: number,
|
x1: number, y1: number, x2: number, y2: number,
|
||||||
color: string, strokeW: number, dashed: boolean,
|
color: string, strokeW: number, dashed: boolean, ghost: boolean,
|
||||||
pct: number, fromId: string, toId: string, edgeType: string,
|
label: string, fromId: string, toId: string, edgeType: string,
|
||||||
): string {
|
): string {
|
||||||
const cy1 = y1 + (y2 - y1) * 0.4;
|
const cy1 = y1 + (y2 - y1) * 0.4;
|
||||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||||
const midX = (x1 + x2) / 2;
|
const midX = (x1 + x2) / 2;
|
||||||
const midY = (y1 + y2) / 2;
|
const midY = (y1 + y2) / 2;
|
||||||
const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
|
const d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
|
||||||
|
|
||||||
|
if (ghost) {
|
||||||
|
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
||||||
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="1" stroke-opacity="0.2" stroke-dasharray="4 6" class="edge-ghost"/>
|
||||||
|
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
||||||
|
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.5"/>
|
||||||
|
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
|
||||||
|
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
||||||
|
<rect x="-32" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
||||||
|
<text x="-24" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">−</text>
|
||||||
|
</g>
|
||||||
|
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
||||||
|
<rect x="16" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
||||||
|
<text x="24" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">+</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
||||||
|
// Wider label box to fit dollar amounts
|
||||||
|
const labelW = Math.max(68, label.length * 7 + 36);
|
||||||
|
const halfW = labelW / 2;
|
||||||
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}">
|
||||||
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
||||||
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
|
||||||
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
||||||
<rect x="-34" y="-12" width="68" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
|
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" fill="#1e293b" stroke="#475569" stroke-width="1" opacity="0.9"/>
|
||||||
<text x="-14" y="5" fill="${color}" font-size="11" font-weight="600" text-anchor="middle">${pct}%</text>
|
<text x="0" y="5" fill="${color}" font-size="10" font-weight="600" text-anchor="middle">${label}</text>
|
||||||
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
<g data-edge-action="dec" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
||||||
<rect x="-32" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
<rect x="${-halfW + 2}" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
||||||
<text x="-24" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">−</text>
|
<text x="${-halfW + 10}" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">−</text>
|
||||||
</g>
|
</g>
|
||||||
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
|
||||||
<rect x="16" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
<rect x="${halfW - 18}" y="-9" width="16" height="18" rx="3" fill="#334155"/>
|
||||||
<text x="24" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">+</text>
|
<text x="${halfW - 10}" y="5" fill="#e2e8f0" font-size="13" text-anchor="middle">+</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</g>`;
|
</g>`;
|
||||||
|
|
|
||||||
|
|
@ -334,6 +334,13 @@
|
||||||
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
|
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
|
||||||
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
|
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
|
||||||
|
|
||||||
|
/* Ghost edge (zero-flow potential paths) */
|
||||||
|
.edge-ghost { pointer-events: none; }
|
||||||
|
|
||||||
|
/* Satisfaction bar (inflow bar on funnels & outcomes) */
|
||||||
|
.satisfaction-bar-bg { opacity: 0.3; }
|
||||||
|
.satisfaction-bar-fill { transition: width 0.3s ease; }
|
||||||
|
|
||||||
/* ── Node detail modals ──────────────────────────────── */
|
/* ── Node detail modals ──────────────────────────────── */
|
||||||
.funds-modal-backdrop {
|
.funds-modal-backdrop {
|
||||||
position: fixed; inset: 0; z-index: 50;
|
position: fixed; inset: 0; z-index: 50;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue