Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 15:44:24 -07:00
commit 1765204d0d
7 changed files with 465 additions and 128 deletions

View File

@ -407,7 +407,7 @@ function seedTemplateBooks(space: string) {
_syncServer.changeDoc<BooksCatalogDoc>(docId, 'seed template books', (d) => {
for (const b of books) {
const id = crypto.randomUUID();
const id = randomUUID();
const slug = b.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
d.items[id] = {
id, slug, title: b.title, author: b.author, description: b.desc,

View File

@ -525,6 +525,13 @@
.satisfaction-bar-bg { opacity: 0.3; }
.satisfaction-bar-fill { transition: width 0.3s ease; }
/* Split controls — proportional allocation sliders */
.split-control { pointer-events: all; }
.split-seg { transition: width 50ms ease-out; }
.split-divider { cursor: ew-resize; }
.split-divider:hover rect { fill: #6366f1; stroke: #818cf8; }
.split-divider:active rect { fill: #4f46e5; }
/* ── Node detail modals ──────────────────────────────── */
.flows-modal-backdrop {
position: fixed; inset: 0; z-index: 50;
@ -662,8 +669,8 @@
/* Inline edit overlay container */
.inline-edit-overlay { pointer-events: all; }
/* Funnel overflow pipe (vessel metaphor) */
.funnel-pipe { transition: fill 0.3s, height 0.3s, y 0.3s; }
/* Funnel overflow pipe (vessel metaphor) — fixed height, animate opacity + fill */
.funnel-pipe { transition: opacity 0.2s ease, fill 0.2s ease; }
.funnel-pipe--active { fill: #10b981; }
/* Threshold lines inside tank */

View File

@ -116,6 +116,17 @@ class FolkFlowsApp extends HTMLElement {
private draggingEdgeKey: string | null = null;
private edgeDragPointerId: number | null = null;
// Sankey flow width pre-pass results
private _currentFlowWidths: Map<string, { totalOutflow: number; totalInflow: number; outflowWidthPx: number; inflowWidthPx: number; inflowFillRatio: number }> = new Map();
// Split control drag state
private _splitDragging = false;
private _splitDragNodeId: string | null = null;
private _splitDragAllocType: string | null = null;
private _splitDragDividerIdx = 0;
private _splitDragStartX = 0;
private _splitDragStartPcts: number[] = [];
// Source purchase modal state
private sourceModalNodeId: string | null = null;
@ -1308,6 +1319,11 @@ class FolkFlowsApp extends HTMLElement {
const DRAG_THRESHOLD = 5;
this._boundPointerMove = (e: PointerEvent) => {
// Split control drag
if (this._splitDragging) {
this.handleSplitDragMove(e.clientX);
return;
}
if (this.wiringActive && this.wiringDragging) {
this.wiringPointerX = e.clientX;
this.wiringPointerY = e.clientY;
@ -1355,6 +1371,11 @@ class FolkFlowsApp extends HTMLElement {
}
};
this._boundPointerUp = (e: PointerEvent) => {
// Split control drag end
if (this._splitDragging) {
this.handleSplitDragEnd();
return;
}
if (this.wiringActive && this.wiringDragging) {
// Hit-test: did we release on a compatible input port?
const el = this.shadow.elementFromPoint(e.clientX, e.clientY);
@ -1563,25 +1584,38 @@ class FolkFlowsApp extends HTMLElement {
});
}
// Edge +/- buttons (delegated)
// Split control drag (delegated on node layer)
const nodeLayerForSplit = this.shadow.getElementById("node-layer");
if (nodeLayerForSplit) {
nodeLayerForSplit.addEventListener("pointerdown", (e: PointerEvent) => {
const divider = (e.target as Element).closest(".split-divider") as SVGGElement | null;
if (!divider) return;
e.stopPropagation();
e.preventDefault();
const nodeId = divider.dataset.nodeId!;
const allocType = divider.dataset.allocType!;
const dividerIdx = parseInt(divider.dataset.dividerIdx!, 10);
// Capture starting percentages
const allocs = this.getSplitAllocs(nodeId, allocType);
if (!allocs || allocs.length < 2) return;
this._splitDragging = true;
this._splitDragNodeId = nodeId;
this._splitDragAllocType = allocType;
this._splitDragDividerIdx = dividerIdx;
this._splitDragStartX = e.clientX;
this._splitDragStartPcts = allocs.map(a => a.percentage);
(e.target as Element).setPointerCapture?.(e.pointerId);
});
}
// Edge layer — edge selection + drag handles
const edgeLayer = this.shadow.getElementById("edge-layer");
if (edgeLayer) {
edgeLayer.addEventListener("click", (e: Event) => {
const btn = (e.target as Element).closest("[data-edge-action]") as HTMLElement | null;
if (!btn) return;
e.stopPropagation();
const action = btn.dataset.edgeAction; // "inc" or "dec"
const fromId = btn.dataset.edgeFrom!;
const toId = btn.dataset.edgeTo!;
const allocType = btn.dataset.edgeType as "overflow" | "spending" | "source";
this.handleAdjustAllocation(fromId, toId, allocType, action === "inc" ? 5 : -5);
});
// Edge selection — click on edge path (not buttons)
// Edge selection — click on edge path
edgeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
const target = e.target as Element;
// Ignore clicks on +/- buttons or drag handle
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
@ -1601,7 +1635,6 @@ class FolkFlowsApp extends HTMLElement {
// Double-click edge → open source node editor
edgeLayer.addEventListener("dblclick", (e: Event) => {
const target = e.target as Element;
if (target.closest("[data-edge-action]")) return;
if (target.closest(".edge-drag-handle")) return;
const edgeGroup = target.closest(".edge-group") as SVGGElement | null;
@ -1834,26 +1867,17 @@ class FolkFlowsApp extends HTMLElement {
const nozzleBotW = 7; // half-width at end
const nozzlePath = `M ${nozzleStartX},${nozzleStartY - nozzleTopW} L ${nozzleEndX},${nozzleEndY - nozzleBotW} L ${nozzleEndX},${nozzleEndY + nozzleBotW} L ${nozzleStartX},${nozzleStartY + nozzleTopW} Z`;
// Stream: rect from nozzle tip downward, width proportional to sqrt(flowRate/100)
const streamW = Math.max(4, Math.round(Math.sqrt(d.flowRate / 100) * 2.5));
// Stream: rect from nozzle tip downward, width from Sankey pre-pass
const fw = this._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 streamX = nozzleEndX;
const streamY = nozzleEndY + nozzleBotW;
const streamH = h - streamY;
// Allocation bar
let allocBar = "";
if (d.targetAllocations && d.targetAllocations.length > 0) {
const barY = h - 8;
const barW = w - 40;
const barX = 20;
let cx = barX;
allocBar = d.targetAllocations.map(a => {
const segW = (a.percentage / 100) * barW;
const rect = `<rect x="${cx}" y="${barY}" width="${segW}" height="3" rx="1" fill="${a.color}" opacity="0.85"/>`;
cx += segW + 1;
return rect;
}).join("");
}
// Split control replaces old allocation bar
const allocBar = d.targetAllocations && d.targetAllocations.length >= 2
? this.renderSplitControl(n.id, "source", d.targetAllocations, w / 2, h - 8, w - 40)
: "";
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"/>
@ -1939,14 +1963,12 @@ class FolkFlowsApp extends HTMLElement {
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
let pipeH = basePipeH;
let pipeY = Math.round(maxLineY - basePipeH / 2);
let excessRatio = 0;
if (isOverflow && d.maxCapacity > d.maxThreshold) {
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeH = basePipeH + Math.round(excessRatio * 16);
pipeY = Math.round(maxLineY - pipeH / 2);
}
// Fixed pipe height — animate fill/opacity instead of resizing to prevent frame jumps
const pipeH = basePipeH;
const pipeY = Math.round(maxLineY - basePipeH / 2);
const excessRatio = isOverflow && d.maxCapacity > d.maxThreshold
? Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold))
: 0;
// Wall inset at pipe Y position for pipe attachment
const pipeYFrac = (maxLineY - zoneTop) / zoneH;
@ -1964,6 +1986,31 @@ class FolkFlowsApp extends HTMLElement {
rightWall.push(`${w - inset},${py}`);
}
// Compute interpolated wall insets at exact pipe boundaries to avoid path discontinuities
const pipeTopFrac = Math.max(0, (pipeY - zoneTop) / zoneH);
const pipeBotFrac = Math.min(1, (pipeY + pipeH - zoneTop) / zoneH);
const rightInsetAtPipeTop = this.vesselWallInset(pipeTopFrac, taperAtBottom);
const rightInsetAtPipeBot = this.vesselWallInset(pipeBotFrac, taperAtBottom);
// Right wall segments below pipe bottom
const rightWallBelow: string[] = [];
// Add interpolated point at exact pipe bottom
rightWallBelow.push(`${w - rightInsetAtPipeBot},${pipeY + pipeH}`);
for (let i = 0; i <= steps; i++) {
const py = zoneTop + zoneH * (i / steps);
if (py > pipeY + pipeH) rightWallBelow.push(rightWall[i]);
}
// Left wall segments below pipe bottom (reversed for upward traversal)
const leftWallBelow: string[] = [];
for (let i = 0; i <= steps; i++) {
const py = zoneTop + zoneH * (i / steps);
if (py > pipeY + pipeH) leftWallBelow.push(leftWall[i]);
}
// Add interpolated point at exact pipe bottom
leftWallBelow.push(`${this.vesselWallInset(pipeBotFrac, taperAtBottom)},${pipeY + pipeH}`);
leftWallBelow.reverse();
const vesselPath = [
`M ${r},0`,
`L ${w - r},0`,
@ -1972,12 +2019,8 @@ class FolkFlowsApp extends HTMLElement {
`L ${w},${pipeY}`,
`L ${w + pipeW},${pipeY}`,
`L ${w + pipeW},${pipeY + pipeH}`,
`L ${w},${pipeY + pipeH}`,
// Continue right wall tapering down
...rightWall.filter((_, i) => {
const py = zoneTop + zoneH * (i / steps);
return py > pipeY + pipeH;
}).map(p => `L ${p}`),
// Continue right wall tapering from interpolated pipe bottom point
...rightWallBelow.map(p => `L ${p}`),
// Bottom: narrow drain spout with rounded corners
`L ${w - taperAtBottom + r},${zoneBot}`,
`Q ${w - taperAtBottom},${zoneBot} ${w - taperAtBottom},${h - r}`,
@ -1985,13 +2028,9 @@ class FolkFlowsApp extends HTMLElement {
`L ${taperAtBottom},${h}`,
`L ${taperAtBottom},${h - r}`,
`Q ${taperAtBottom},${zoneBot} ${taperAtBottom + r},${zoneBot}`,
// Left wall tapering up
...leftWall.filter((_, i) => {
const py = zoneTop + zoneH * (i / steps);
return py > pipeY + pipeH;
}).reverse().map(p => `L ${p}`),
// Left wall tapering up from interpolated pipe bottom point
...leftWallBelow.map(p => `L ${p}`),
// Left pipe notch
`L 0,${pipeY + pipeH}`,
`L ${-pipeW},${pipeY + pipeH}`,
`L ${-pipeW},${pipeY}`,
`L 0,${pipeY}`,
@ -2036,6 +2075,15 @@ class FolkFlowsApp extends HTMLElement {
<ellipse class="overflow-spill-left" cx="${-pipeW - 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>
<ellipse class="overflow-spill-right" cx="${w + pipeW + 4}" cy="${pipeY + pipeH / 2}" rx="8" ry="6" fill="url(#overflow-splash)"/>` : "";
// Inflow pipe indicator (Sankey-consistent)
const fwFunnel = this._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="var(--rs-bg-surface-raised)" opacity="0.3"/>
<rect x="${inflowPipeX}" y="26" width="${Math.round(inflowPipeW * inflowFillRatio)}" height="6" rx="3" fill="var(--rflows-label-inflow)" opacity="0.7"/>` : "";
// Inflow satisfaction bar
const satBarY = 50;
const satBarW = w - 48;
@ -2074,6 +2122,7 @@ class FolkFlowsApp extends HTMLElement {
${shimmerLine}
${thresholdLines}
</g>
${inflowPipeIndicator}
<!-- Overflow pipes at max threshold -->
<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}"/>
@ -2088,28 +2137,41 @@ class FolkFlowsApp extends HTMLElement {
${this.formatDollar(outflow)}/mo
</text>
</g>
<!-- Spending split control at drain spout -->
${d.spendingAllocations.length >= 2
? this.renderSplitControl(n.id, "spending", d.spendingAllocations, w / 2, h + 26, Math.min(w - 20, drainW + 60))
: ""}
<!-- Overflow split control at pipe area -->
${d.overflowAllocations.length >= 2
? this.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="5"
style="fill:var(--rs-border-strong);cursor:ns-resize;stroke:var(--rs-text-muted);stroke-width:1"/>
<text x="${w / 2}" y="${h + 13}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none"> capacity</text>
</g>
<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:var(--rs-text-primary)">${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:var(--rs-text-secondary)">${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:${drainInset + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:var(--rs-text-muted)">$${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>` : ""}
<!-- Inflow label -->
<text x="${w / 2}" y="-8" text-anchor="middle" fill="#10b981" font-size="12" font-weight="500" opacity="0.9" pointer-events="none">\u2193 ${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:var(--rs-text-primary)">${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>
</foreignObject>
<!-- Satisfaction label -->
<text x="${w / 2}" y="${satBarY + 22}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="10" pointer-events="none">${satLabel}</text>
<!-- Zone labels (SVG text in clip group) -->
${criticalH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH + criticalH / 2 + 4}" text-anchor="middle" fill="#ef4444" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">CRITICAL</text>` : ""}
${sufficientH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH + sufficientH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">SUFFICIENT</text>` : ""}
${overflowH > 20 ? `<text x="${w / 2}" y="${zoneTop + overflowH / 2 + 4}" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600" opacity="0.5" pointer-events="none">OVERFLOW</text>` : ""}
<!-- Value text -->
<text class="funnel-value-text" data-node-id="${n.id}" x="${w / 2}" y="${h - drainInset - 44}" text-anchor="middle" fill="var(--rs-text-muted)" 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="#34d399" font-size="12" font-weight="600" pointer-events="none">${this.formatDollar(outflow)}/mo \u25BE</text>
<!-- Overflow labels at pipe positions -->
${isOverflow ? `<text x="${-pipeW - 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="end" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>
<text x="${w + pipeW + 6}" y="${pipeY + pipeH / 2 + 4}" text-anchor="start" fill="#6ee7b7" font-size="11" font-weight="500" opacity="0.8" pointer-events="none">${overflowLabel}</text>` : ""}
${this.renderPortsSvg(n)}
</g>`;
}
@ -2209,6 +2271,45 @@ class FolkFlowsApp extends HTMLElement {
return bar;
}
/** Render a proportional split control — draggable stacked bar showing allocation ratios */
private renderSplitControl(
nodeId: string, allocType: string,
allocs: { targetId: string; percentage: number; color: string }[],
cx: number, cy: number, trackW: number,
): string {
if (!allocs || allocs.length < 2) return "";
const trackH = 14;
const trackX = cx - trackW / 2;
const trackY = cy - trackH / 2;
let svg = `<g class="split-control" data-node-id="${nodeId}" data-alloc-type="${allocType}">`;
svg += `<rect class="split-track" x="${trackX}" y="${trackY}" width="${trackW}" height="${trackH}" rx="4" fill="var(--rs-bg-surface-raised)" opacity="0.5"/>`;
// Segments
let segX = trackX;
for (let i = 0; i < allocs.length; i++) {
const a = allocs[i];
const segW = trackW * (a.percentage / 100);
svg += `<rect class="split-seg" x="${segX}" y="${trackY}" width="${Math.max(segW, 2)}" height="${trackH}" ${i === 0 ? 'rx="4"' : ""} ${i === allocs.length - 1 ? 'rx="4"' : ""} fill="${a.color}" opacity="0.75"/>`;
segX += segW;
}
// Dividers between segments
let divX = trackX;
for (let i = 0; i < allocs.length - 1; i++) {
divX += trackW * (allocs[i].percentage / 100);
const leftPct = Math.round(allocs[i].percentage);
const rightPct = Math.round(allocs[i + 1].percentage);
svg += `<g class="split-divider" data-divider-idx="${i}" data-node-id="${nodeId}" data-alloc-type="${allocType}" style="cursor:ew-resize">
<rect x="${divX - 6}" y="${trackY - 3}" width="12" height="${trackH + 6}" rx="3" fill="var(--rs-bg-surface)" stroke="var(--rs-text-muted)" stroke-width="1" opacity="0.9"/>
<text x="${divX}" y="${trackY - 6}" text-anchor="middle" fill="var(--rs-text-secondary)" font-size="9" font-weight="600" pointer-events="none">${leftPct}% | ${rightPct}%</text>
</g>`;
}
svg += `</g>`;
return svg;
}
// ─── Edge rendering ───────────────────────────────────
private formatDollar(amount: number): string {
@ -2217,6 +2318,74 @@ class FolkFlowsApp extends HTMLElement {
return `$${Math.round(amount)}`;
}
/** Pre-pass: compute per-node flow totals and Sankey-consistent pixel widths */
private computeFlowWidths(): void {
const MIN_PX = 8, MAX_PX = 80;
const nodeFlows = new Map<string, { totalOutflow: number; totalInflow: number }>();
// Initialize all nodes
for (const n of this.nodes) nodeFlows.set(n.id, { totalOutflow: 0, totalInflow: 0 });
// Sum outflow/inflow per node (mirrors edge-building logic)
for (const n of this.nodes) {
if (n.type === "source") {
const d = n.data as SourceNodeData;
for (const alloc of d.targetAllocations) {
const flow = d.flowRate * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow;
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
}
}
if (n.type === "funnel") {
const d = n.data as FunnelNodeData;
const excess = Math.max(0, d.currentValue - d.maxThreshold);
for (const alloc of d.overflowAllocations) {
const flow = excess * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow;
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
}
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 drain = d.inflowRate * rateMultiplier;
for (const alloc of d.spendingAllocations) {
const flow = drain * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow;
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
}
}
if (n.type === "outcome") {
const d = n.data as OutcomeNodeData;
const excess = Math.max(0, d.fundingReceived - d.fundingTarget);
for (const alloc of (d.overflowAllocations || [])) {
const flow = excess * (alloc.percentage / 100);
nodeFlows.get(n.id)!.totalOutflow += flow;
if (nodeFlows.has(alloc.targetId)) nodeFlows.get(alloc.targetId)!.totalInflow += flow;
}
}
}
// Find global max outflow for scaling
let globalMaxFlow = 0;
for (const [, v] of nodeFlows) globalMaxFlow = Math.max(globalMaxFlow, v.totalOutflow);
if (globalMaxFlow === 0) globalMaxFlow = 1;
// Compute pixel widths
this._currentFlowWidths = new Map();
for (const n of this.nodes) {
const nf = nodeFlows.get(n.id)!;
const outflowWidthPx = nf.totalOutflow > 0 ? MIN_PX + (nf.totalOutflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX;
// Inflow: compute needed rate for funnels/outcomes
let neededInflow = 0;
if (n.type === "funnel") neededInflow = (n.data as FunnelNodeData).inflowRate || 1;
else if (n.type === "outcome") neededInflow = Math.max((n.data as OutcomeNodeData).fundingTarget, 1);
const inflowFillRatio = neededInflow > 0 ? Math.min(nf.totalInflow / neededInflow, 1) : 0;
const inflowWidthPx = nf.totalInflow > 0 ? MIN_PX + (nf.totalInflow / globalMaxFlow) * (MAX_PX - MIN_PX) : MIN_PX;
this._currentFlowWidths.set(n.id, { totalOutflow: nf.totalOutflow, totalInflow: nf.totalInflow, outflowWidthPx, inflowWidthPx, inflowFillRatio });
}
}
private renderAllEdges(): string {
// First pass: compute actual dollar flow per edge
interface EdgeInfo {
@ -2308,16 +2477,26 @@ class FolkFlowsApp extends HTMLElement {
}
}
// Second pass: render edges with flow-value-relative widths (Sankey-style)
const MAX_EDGE_W = 28;
// Pre-compute Sankey-consistent flow widths
this.computeFlowWidths();
// Second pass: render edges with per-node proportional widths (Sankey-consistent)
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.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W);
// Per-node proportional width: edge width = node's outflowWidthPx * (edgeFlow / totalOutflow)
const nodeWidths = this._currentFlowWidths.get(e.fromId);
let strokeW: number;
if (isGhost) {
strokeW = 1;
} else if (nodeWidths && nodeWidths.totalOutflow > 0) {
strokeW = Math.max(MIN_EDGE_W, nodeWidths.outflowWidthPx * (e.flowAmount / nodeWidths.totalOutflow));
} else {
strokeW = 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,
@ -2381,15 +2560,7 @@ class FolkFlowsApp extends HTMLElement {
<path d="${d}" fill="none" style="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" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.5"/>
<text x="-14" y="5" style="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" style="fill:var(--rs-bg-surface-raised)"/>
<text x="-24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</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" style="fill:var(--rs-bg-surface-raised)"/>
<text x="24" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
<text x="0" y="5" style="fill:${color}" font-size="11" font-weight="600" text-anchor="middle" opacity="0.5">${label}</text>
</g>
</g>`;
}
@ -2397,8 +2568,8 @@ class FolkFlowsApp extends HTMLElement {
const overflowMul = dashed ? 1.3 : 1;
const finalStrokeW = strokeW * overflowMul;
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);
// Label box — read-only, no +/- buttons (splits controlled at nodes)
const labelW = Math.max(68, label.length * 7 + 12);
const halfW = labelW / 2;
// Drag handle at midpoint
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
@ -2413,14 +2584,6 @@ class FolkFlowsApp extends HTMLElement {
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
<text x="0" y="5" style="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">
<rect x="${-halfW + 2}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${-halfW + 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">&minus;</text>
</g>
<g data-edge-action="inc" data-edge-from="${fromId}" data-edge-to="${toId}" data-edge-type="${edgeType}" style="cursor:pointer">
<rect x="${halfW - 18}" y="-9" width="16" height="18" rx="3" style="fill:var(--rs-bg-surface-raised)"/>
<text x="${halfW - 10}" y="5" style="fill:var(--rs-text-primary)" font-size="13" text-anchor="middle">+</text>
</g>
</g>
</g>`;
}
@ -2853,6 +3016,111 @@ class FolkFlowsApp extends HTMLElement {
// ─── Allocation adjustment ────────────────────────────
/** Get the allocation array for a node + allocType combo */
private getSplitAllocs(nodeId: string, allocType: string): { targetId: string; percentage: number; color: string }[] | null {
const node = this.nodes.find(n => n.id === nodeId);
if (!node) return null;
if (allocType === "source" && node.type === "source") return (node.data as SourceNodeData).targetAllocations;
if (allocType === "spending" && node.type === "funnel") return (node.data as FunnelNodeData).spendingAllocations;
if (allocType === "overflow") {
if (node.type === "funnel") return (node.data as FunnelNodeData).overflowAllocations;
if (node.type === "outcome") return (node.data as OutcomeNodeData).overflowAllocations || [];
}
return null;
}
/** Handle split divider drag — redistribute percentages between adjacent segments */
private handleSplitDragMove(clientX: number) {
if (!this._splitDragging || !this._splitDragNodeId) return;
const allocs = this.getSplitAllocs(this._splitDragNodeId, this._splitDragAllocType!);
if (!allocs || allocs.length < 2) return;
const idx = this._splitDragDividerIdx;
const startPcts = this._splitDragStartPcts;
const MIN_PCT = 5;
// Compute delta as percentage of track width (approximate from zoom-adjusted pixels)
const deltaX = clientX - this._splitDragStartX;
const trackW = 200; // approximate track width in pixels
const deltaPct = (deltaX / (trackW * this.canvasZoom)) * 100;
// Redistribute between segment[idx] and segment[idx+1]
const leftOrig = startPcts[idx];
const rightOrig = startPcts[idx + 1];
const combined = leftOrig + rightOrig;
let newLeft = Math.round(leftOrig + deltaPct);
newLeft = Math.max(MIN_PCT, Math.min(combined - MIN_PCT, newLeft));
const newRight = combined - newLeft;
allocs[idx].percentage = newLeft;
allocs[idx + 1].percentage = newRight;
// Normalize to exactly 100
const total = allocs.reduce((s, a) => s + a.percentage, 0);
if (total !== 100 && allocs.length > 0) {
allocs[allocs.length - 1].percentage += 100 - total;
allocs[allocs.length - 1].percentage = Math.max(MIN_PCT, allocs[allocs.length - 1].percentage);
}
// 60fps visual update: patch split control SVG in-place
this.updateSplitControlVisual(this._splitDragNodeId, this._splitDragAllocType!);
this.redrawEdges();
}
private handleSplitDragEnd() {
if (!this._splitDragging) return;
const nodeId = this._splitDragNodeId;
this._splitDragging = false;
this._splitDragNodeId = null;
this._splitDragAllocType = null;
this._splitDragStartPcts = [];
if (nodeId) {
this.refreshEditorIfOpen(nodeId);
this.scheduleSave();
}
}
/** Patch split control segment widths and divider positions without full re-render */
private updateSplitControlVisual(nodeId: string, allocType: string) {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
const ctrl = nodeLayer.querySelector(`.split-control[data-node-id="${nodeId}"][data-alloc-type="${allocType}"]`) as SVGGElement | null;
if (!ctrl) return;
const allocs = this.getSplitAllocs(nodeId, allocType);
if (!allocs) return;
const track = ctrl.querySelector(".split-track") as SVGRectElement | null;
if (!track) return;
const trackX = parseFloat(track.getAttribute("x")!);
const trackW = parseFloat(track.getAttribute("width")!);
// Update segment rects
const segs = ctrl.querySelectorAll(".split-seg");
let segX = trackX;
segs.forEach((seg, i) => {
if (i >= allocs.length) return;
const segW = trackW * (allocs[i].percentage / 100);
(seg as SVGRectElement).setAttribute("x", String(segX));
(seg as SVGRectElement).setAttribute("width", String(Math.max(segW, 2)));
segX += segW;
});
// Update divider positions and labels
const dividers = ctrl.querySelectorAll(".split-divider");
let divX = trackX;
dividers.forEach((div, i) => {
if (i >= allocs.length - 1) return;
divX += trackW * (allocs[i].percentage / 100);
const rect = div.querySelector("rect");
const text = div.querySelector("text");
if (rect) rect.setAttribute("x", String(divX - 6));
if (text) {
text.setAttribute("x", String(divX));
text.textContent = `${Math.round(allocs[i].percentage)}% | ${Math.round(allocs[i + 1].percentage)}%`;
}
});
}
private handleAdjustAllocation(fromId: string, toId: string, allocType: string, delta: number) {
const node = this.nodes.find((n) => n.id === fromId);
if (!node) return;

View File

@ -90,7 +90,9 @@ interface YieldRate {
assetAddress: string;
vaultAddress: string;
apy: number;
apyBase?: number;
apy7d?: number;
apy30d?: number;
tvl?: number;
vaultName?: string;
}
@ -214,23 +216,29 @@ class FolkWalletViewer extends HTMLElement {
this.address = params.get("address") || "";
this.checkAuthState();
// If address in URL, show visualizer regardless of auth
if (this.address) {
// Yield view is standalone — always force visualizer tab
if (this.activeView === "yield") {
this.topTab = "visualizer";
}
// For visualizer tab: auto-load address or demo
if (this.topTab === "visualizer" && !this.address) {
if (this.passKeyEOA) {
this.address = this.passKeyEOA;
} else {
this.address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
this.render();
this.loadYieldData();
} else {
// If address in URL, show visualizer regardless of auth
if (this.address) {
this.topTab = "visualizer";
}
}
this.render();
if (this.topTab === "visualizer" && this.address) this.detectChains();
if (this.activeView === "yield") this.loadYieldData();
// For visualizer tab: auto-load address or demo
if (this.topTab === "visualizer" && !this.address) {
if (this.passKeyEOA) {
this.address = this.passKeyEOA;
} else {
this.address = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
}
}
this.render();
if (this.topTab === "visualizer" && this.address) this.detectChains();
}
}
if (!localStorage.getItem("rwallet_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
@ -1433,6 +1441,12 @@ class FolkWalletViewer extends HTMLElement {
.yield-header-desc {
font-size: 13px; color: var(--rs-text-secondary); margin: 0; line-height: 1.5; max-width: 560px;
}
.yield-back-link {
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--rs-border);
color: var(--rs-text-secondary); text-decoration: none; font-size: 13px; font-weight: 500;
white-space: nowrap; transition: all 0.2s;
}
.yield-back-link:hover { border-color: var(--rs-accent); color: var(--rs-accent); }
/* ── Yield tab ── */
.yield-summary {
@ -1497,6 +1511,22 @@ class FolkWalletViewer extends HTMLElement {
.yield-action-btn:hover { background: rgba(20,184,166,0.1); }
.yield-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Yield rates table ── */
.yield-rates-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.yield-rates-table th {
text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--rs-border);
color: var(--rs-text-secondary); font-size: 11px; text-transform: uppercase;
}
.yield-rates-table td {
padding: 12px 8px; border-bottom: 1px solid var(--rs-border-subtle);
vertical-align: middle;
}
.yield-rates-table tr:hover td { background: var(--rs-bg-hover); }
.yield-rates-table .col-protocol { width: 22%; }
.yield-rates-table .col-chain { width: 14%; }
.yield-rates-table .col-asset { width: 12%; }
.yield-rates-table .col-num { width: 17%; text-align: right; font-family: monospace; }
@media (max-width: 768px) {
.hero-title { font-size: 22px; }
.balance-table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
@ -1924,6 +1954,8 @@ class FolkWalletViewer extends HTMLElement {
}
private renderYieldStandaloneHeader(): string {
// Derive the wallets link from current URL
const basePath = window.location.pathname.replace(/\/yield\/?$/, "");
return `
<div class="yield-header">
<div class="yield-header-row">
@ -1931,7 +1963,7 @@ class FolkWalletViewer extends HTMLElement {
<h2 class="yield-header-title">Stablecoin Yield</h2>
<p class="yield-header-desc">Compare APY rates across Aave V3 and Morpho Blue vaults on Ethereum and Base. Deposit idle USDC, USDT, or DAI through your Safe multisig.</p>
</div>
<button class="view-tab" data-view="balances" style="white-space:nowrap">Back to Balances</button>
<a href="${basePath}/wallets" class="yield-back-link">Back to Wallets</a>
</div>
</div>`;
}
@ -2029,28 +2061,46 @@ class FolkWalletViewer extends HTMLElement {
// ── Rates table ──
if (this.yieldRates.length > 0) {
// Deduplicate: keep highest-APY entry per protocol+chain+asset
const deduped = new Map<string, YieldRate>();
const sorted = [...this.yieldRates].sort((a, b) => b.apy - a.apy);
for (const r of sorted) {
const key = `${r.protocol}:${r.chainId}:${r.asset}`;
if (!deduped.has(key)) deduped.set(key, r);
}
const rates = [...deduped.values()];
html += `<div class="yield-section">
<h3 class="yield-section-title">Available Rates</h3>
<table class="balance-table">
<table class="yield-rates-table">
<colgroup>
<col class="col-protocol">
<col class="col-chain">
<col class="col-asset">
<col class="col-num">
<col class="col-num">
<col class="col-num">
</colgroup>
<thead><tr>
<th>Protocol</th>
<th>Chain</th>
<th>Asset</th>
<th class="amount-cell">APY</th>
<th class="amount-cell">7d Avg</th>
<th class="amount-cell">TVL</th>
<th class="col-num">APY</th>
<th class="col-num">30d Avg</th>
<th class="col-num">TVL</th>
</tr></thead>
<tbody>`;
const sorted = [...this.yieldRates].sort((a, b) => b.apy - a.apy);
for (const r of sorted) {
for (const r of rates) {
const protocolLabel = r.protocol === "aave-v3" ? "Aave V3" : (r.vaultName || "Morpho");
const chainColor = r.chainId === "1" ? "#627eea" : "#0052ff";
const apyColor = r.apy >= 3 ? "var(--rs-success)" : r.apy >= 1.5 ? "#ffa726" : "var(--rs-text-secondary)";
html += `<tr>
<td><span class="yield-protocol-badge ${r.protocol}">${protocolLabel}</span></td>
<td>${chainNames[r.chainId] || r.chainId}</td>
<td><span class="yield-chain-badge" style="background:${chainColor}22;color:${chainColor}">${chainNames[r.chainId] || r.chainId}</span></td>
<td><span class="token-symbol">${this.esc(r.asset)}</span></td>
<td class="amount-cell" style="color:var(--rs-success);font-weight:600">${r.apy.toFixed(2)}%</td>
<td class="amount-cell">${r.apy7d ? r.apy7d.toFixed(2) + "%" : "-"}</td>
<td class="amount-cell">${r.tvl ? "$" + (r.tvl / 1e6).toFixed(1) + "M" : "-"}</td>
<td class="col-num" style="color:${apyColor};font-weight:700">${r.apy.toFixed(2)}%</td>
<td class="col-num" style="color:var(--rs-text-secondary)">${r.apy30d != null ? r.apy30d.toFixed(2) + "%" : "-"}</td>
<td class="col-num" style="color:var(--rs-text-secondary)">${r.tvl ? "$" + (r.tvl >= 1e9 ? (r.tvl / 1e9).toFixed(1) + "B" : (r.tvl / 1e6).toFixed(0) + "M") : "-"}</td>
</tr>`;
}
html += `</tbody></table></div>`;
@ -2169,6 +2219,11 @@ class FolkWalletViewer extends HTMLElement {
}
private renderVisualizerTab(): string {
// Yield view is standalone — skip wallet UI entirely
if (this.activeView === "yield") {
return `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`;
}
return `
${this.renderHero()}
@ -2191,15 +2246,14 @@ class FolkWalletViewer extends HTMLElement {
` : ""}
</div>
${this.activeView !== "yield" ? this.renderSupportedChains() : ""}
${this.renderSupportedChains()}
${this.error ? `<div class="error">${this.esc(this.error)}</div>` : ""}
${this.loading ? '<div class="loading"><span class="spinner"></span> Detecting wallet across chains...</div>' : ""}
${this.activeView === "yield"
? `${this.renderYieldStandaloneHeader()}${this.renderYieldTab()}`
: `${this.renderFeatures()}${this.renderExamples()}${this.renderDashboard()}`
}
${this.renderFeatures()}
${this.renderExamples()}
${this.renderDashboard()}
`;
}
@ -2311,11 +2365,12 @@ class FolkWalletViewer extends HTMLElement {
private render() {
const isMyWallets = this.topTab === "my-wallets" && this.isAuthenticated;
const isYield = this.activeView === "yield";
this.shadow.innerHTML = `
${this.renderStyles()}
${this.isAuthenticated ? this.renderTopTabBar() : ''}
${isMyWallets ? this.renderMyWalletsTab() : this.renderVisualizerTab()}
${this.isAuthenticated && !isYield ? this.renderTopTabBar() : ''}
${isMyWallets && !isYield ? this.renderMyWalletsTab() : this.renderVisualizerTab()}
`;
// Top tab listeners

View File

@ -16,6 +16,8 @@ export interface YieldOpportunity {
vaultAddress: string; // aToken for Aave, vault for Morpho
apy: number;
apy7d?: number;
apy30d?: number;
apyBase?: number;
tvl?: number;
poolId?: string; // DeFi Llama pool ID
vaultName?: string;

View File

@ -63,7 +63,10 @@ interface LlamaPool {
symbol: string;
tvlUsd: number;
apy: number;
apyMean7d?: number;
apyBase?: number;
apyReward?: number | null;
apyPct7D?: number;
apyMean30d?: number;
underlyingTokens?: string[];
}
@ -119,7 +122,9 @@ async function fetchDefiLlamaRates(): Promise<YieldOpportunity[]> {
assetAddress,
vaultAddress,
apy: pool.apy || 0,
apy7d: pool.apyMean7d,
apyBase: pool.apyBase ?? undefined,
apy7d: pool.apyPct7D != null ? pool.apy + pool.apyPct7D : undefined,
apy30d: pool.apyMean30d ?? undefined,
tvl: pool.tvlUsd,
poolId: pool.pool,
vaultName: protocol === "morpho-blue"

View File

@ -1014,7 +1014,7 @@ function renderWallet(spaceSlug: string, initialView?: string) {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-wallet-viewer${viewAttr}></folk-wallet-viewer>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=9"></script>`,
scripts: `<script type="module" src="/modules/rwallet/folk-wallet-viewer.js?v=12"></script>`,
styles: `<link rel="stylesheet" href="/modules/rwallet/wallet.css">`,
});
}