feat: rFlows visual redesign — HTML card nodes, Sankey widths, smooth drag

- Switch source/outcome nodes from SVG shapes to foreignObject HTML cards
  with white backgrounds, gradient headers, status badges, and progress bars
- Add foreignObject text overlay to funnel nodes (keep SVG tank shape)
- Fix Sankey edge widths: flow-value-relative formula instead of percentage
- Smooth drag: rAF throttle + lightweight edge path patching during drag
- Add dot grid canvas background, arrowhead markers, larger port dots
- Fixed node sizes: source 220x120, outcome 220x180

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 18:18:06 -08:00
parent 92dff99793
commit d8f9f46515
2 changed files with 231 additions and 98 deletions

View File

@ -239,6 +239,9 @@
.flows-canvas-svg {
width: 100%; height: 100%; display: block;
cursor: grab;
background-color: #f8fafc;
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
background-size: 20px 20px;
}
.flows-canvas-svg.panning { cursor: grabbing; }
.flows-canvas-svg.dragging { cursor: move; }
@ -267,10 +270,20 @@
/* SVG node styles */
.flow-node { cursor: pointer; }
.flow-node:hover .node-bg { filter: brightness(1.15); }
.flow-node:hover .node-bg { filter: brightness(1.05); }
.flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; }
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
/* HTML card nodes (foreignObject) */
.node-card {
background: white; border-radius: 12px; overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
height: 100%; box-sizing: border-box;
}
.source-card { }
.outcome-card { }
.funnel-overlay { pointer-events: none; }
/* Editor panel — right side slide-in */
.flows-editor-panel {
position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20;
@ -416,8 +429,8 @@
/* ── Port & wiring ──────────────────────────────────── */
.port-group { pointer-events: all; }
.port-hit { cursor: crosshair; }
.port-dot { transition: r 0.15s, filter 0.15s; }
.port-group:hover .port-dot { r: 7; filter: drop-shadow(0 0 4px currentColor); }
.port-dot { transition: r 0.15s, filter 0.15s; r: 7; stroke: white; stroke-width: 2; }
.port-group:hover .port-dot { r: 9; filter: drop-shadow(0 0 4px currentColor); }
.port-group--wiring-source .port-dot { animation: port-glow 0.8s ease-in-out infinite; }
.port-group--wiring-target .port-dot { animation: port-breathe 1s ease-in-out infinite; }

View File

@ -141,6 +141,7 @@ class FolkFlowsApp extends HTMLElement {
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
private _dragRafId: number | null = null;
// Flow storage & switching
private localFirstClient: FlowsLocalFirstClient | null = null;
@ -900,6 +901,17 @@ class FolkFlowsApp extends HTMLElement {
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
</div>
<svg class="flows-canvas-svg" id="flow-canvas">
<defs>
<marker id="arrowhead-inflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-inflow)"/>
</marker>
<marker id="arrowhead-spending" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-spending)"/>
</marker>
<marker id="arrowhead-overflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-overflow)"/>
</marker>
</defs>
<g id="canvas-transform">
<g id="edge-layer"></g>
<g id="wire-layer"></g>
@ -1030,23 +1042,16 @@ class FolkFlowsApp extends HTMLElement {
private getNodeSize(n: FlowNode): { w: number; h: number } {
if (n.type === "source") {
const d = n.data as SourceNodeData;
const rate = d.flowRate || 100;
// Low rate → tall & thin (recurring drip), high rate → short & thick (large chunk)
const ratio = Math.min(1, rate / 5000);
const w = 140 + Math.round(ratio * 140); // 140280
const h = 110 - Math.round(ratio * 40); // 11070
return { w, h };
return { w: 220, h: 120 };
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const baseW = 280, baseH = 250;
// Height scales with capacity (draggable), width stays fixed
const hRef = d.maxCapacity || 9000;
const hScale = 0.8 + Math.log10(Math.max(1, hRef / 5000)) * 0.35;
return { w: baseW, h: Math.round(baseH * Math.max(0.75, hScale)) };
}
return { w: 220, h: 120 }; // outcome (basin)
return { w: 220, h: 180 }; // outcome card
}
// ─── Canvas event wiring ──────────────────────────────
@ -1139,15 +1144,20 @@ class FolkFlowsApp extends HTMLElement {
nodeDragStarted = true;
svg.classList.add("dragging");
}
const dx = rawDx / this.canvasZoom;
const dy = rawDy / this.canvasZoom;
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
if (node) {
node.position.x = this.dragNodeStartX + dx;
node.position.y = this.dragNodeStartY + dy;
this.updateNodePosition(node);
this.redrawEdges();
}
// rAF throttle: skip if a frame is already queued
if (this._dragRafId) return;
this._dragRafId = requestAnimationFrame(() => {
this._dragRafId = null;
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
if (node) {
node.position.x = this.dragNodeStartX + dx;
node.position.y = this.dragNodeStartY + dy;
this.updateNodePosition(node);
this.updateEdgesDuringDrag(node.id);
}
});
}
};
this._boundPointerUp = (e: PointerEvent) => {
@ -1180,6 +1190,8 @@ class FolkFlowsApp extends HTMLElement {
this.draggingNodeId = null;
nodeDragStarted = false;
svg.classList.remove("dragging");
// Cancel any pending rAF
if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; }
// Single click = select + open inline editor
if (!wasDragged) {
@ -1188,6 +1200,8 @@ class FolkFlowsApp extends HTMLElement {
this.updateSelectionHighlight();
this.enterInlineEdit(clickedNodeId);
} else {
// Full edge redraw for final accuracy
this.redrawEdges();
this.scheduleSave();
}
}
@ -1584,16 +1598,32 @@ class FolkFlowsApp extends HTMLElement {
const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const icons: Record<string, string> = { card: "\u{1F4B3}", safe_wallet: "\u{1F512}", ridentity: "\u{1F464}", unconfigured: "\u{2699}" };
const icon = icons[d.sourceType] || "\u{1F4B0}";
const bodyH = h - 20;
const stubW = 26, stubH = 20;
const isSmall = d.flowRate < 1000;
// Allocation bar segments as inline HTML
let allocBarHtml = "";
if (d.targetAllocations && d.targetAllocations.length > 0) {
const segs = d.targetAllocations.map(a =>
`<div style="flex:${a.percentage};height:3px;background:${a.color};border-radius:1px;opacity:0.8"></div>`
).join("");
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
}
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="${bodyH}" rx="12" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="${selected ? 3 : 2.5}"/>
<rect x="${(w - stubW) / 2}" y="${bodyH}" width="${stubW}" height="${stubH}" rx="4" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="1.5"/>
<text x="${isSmall ? w / 2 : 16}" y="${bodyH * 0.35}" ${isSmall ? 'text-anchor="middle"' : ""} style="fill:var(--rs-text-primary)" font-size="16">${icon}</text>
<text x="${w / 2}" y="${bodyH * 0.35 + (isSmall ? 18 : 0)}" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="14" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="${bodyH * 0.75}" text-anchor="middle" style="fill:var(--rflows-source-rate)" font-size="12" font-weight="500">$${d.flowRate.toLocaleString()}/mo</text>
${this.renderAllocBar(d.targetAllocations, w, bodyH - 4)}
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card source-card ${selected ? "selected" : ""}">
<div class="card-header" style="background:linear-gradient(to right,#ecfdf5,#f0fdfa);padding:8px 12px;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:16px">${icon}</span>
<span style="font-size:13px;font-weight:600;color:#1e293b">${this.esc(d.label)}</span>
</div>
</div>
<div style="padding:8px 12px;text-align:center">
<div style="font-size:20px;font-weight:700;color:#059669;font-family:ui-monospace,monospace">$${d.flowRate.toLocaleString()}<span style="font-size:12px;font-weight:400;color:#64748b">/mo</span></div>
</div>
${allocBarHtml}
</div>
</foreignObject>
${this.renderPortsSvg(n)}
</g>`;
}
@ -1686,15 +1716,6 @@ class FolkFlowsApp extends HTMLElement {
<line class="threshold-line" x1="6" x2="${w - 6}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-overflow)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
// Zone labels (centered in each zone)
const criticalMidY = zoneTop + zoneH - criticalH / 2;
const sufficientMidY = zoneTop + overflowH + sufficientH / 2;
const overflowMidY = zoneTop + overflowH / 2;
const zoneLabels = `
${criticalH > 20 ? `<text x="${w / 2}" y="${criticalMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="600" opacity="0.5">CRITICAL</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${sufficientMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-sustained)" font-size="10" font-weight="600" opacity="0.5">SUFFICIENT</text>` : ""}
${overflowH > 20 ? `<text x="${w / 2}" y="${overflowMidY + 4}" text-anchor="middle" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="600" opacity="0.5">OVERFLOW</text>` : ""}`;
// Inflow satisfaction bar
const satBarY = 50;
const satBarW = w - 48;
@ -1708,17 +1729,20 @@ class FolkFlowsApp extends HTMLElement {
: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
// Rate labels
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
const baseRate = d.desiredOutflow || d.inflowRate;
let rateMultiplier: number;
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
else rateMultiplier = 0.1;
const spendingRate = baseRate * rateMultiplier;
const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
// Status badge colors for HTML
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
<defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
@ -1731,21 +1755,29 @@ class FolkFlowsApp extends HTMLElement {
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/>
<rect class="funnel-fill-rect" data-node-id="${n.id}" x="${-pipeW}" y="${fillY}" width="${w + pipeW * 2}" height="${totalFillH}" style="fill:${fillColor};opacity:var(--rflows-fill-opacity)"/>
${thresholdLines}
${zoneLabels}
</g>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
<text x="${w / 2}" y="-10" text-anchor="middle" style="fill:var(--rflows-label-inflow)" font-size="12" font-weight="500" opacity="0.8">${inflowLabel}</text>
<text x="${w / 2}" y="20" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="16" font-weight="600">${this.esc(d.label)}</text>
<text x="${w - 12}" y="20" text-anchor="end" style="fill:${borderColorVar}" font-size="12" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</text>
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
<text x="${w / 2}" y="${satBarY + 20}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="11">${satLabel}</text>
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - insetPx - 10}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="14" font-weight="500">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</text>
<rect class="funnel-valve-bar" x="${insetPx + 2}" y="${h - 10}" width="${w - insetPx * 2 - 4}" height="8" rx="3" style="fill:var(--rflows-label-spending);opacity:0.6;cursor:ew-resize"/>
<text x="${w / 2}" y="${h + 18}" text-anchor="middle" style="fill:var(--rflows-label-spending)" font-size="12" font-weight="600" opacity="0.9">${this.formatDollar(outflow)}/mo </text>
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" style="fill:var(--rflows-label-overflow)" font-size="11" font-weight="500" opacity="0.8">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" style="fill:var(--rflows-label-overflow)" font-size="11" font-weight="500" opacity="0.8">${overflowLabel}</text>` : ""}
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
<div style="position:absolute;top:38px;left:${pipeW + 10}px;right:${pipeW + 10}px;display:flex;align-items:center;justify-content:space-between">
<span style="font-size:14px;font-weight:600;color:#1e293b">${this.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>
<div style="position:absolute;top:${satBarY + 18 + 28}px;left:${pipeW + 10}px;right:${pipeW + 10}px;text-align:center;font-size:10px;color:#94a3b8">${satLabel}</div>
${criticalH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH + criticalH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#ef4444;opacity:0.5">CRITICAL</div>` : ""}
${sufficientH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">SUFFICIENT</div>` : ""}
${overflowH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">OVERFLOW</div>` : ""}
<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${insetPx + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:#64748b">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
<div style="position:absolute;bottom:10px;width:100%;text-align:center;font-size:12px;font-weight:600;color:#34d399">${this.formatDollar(outflow)}/mo \u25BE</div>
${isOverflow ? `<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;left:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>
<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;right:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>` : ""}
</div>
</foreignObject>
${this.renderPortsSvg(n)}
</g>`;
}
@ -1755,56 +1787,43 @@ class FolkFlowsApp extends HTMLElement {
const s = this.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 statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)"
: d.status === "blocked" ? "var(--rflows-status-blocked)"
: d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)";
const statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
const statusColor = statusColors[d.status] || "#64748b";
const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());
// Basin shape: slightly flared walls (8px wider at top)
const flare = 8;
const clipId = `basin-clip-${n.id}`;
const basinPath = [
`M ${-flare},0`,
`L ${w + flare},0`,
`Q ${w + flare},4 ${w + flare - 2},8`,
`L ${w},${h - 8}`,
`Q ${w},${h} ${w - 8},${h}`,
`L 8,${h}`,
`Q 0,${h} 0,${h - 8}`,
`L ${-flare + 2},8`,
`Q ${-flare},4 ${-flare},0`,
`Z`,
].join(" ");
// Fill level from bottom
const fillZoneTop = 30;
const fillZoneH = h - fillZoneTop - 4;
const fillH = fillZoneH * fillPct;
const fillY = fillZoneTop + fillZoneH - fillH;
let phaseBars = "";
// Phase indicators
let phaseHtml = "";
if (d.phases && d.phases.length > 0) {
const phaseW = (w - 20) / d.phases.length;
phaseBars = d.phases.map((p, i) => {
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
const phaseSegs = d.phases.map((p, i) => {
const unlocked = d.fundingReceived >= p.fundingThreshold;
return `<rect x="${10 + i * phaseW}" y="75" width="${phaseW - 2}" height="6" rx="2" style="fill:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-bg-surface-raised)"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
return `<div style="flex:1;height:4px;border-radius:2px;background:${unlocked ? "#10b981" : "#e2e8f0"}"></div>`;
}).join("");
phaseBars += `<text x="${w / 2}" y="93" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
phaseHtml = `<div style="display:flex;gap:2px;margin:6px 0">${phaseSegs}</div>
<div style="font-size:10px;color:#94a3b8;text-align:center">${unlockedCount}/${d.phases.length} phases unlocked</div>`;
}
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})">
<defs>
<clipPath id="${clipId}"><path d="${basinPath}"/></clipPath>
</defs>
<path class="node-bg" d="${basinPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColorVar}" stroke-width="${selected ? 3 : 1.5}"/>
<g clip-path="url(#${clipId})">
<rect class="basin-fill-rect" x="${-flare}" y="${fillY}" width="${w + flare * 2}" height="${fillH}" style="fill:${statusColorVar};opacity:0.25"/>
</g>
<circle cx="14" cy="18" r="5" style="fill:${statusColorVar}" opacity="0.7"/>
<text x="26" y="22" style="fill:var(--rs-text-primary)" font-size="12" font-weight="600">${this.esc(d.label)}</text>
<text x="${w / 2}" y="${fillY > 50 ? fillY - 4 : 50}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="10">${Math.round(fillPct * 100)}% ${dollarLabel}</text>
${phaseBars}
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
<foreignObject x="0" y="0" width="${w}" height="${h}">
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
<div class="card-header" style="background:linear-gradient(to right,#fdf2f8,#faf5ff);padding:8px 12px;border-bottom:1px solid #e2e8f0">
<div style="display:flex;align-items:center;justify-content:space-between">
<span style="font-size:13px;font-weight:600;color:#1e293b">${this.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>
</div>
</div>
<div style="padding:8px 12px">
<div style="font-size:11px;color:#64748b;margin-bottom:4px">${Math.round(fillPct * 100)}% funded ${dollarLabel}</div>
<div style="height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden">
<div style="width:${fillPct * 100}%;height:100%;background:${statusColor};border-radius:3px;transition:width 0.3s"></div>
</div>
${phaseHtml}
</div>
</div>
</foreignObject>
${this.renderPortsSvg(n)}
</g>`;
}
@ -1921,15 +1940,16 @@ class FolkFlowsApp extends HTMLElement {
}
}
// Second pass: render edges with percentage-proportional widths
// Second pass: render edges with flow-value-relative widths (Sankey-style)
const MAX_EDGE_W = 28;
const MIN_EDGE_W = 3;
const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1);
let html = "";
for (const e of edges) {
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
const to = this.getPortPosition(e.toNode, "inflow");
const isGhost = e.flowAmount === 0;
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W);
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W);
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
html += this.renderEdgePath(
from.x, from.y, to.x, to.y,
@ -2014,10 +2034,12 @@ class FolkFlowsApp extends HTMLElement {
const halfW = labelW / 2;
// Drag handle at midpoint
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
// Arrow marker
const markerId = edgeType === "overflow" ? "arrowhead-overflow" : edgeType === "spending" ? "arrowhead-spending" : "arrowhead-inflow";
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
${hitPath}
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}"/>
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}" marker-end="url(#${markerId})"/>
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" style="fill:${color}" opacity="0.5" class="edge-splash"><animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.6)};${Math.max(8, finalStrokeW)};${Math.max(4, finalStrokeW * 0.6)}" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0.2;0.5" dur="1.2s" repeatCount="indefinite"/></circle>` : ""}
${dragHandle}
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
@ -2040,6 +2062,101 @@ class FolkFlowsApp extends HTMLElement {
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
}
/** Pure path computation — returns { d, midX, midY } */
private computeEdgePath(
x1: number, y1: number, x2: number, y2: number,
strokeW: number, fromSide?: "left" | "right",
waypoint?: { x: number; y: number },
): { d: string; midX: number; midY: number } {
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 cy1 = y1 + (y2 - y1) * 0.4;
const cy2 = y1 + (y2 - y1) * 0.6;
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
midX = (x1 + x2) / 2;
midY = (y1 + y2) / 2;
}
return { d, midX, midY };
}
/** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */
private updateEdgesDuringDrag(nodeId: string) {
const edgeLayer = this.shadow.getElementById("edge-layer");
if (!edgeLayer) return;
const groups = edgeLayer.querySelectorAll(`.edge-group[data-from="${nodeId}"], .edge-group[data-to="${nodeId}"]`);
for (const g of groups) {
const el = g as SVGGElement;
const fromId = el.dataset.from!;
const toId = el.dataset.to!;
const edgeType = el.dataset.edgeType || "source";
const fromNode = this.nodes.find(n => n.id === fromId);
const toNode = this.nodes.find(n => n.id === toId);
if (!fromNode || !toNode) continue;
// Determine port kinds and side
let fromPort: PortKind = "outflow";
let fromSide: "left" | "right" | undefined;
if (edgeType === "overflow") {
fromPort = "overflow";
fromSide = this.getOverflowSideForTarget(fromNode, toNode);
} else if (edgeType === "spending") {
fromPort = "spending";
}
const from = this.getPortPosition(fromNode, fromPort, fromSide);
const to = this.getPortPosition(toNode, "inflow");
// Get waypoint from allocation
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
const waypoint = alloc?.waypoint;
// Compute stroke width (approximate — use existing path width)
const mainPath = el.querySelector<SVGPathElement>(".edge-path-animated, .edge-path-overflow, .edge-ghost");
const existingStrokeW = mainPath ? parseFloat(mainPath.getAttribute("stroke-width") || "4") : 4;
const { d, midX, midY } = this.computeEdgePath(from.x, from.y, to.x, to.y, existingStrokeW, fromSide, waypoint);
// Update all path elements in this group
el.querySelectorAll("path").forEach(path => {
path.setAttribute("d", d);
});
// Update label/control group position
const ctrlGroup = el.querySelector(".edge-ctrl-group") as SVGGElement | null;
if (ctrlGroup) ctrlGroup.setAttribute("transform", `translate(${midX},${midY})`);
// Update drag handle
const dragHandle = el.querySelector(".edge-drag-handle") as SVGCircleElement | null;
if (dragHandle) {
dragHandle.setAttribute("cx", String(midX));
dragHandle.setAttribute("cy", String(midY - 18));
}
// Update splash circle for overflow edges
const splash = el.querySelector(".edge-splash") as SVGCircleElement | null;
if (splash) {
splash.setAttribute("cx", String(from.x));
splash.setAttribute("cy", String(from.y));
}
}
}
// ─── Edge waypoint helpers ──────────────────────────────
private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null {
@ -2081,21 +2198,24 @@ class FolkFlowsApp extends HTMLElement {
const el = g as SVGGElement;
const isSelected = el.dataset.nodeId === this.selectedNodeId;
el.classList.toggle("selected", isSelected);
// Update SVG rect stroke
const bg = el.querySelector(".node-bg") as SVGElement | null;
if (bg) {
if (isSelected) {
bg.setAttribute("stroke", "var(--rflows-selected)");
bg.setAttribute("stroke-width", "3");
} else {
// Restore original color
const node = this.nodes.find((n) => n.id === el.dataset.nodeId);
if (node) {
const origColor = this.getNodeBorderColor(node);
bg.setAttribute("stroke", origColor);
bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2");
bg.setAttribute("stroke-width", node.type === "outcome" ? "2" : "2");
}
}
}
// Update HTML card selected class
const card = el.querySelector(".node-card") as HTMLElement | null;
if (card) card.classList.toggle("selected", isSelected);
});
// Edge selection highlight
@ -2185,8 +2305,8 @@ class FolkFlowsApp extends HTMLElement {
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" style="fill:${p.color}" opacity="0.7"/>`;
}
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"${sideAttr}>
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" style="fill:${p.color};color:${p.color}"/>
<circle class="port-hit" cx="${cx}" cy="${cy}" r="14" fill="transparent"/>
<circle class="port-dot" cx="${cx}" cy="${cy}" r="7" style="fill:${p.color};color:${p.color}" stroke="white" stroke-width="2"/>
${arrow}
</g>`;
}).join("");