feat: overhaul rFlows funnel visuals — bigger nodes, 3 zones, draggable config

- Increase funnel base size (280×250), source nodes (140-280 dynamic),
  outcome nodes (220×120) for better visibility
- Simplify to 2 threshold lines (Min/Max) and 3 labeled zones:
  Critical (below min), Sufficient (min-max), Overflow (above max)
- Position overflow pipes at max threshold line instead of fixed 55%
- Make drain width proportional to desiredOutflow (physical metaphor)
- Replace popup config panel with draggable handles for funnels:
  valve handle (horizontal drag → outflow rate) and height handle
  (vertical drag → capacity)
- Increase edge stroke widths (3-28px) for more visible flow changes
- Source nodes: tall/thin for small recurring, short/thick for large chunks
- Keep config panel for source/outcome node types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-05 16:58:45 -08:00
parent ff5dd8c006
commit 3f19fd9c8e
2 changed files with 232 additions and 130 deletions

View File

@ -1029,15 +1029,24 @@ class FolkFlowsApp 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: 70 }; 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 };
}
if (n.type === "funnel") { if (n.type === "funnel") {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const baseW = 200, baseH = 180; const baseW = 280, baseH = 250;
const scaleRef = d.desiredOutflow || d.inflowRate; // Height scales with capacity (draggable), width stays fixed
const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3; const hRef = d.maxCapacity || 9000;
return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) }; 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: 200, h: 110 }; // outcome (basin) return { w: 220, h: 120 }; // outcome (basin)
} }
// ─── Canvas event wiring ────────────────────────────── // ─── Canvas event wiring ──────────────────────────────
@ -1571,17 +1580,20 @@ class FolkFlowsApp extends HTMLElement {
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string { private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
const d = n.data as SourceNodeData; const d = n.data as SourceNodeData;
const x = n.position.x, y = n.position.y, w = 200, h = 70; const s = this.getNodeSize(n);
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 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 icon = icons[d.sourceType] || "\u{1F4B0}";
const stubW = 24, stubH = 20; const bodyH = h - 20;
const stubW = 26, stubH = 20;
const isSmall = d.flowRate < 1000;
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="50" rx="12" style="fill:var(--rflows-source-bg)" stroke="${selected ? "var(--rflows-selected)" : "var(--rflows-source-border)"}" stroke-width="${selected ? 3 : 2}"/> <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="50" 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"/> <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="14" y="24" style="fill:var(--rs-text-primary)" font-size="15">${icon}</text> <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="36" y="24" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</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="44" text-anchor="middle" style="fill:var(--rflows-source-rate)" font-size="11">$${d.flowRate.toLocaleString()}/mo</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, 48)} ${this.renderAllocBar(d.targetAllocations, w, bodyH - 4)}
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
@ -1590,34 +1602,51 @@ class FolkFlowsApp extends HTMLElement {
const d = n.data as FunnelNodeData; const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n); const s = this.getNodeSize(n);
const x = n.position.x, y = n.position.y, w = s.w, h = s.h; const x = n.position.x, y = n.position.y, w = s.w, h = s.h;
const threshold = d.sufficientThreshold ?? d.maxThreshold;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1)); const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
const isOverflow = d.currentValue > d.maxThreshold; const isOverflow = d.currentValue > d.maxThreshold;
const isCritical = d.currentValue < d.minThreshold; const isCritical = d.currentValue < d.minThreshold;
const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)"; const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
const fillColor = borderColorVar; const fillColor = borderColorVar;
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained"; const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient";
// Tank shape parameters // Tank shape parameters
const r = 10; const r = 12;
const pipeW = 24; // overflow pipe extension from wall const pipeW = 30; // overflow pipe extension from wall
const basePipeH = 20; // base pipe height const basePipeH = 24; // base pipe height
const pipeYFrac = 0.55; // pipe center at ~55% down const taperStart = 0.80; // body tapers at 80% down
const taperStart = 0.75; // body tapers at 75% down // Drain width proportional to outflow: wider drain = more outflow
const taperInset = 0.2; const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000);
const taperInset = 0.30 - outflowRatio * 0.18; // 0.30 (narrow/$0) → 0.12 (wide/$3000)
const insetPx = Math.round(w * taperInset); const insetPx = Math.round(w * taperInset);
const taperY = Math.round(h * taperStart); const taperY = Math.round(h * taperStart);
const clipId = `funnel-clip-${n.id}`; const clipId = `funnel-clip-${n.id}`;
// Dynamic pipe sizing for overflow // Interior zone boundaries
const zoneTop = 36;
const zoneBot = h - 6;
const zoneH = zoneBot - zoneTop;
// Zone fractions for 3 zones: Critical (below min), Sufficient (min-max), Overflow (above max)
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const criticalPct = minFrac;
const sufficientPct = maxFrac - minFrac;
const overflowPct = Math.max(0, 1 - maxFrac);
const criticalH = zoneH * criticalPct;
const sufficientH = zoneH * sufficientPct;
const overflowH = zoneH * overflowPct;
// Pipe position at max threshold line
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
let pipeH = basePipeH; let pipeH = basePipeH;
let pipeY = Math.round(h * pipeYFrac) - basePipeH / 2; let pipeY = Math.round(maxLineY - basePipeH / 2);
let excessRatio = 0; let excessRatio = 0;
if (isOverflow && d.maxCapacity > d.maxThreshold) { if (isOverflow && d.maxCapacity > d.maxThreshold) {
excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); excessRatio = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
pipeH = basePipeH + excessRatio * 16; pipeH = basePipeH + Math.round(excessRatio * 20);
pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8; pipeY = Math.round(maxLineY - pipeH / 2);
} }
// Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom // Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom
@ -1644,49 +1673,39 @@ class FolkFlowsApp extends HTMLElement {
`Z`, `Z`,
].join(" "); ].join(" ");
// Interior fill zones
const zoneTop = 28;
const zoneBot = h - 4;
const zoneH = zoneBot - zoneTop;
const drainPct = d.minThreshold / (d.maxCapacity || 1);
const healthyPct = (d.maxThreshold - d.minThreshold) / (d.maxCapacity || 1);
const overflowPct = Math.max(0, 1 - drainPct - healthyPct);
const drainH = zoneH * drainPct;
const healthyH = zoneH * healthyPct;
const overflowH = zoneH * overflowPct;
// Fill level // Fill level
const totalFillH = zoneH * fillPct; const totalFillH = zoneH * fillPct;
const fillY = zoneTop + zoneH - totalFillH; const fillY = zoneTop + zoneH - totalFillH;
// Threshold lines (always visible) // Threshold lines: only min and max (2 lines, 3 zones)
const minFrac = d.minThreshold / (d.maxCapacity || 1);
const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1);
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
const minLineY = zoneTop + zoneH * (1 - minFrac); const minLineY = zoneTop + zoneH * (1 - minFrac);
const sufLineY = zoneTop + zoneH * (1 - sufFrac);
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
const thresholdLines = ` const thresholdLines = `
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/> <line class="threshold-line" x1="6" x2="${w - 6}" y1="${minLineY}" y2="${minLineY}" stroke="var(--rflows-status-critical)" stroke-width="2" stroke-dasharray="6 4" opacity="0.8"/>
<text x="8" y="${minLineY - 3}" style="fill:var(--rflows-status-critical)" font-size="8" opacity="0.8">Min</text> <text x="10" y="${minLineY - 5}" style="fill:var(--rflows-status-critical)" font-size="10" font-weight="500" opacity="0.9">Min</text>
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${sufLineY}" y2="${sufLineY}" stroke="var(--rflows-status-thriving)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/> <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="8" y="${sufLineY - 3}" style="fill:var(--rflows-status-thriving)" font-size="8" opacity="0.8">Suf</text> <text x="10" y="${maxLineY - 5}" style="fill:var(--rflows-status-overflow)" font-size="10" font-weight="500" opacity="0.9">Max</text>`;
<line class="threshold-line" x1="4" x2="${w - 4}" y1="${maxLineY}" y2="${maxLineY}" stroke="var(--rflows-status-sustained)" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.7"/>
<text x="8" y="${maxLineY - 3}" style="fill:var(--rflows-status-sustained)" font-size="8" opacity="0.8">Overflow</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 // Inflow satisfaction bar
const satBarY = 40; const satBarY = 50;
const satBarW = w - 40; const satBarW = w - 48;
const satRatio = sat ? Math.min(sat.ratio, 1) : 0; const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
const satOverflow = sat ? sat.ratio > 1 : false; const satOverflow = sat ? sat.ratio > 1 : false;
const satFillW = satBarW * satRatio; const satFillW = satBarW * satRatio;
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : ""; const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : ""; const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : "";
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))" const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))"
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : ""; : !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
// Rate labels // Rate labels
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`; const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
@ -1704,36 +1723,37 @@ class FolkFlowsApp extends HTMLElement {
<defs> <defs>
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath> <clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
</defs> </defs>
${isOverflow ? `<path d="${tankPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""} ${isOverflow ? `<path d="${tankPath}" fill="none" stroke="var(--rflows-status-overflow)" stroke-width="2.5" opacity="0.4" transform="translate(-2,-2) scale(${(w + 4) / w},${(h + 4) / h})"/>` : ""}
<path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3 : 2}"/> <path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3.5 : 2.5}"/>
<g clip-path="url(#${clipId})"> <g clip-path="url(#${clipId})">
<rect x="${-pipeW}" y="${zoneTop + overflowH + healthyH}" width="${w + pipeW * 2}" height="${drainH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/> <rect x="${-pipeW}" y="${zoneTop + overflowH + sufficientH}" width="${w + pipeW * 2}" height="${criticalH}" style="fill:var(--rflows-zone-drain);opacity:var(--rflows-zone-drain-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${healthyH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/> <rect x="${-pipeW}" y="${zoneTop + overflowH}" width="${w + pipeW * 2}" height="${sufficientH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-opacity)"/>
<rect x="${-pipeW}" y="${zoneTop}" width="${w + pipeW * 2}" height="${overflowH}" style="fill:var(--rflows-zone-overflow);opacity:var(--rflows-zone-overflow-opacity)"/> <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)"/> <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} ${thresholdLines}
${zoneLabels}
</g> </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="3" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 0.3}"/> <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="3" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 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="-8" text-anchor="middle" style="fill:var(--rflows-label-inflow)" font-size="10" font-weight="500" opacity="0.8">${inflowLabel}</text> <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="16" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" font-weight="600">${this.esc(d.label)}</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 - 10}" y="16" text-anchor="end" style="fill:${borderColorVar}" font-size="10" font-weight="500" class="${!isCritical ? "sufficiency-glow" : ""}">${statusLabel}</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="20" y="${satBarY}" width="${satBarW}" height="6" rx="3" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/> <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="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/> <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 + 16}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${satLabel}</text> <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 - 8}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="11">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}</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 x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/> <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"/>
<rect x="${insetPx + 4}" y="${h - 10}" width="${(w - insetPx * 2 - 8) * fillPct}" height="4" rx="2" style="fill:${fillColor}"/> <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>
<text x="${w / 2}" y="${h + 16}" text-anchor="middle" style="fill:var(--rflows-label-spending)" font-size="10" font-weight="500" opacity="0.8">${spendingLabel}</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>
${isOverflow ? `<text x="${-pipeW - 4}" y="${pipeY + pipeH / 2 + 3}" text-anchor="end" style="fill:var(--rflows-label-overflow)" font-size="9" opacity="0.7">${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>` : ""}
<text x="${w + pipeW + 4}" y="${pipeY + pipeH / 2 + 3}" text-anchor="start" style="fill:var(--rflows-label-overflow)" font-size="9" opacity="0.7">${overflowLabel}</text>` : ""}
${this.renderPortsSvg(n)} ${this.renderPortsSvg(n)}
</g>`; </g>`;
} }
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): 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 = 110; 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 fillPct = d.fundingTarget > 0 ? Math.min(1, d.fundingReceived / d.fundingTarget) : 0;
const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)" const statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)"
: d.status === "blocked" ? "var(--rflows-status-blocked)" : d.status === "blocked" ? "var(--rflows-status-blocked)"
@ -1902,8 +1922,8 @@ class FolkFlowsApp extends HTMLElement {
} }
// Second pass: render edges with percentage-proportional widths // Second pass: render edges with percentage-proportional widths
const MAX_EDGE_W = 16; const MAX_EDGE_W = 28;
const MIN_EDGE_W = 1.5; const MIN_EDGE_W = 3;
let html = ""; let html = "";
for (const e of edges) { for (const e of edges) {
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide); const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
@ -2124,18 +2144,14 @@ class FolkFlowsApp extends HTMLElement {
} }
if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 }; if (!def) return { x: node.position.x + s.w / 2, y: node.position.y + s.h / 2 };
// Dynamic overflow port Y for funnels — match pipe position // Dynamic overflow port Y for funnels — match pipe at max threshold line
if (node.type === "funnel" && portKind === "overflow" && def.side) { if (node.type === "funnel" && portKind === "overflow" && def.side) {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const h = s.h; const h = s.h;
const basePipeH = 20; const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
let pipeY = Math.round(h * 0.55) - basePipeH / 2; const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) { const maxLineY = zoneTop + zoneH * (1 - maxFrac);
const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold)); return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY };
pipeY = Math.round(h * 0.55) - basePipeH / 2 - er * 8;
}
const pipeMidY = pipeY + basePipeH / 2;
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + pipeMidY };
} }
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac }; return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
@ -2439,12 +2455,43 @@ class FolkFlowsApp extends HTMLElement {
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g"); const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
overlay.classList.add("inline-edit-overlay"); overlay.classList.add("inline-edit-overlay");
// For funnels, render threshold drag markers on the node body // Funnels: drag handles instead of config panel
if (node.type === "funnel") { if (node.type === "funnel") {
this.renderFunnelThresholdMarkers(overlay, node, s); const d = node.data as FunnelNodeData;
const outflow = d.desiredOutflow || 0;
const outflowRatio = Math.min(1, outflow / 3000);
const valveInset = 0.30 - outflowRatio * 0.18;
const valveInsetPx = Math.round(s.w * valveInset);
const drainWidth = s.w - 2 * valveInsetPx;
overlay.innerHTML = `
<rect class="valve-drag-handle" x="${valveInsetPx - 8}" y="${s.h - 16}" width="${drainWidth + 16}" height="18" rx="5"
style="fill:var(--rflows-label-spending);cursor:ew-resize;opacity:0.85;stroke:white;stroke-width:1.5"/>
<text class="valve-drag-label" x="${s.w / 2}" y="${s.h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
${this.formatDollar(outflow)}/mo
</text>
<rect class="height-drag-handle" x="${s.w / 2 - 28}" y="${s.h + 22}" width="56" height="12" rx="5"
style="fill:var(--rs-border-strong);cursor:ns-resize;opacity:0.6;stroke:var(--rs-text-muted);stroke-width:1"/>
<text class="height-drag-label" x="${s.w / 2}" y="${s.h + 31}" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9" font-weight="500" pointer-events="none"> capacity</text>`;
g.appendChild(overlay);
this.attachFunnelDragListeners(overlay, node, s);
// Click-outside handler
const clickOutsideHandler = (e: Event) => {
const target = e.target as Element;
if (!target.closest(`[data-node-id="${node.id}"]`)) {
this.exitInlineEdit();
this.shadow.removeEventListener("pointerdown", clickOutsideHandler, true);
}
};
setTimeout(() => {
this.shadow.addEventListener("pointerdown", clickOutsideHandler, true);
}, 100);
return;
} }
// Panel positioned beside the node (right side) // Source/outcome: keep config panel
const panelW = 280; const panelW = 280;
const panelH = 260; const panelH = 260;
const panelX = s.w + 12; const panelX = s.w + 12;
@ -2635,18 +2682,107 @@ class FolkFlowsApp extends HTMLElement {
return html || '<div class="icp-empty">No allocations configured</div>'; return html || '<div class="icp-empty">No allocations configured</div>';
} }
// ── Funnel drag handles (valve width + tank height) ──
private attachFunnelDragListeners(overlay: Element, node: FlowNode, s: { w: number; h: number }) {
const valveHandle = overlay.querySelector(".valve-drag-handle");
const heightHandle = overlay.querySelector(".height-drag-handle");
// Valve drag (horizontal → desiredOutflow)
if (valveHandle) {
valveHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const startX = pe.clientX;
const fd = node.data as FunnelNodeData;
const startOutflow = fd.desiredOutflow || 0;
(valveHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => {
const me = ev as PointerEvent;
const deltaX = (me.clientX - startX) / this.canvasZoom;
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 3000) / 50) * 50;
newOutflow = Math.max(0, Math.min(3000, newOutflow));
fd.desiredOutflow = newOutflow;
fd.minThreshold = newOutflow;
fd.maxThreshold = newOutflow * 6;
if (fd.maxCapacity < fd.maxThreshold * 1.5) {
fd.maxCapacity = Math.round(fd.maxThreshold * 1.5);
}
// Update label text only during drag
const label = overlay.querySelector(".valve-drag-label");
if (label) label.textContent = `${this.formatDollar(newOutflow)}/mo ▷`;
};
const onUp = () => {
valveHandle.removeEventListener("pointermove", onMove);
valveHandle.removeEventListener("pointerup", onUp);
valveHandle.removeEventListener("lostpointercapture", onUp);
// Full redraw with new shape
this.drawCanvasContent();
this.redrawEdges();
this.enterInlineEdit(node.id);
this.scheduleSave();
};
valveHandle.addEventListener("pointermove", onMove);
valveHandle.addEventListener("pointerup", onUp);
valveHandle.addEventListener("lostpointercapture", onUp);
});
}
// Height drag (vertical → maxCapacity)
if (heightHandle) {
heightHandle.addEventListener("pointerdown", (e: Event) => {
const pe = e as PointerEvent;
pe.stopPropagation();
pe.preventDefault();
const startY = pe.clientY;
const fd = node.data as FunnelNodeData;
const startCapacity = fd.maxCapacity || 9000;
(heightHandle as Element).setPointerCapture(pe.pointerId);
const onMove = (ev: Event) => {
const me = ev as PointerEvent;
const deltaY = (me.clientY - startY) / this.canvasZoom;
// Down = more capacity, up = less
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(50000, newCapacity));
fd.maxCapacity = newCapacity;
// Update label
const label = overlay.querySelector(".height-drag-label");
if (label) label.textContent = `${this.formatDollar(newCapacity)}`;
};
const onUp = () => {
heightHandle.removeEventListener("pointermove", onMove);
heightHandle.removeEventListener("pointerup", onUp);
heightHandle.removeEventListener("lostpointercapture", onUp);
this.drawCanvasContent();
this.redrawEdges();
this.enterInlineEdit(node.id);
this.scheduleSave();
};
heightHandle.addEventListener("pointermove", onMove);
heightHandle.addEventListener("pointerup", onUp);
heightHandle.addEventListener("lostpointercapture", onUp);
});
}
}
// ── Funnel threshold markers (SVG on node body) ── // ── Funnel threshold markers (SVG on node body) ──
private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) { private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const zoneTop = 28; const zoneTop = 36;
const zoneBot = s.h - 4; const zoneBot = s.h - 6;
const zoneH = zoneBot - zoneTop; const zoneH = zoneBot - zoneTop;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [ const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" }, { key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
{ key: "sufficientThreshold", value: d.sufficientThreshold ?? d.maxThreshold, color: "var(--rflows-status-thriving)", label: "Suf" }, { key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-sustained)", label: "Max" },
]; ];
for (const t of thresholds) { for (const t of thresholds) {
@ -2815,7 +2951,7 @@ class FolkFlowsApp extends HTMLElement {
const pe = e as PointerEvent; const pe = e as PointerEvent;
const d = node.data as FunnelNodeData; const d = node.data as FunnelNodeData;
const s = this.getNodeSize(node); const s = this.getNodeSize(node);
const zoneH = s.h - 4 - 28; const zoneH = s.h - 6 - 36;
const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom; const deltaY = (pe.clientY - this.inlineEditDragStartY) / this.canvasZoom;
const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1); const deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
let newVal = this.inlineEditDragStartValue + deltaDollars; let newVal = this.inlineEditDragStartValue + deltaDollars;
@ -2877,42 +3013,8 @@ class FolkFlowsApp extends HTMLElement {
private redrawNodeInlineEdit(node: FlowNode) { private redrawNodeInlineEdit(node: FlowNode) {
this.drawCanvasContent(); this.drawCanvasContent();
const nodeLayer = this.shadow.getElementById("node-layer"); // Re-enter inline edit to show appropriate handles/panel
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null; this.enterInlineEdit(node.id);
if (!g) return;
g.querySelector(".inline-edit-overlay")?.remove();
const s = this.getNodeSize(node);
const overlay = document.createElementNS("http://www.w3.org/2000/svg", "g");
overlay.classList.add("inline-edit-overlay");
if (node.type === "funnel") {
this.renderFunnelThresholdMarkers(overlay, node, s);
}
const panelW = Math.max(280, s.w);
const panelH = 260;
const panelX = (s.w - panelW) / 2;
const panelY = s.h + 8;
const tabs = ["config", "analytics", "allocations"] as const;
overlay.innerHTML += `
<foreignObject x="${panelX}" y="${panelY}" width="${panelW}" height="${panelH}">
<div xmlns="http://www.w3.org/1999/xhtml" class="inline-config-panel" style="height:${panelH}px">
<div class="icp-tabs">
${tabs.map((t) => `<button class="icp-tab ${t === this.inlineConfigTab ? "icp-tab--active" : ""}" data-icp-tab="${t}">${t === "allocations" ? "Alloc" : t.charAt(0).toUpperCase() + t.slice(1)}</button>`).join("")}
</div>
<div class="icp-body">${this.renderInlineConfigContent(node)}</div>
<div class="icp-toolbar">
<button class="iet-done" style="background:var(--rflows-btn-done);color:white">Done</button>
<button class="iet-delete" style="background:var(--rflows-btn-delete);color:white">Delete</button>
<button class="iet-panel" style="background:var(--rs-border-strong);color:var(--rs-text-primary)">...</button>
</div>
</div>
</foreignObject>`;
g.appendChild(overlay);
this.attachInlineConfigListeners(g, node);
} }
private exitInlineEdit() { private exitInlineEdit() {

View File

@ -133,8 +133,8 @@ export const PORT_DEFS: Record<FlowNode["type"], PortDefinition[]> = {
], ],
funnel: [ funnel: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" }, { kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },
{ kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "left" }, { kind: "overflow", dir: "out", xFrac: 0.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "left" },
{ kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "right" }, { kind: "overflow", dir: "out", xFrac: 1.0, yFrac: 0.40, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
{ kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] }, { kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] },
], ],
outcome: [ outcome: [