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:
parent
ff5dd8c006
commit
3f19fd9c8e
|
|
@ -1029,15 +1029,24 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}
|
||||
|
||||
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); // 140–280
|
||||
const h = 110 - Math.round(ratio * 40); // 110–70
|
||||
return { w, h };
|
||||
}
|
||||
if (n.type === "funnel") {
|
||||
const d = n.data as FunnelNodeData;
|
||||
const baseW = 200, baseH = 180;
|
||||
const scaleRef = d.desiredOutflow || d.inflowRate;
|
||||
const scale = 1 + Math.log10(Math.max(1, scaleRef / 1000)) * 0.3;
|
||||
return { w: Math.round(baseW * scale), h: Math.round(baseH * scale) };
|
||||
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: 200, h: 110 }; // outcome (basin)
|
||||
return { w: 220, h: 120 }; // outcome (basin)
|
||||
}
|
||||
|
||||
// ─── Canvas event wiring ──────────────────────────────
|
||||
|
|
@ -1571,17 +1580,20 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private renderSourceNodeSvg(n: FlowNode, selected: boolean): string {
|
||||
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 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})">
|
||||
<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 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"/>
|
||||
<text x="14" y="24" style="fill:var(--rs-text-primary)" font-size="15">${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="44" text-anchor="middle" style="fill:var(--rflows-source-rate)" font-size="11">$${d.flowRate.toLocaleString()}/mo</text>
|
||||
${this.renderAllocBar(d.targetAllocations, w, 48)}
|
||||
<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)}
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -1590,34 +1602,51 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const d = n.data as FunnelNodeData;
|
||||
const s = this.getNodeSize(n);
|
||||
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 isOverflow = d.currentValue > d.maxThreshold;
|
||||
const isCritical = d.currentValue < d.minThreshold;
|
||||
const borderColorVar = isCritical ? "var(--rflows-status-critical)" : isOverflow ? "var(--rflows-status-overflow)" : "var(--rflows-status-sustained)";
|
||||
const fillColor = borderColorVar;
|
||||
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sustained";
|
||||
const statusLabel = isCritical ? "Critical" : isOverflow ? "Overflow" : "Sufficient";
|
||||
|
||||
// Tank shape parameters
|
||||
const r = 10;
|
||||
const pipeW = 24; // overflow pipe extension from wall
|
||||
const basePipeH = 20; // base pipe height
|
||||
const pipeYFrac = 0.55; // pipe center at ~55% down
|
||||
const taperStart = 0.75; // body tapers at 75% down
|
||||
const taperInset = 0.2;
|
||||
const r = 12;
|
||||
const pipeW = 30; // overflow pipe extension from wall
|
||||
const basePipeH = 24; // base pipe height
|
||||
const taperStart = 0.80; // body tapers at 80% down
|
||||
// Drain width proportional to outflow: wider drain = more outflow
|
||||
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 taperY = Math.round(h * taperStart);
|
||||
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 pipeY = Math.round(h * pipeYFrac) - basePipeH / 2;
|
||||
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 + excessRatio * 16;
|
||||
pipeY = Math.round(h * pipeYFrac) - basePipeH / 2 - excessRatio * 8;
|
||||
pipeH = basePipeH + Math.round(excessRatio * 20);
|
||||
pipeY = Math.round(maxLineY - pipeH / 2);
|
||||
}
|
||||
|
||||
// Tank SVG path: flat-top wide body with pipe notches, tapering to drain at bottom
|
||||
|
|
@ -1644,49 +1673,39 @@ class FolkFlowsApp extends HTMLElement {
|
|||
`Z`,
|
||||
].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
|
||||
const totalFillH = zoneH * fillPct;
|
||||
const fillY = zoneTop + zoneH - totalFillH;
|
||||
|
||||
// Threshold lines (always visible)
|
||||
const minFrac = d.minThreshold / (d.maxCapacity || 1);
|
||||
const sufFrac = (d.sufficientThreshold ?? d.maxThreshold) / (d.maxCapacity || 1);
|
||||
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
||||
// Threshold lines: only min and max (2 lines, 3 zones)
|
||||
const minLineY = zoneTop + zoneH * (1 - minFrac);
|
||||
const sufLineY = zoneTop + zoneH * (1 - sufFrac);
|
||||
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
||||
|
||||
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"/>
|
||||
<text x="8" y="${minLineY - 3}" style="fill:var(--rflows-status-critical)" font-size="8" opacity="0.8">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"/>
|
||||
<text x="8" y="${sufLineY - 3}" style="fill:var(--rflows-status-thriving)" font-size="8" opacity="0.8">Suf</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>`;
|
||||
<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="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="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 = 40;
|
||||
const satBarW = w - 40;
|
||||
const satBarY = 50;
|
||||
const satBarW = w - 48;
|
||||
const satRatio = sat ? Math.min(sat.ratio, 1) : 0;
|
||||
const satOverflow = sat ? sat.ratio > 1 : false;
|
||||
const satFillW = satBarW * satRatio;
|
||||
const satLabel = sat ? `${this.formatDollar(sat.actual)} of ${this.formatDollar(sat.needed)}/mo` : "";
|
||||
const satBarBorder = satOverflow ? `stroke="var(--rflows-sat-border)" stroke-width="1"` : "";
|
||||
|
||||
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 6px rgba(16,185,129,0.5))"
|
||||
: !isCritical ? "filter: drop-shadow(0 0 6px rgba(245,158,11,0.4))" : "";
|
||||
|
||||
const glowStyle = isOverflow ? "filter: drop-shadow(0 0 8px rgba(16,185,129,0.5))"
|
||||
: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
|
||||
|
||||
// Rate labels
|
||||
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
|
||||
|
|
@ -1704,36 +1723,37 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
|
||||
</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})"/>` : ""}
|
||||
<path class="node-bg" d="${tankPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : borderColorVar}" stroke-width="${selected ? 3 : 2}"/>
|
||||
${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.5 : 2.5}"/>
|
||||
<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}" width="${w + pipeW * 2}" height="${healthyH}" style="fill:var(--rflows-zone-healthy);opacity:var(--rflows-zone-healthy-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="${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 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="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="3" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.8 : 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="16" text-anchor="middle" style="fill:var(--rs-text-primary)" font-size="13" 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>
|
||||
<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="20" y="${satBarY}" width="${satFillW}" height="6" rx="3" 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 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>
|
||||
<rect x="${insetPx + 4}" y="${h - 10}" width="${w - insetPx * 2 - 8}" height="4" rx="2" style="fill:var(--rs-bg-surface-raised)"/>
|
||||
<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 + 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 - 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 + 4}" y="${pipeY + pipeH / 2 + 3}" text-anchor="start" style="fill:var(--rflows-label-overflow)" font-size="9" opacity="0.7">${overflowLabel}</text>` : ""}
|
||||
<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>` : ""}
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
private renderOutcomeNodeSvg(n: FlowNode, selected: boolean, sat?: { actual: number; needed: number; ratio: number }): string {
|
||||
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 statusColorVar = d.status === "completed" ? "var(--rflows-status-completed)"
|
||||
: d.status === "blocked" ? "var(--rflows-status-blocked)"
|
||||
|
|
@ -1902,8 +1922,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}
|
||||
|
||||
// Second pass: render edges with percentage-proportional widths
|
||||
const MAX_EDGE_W = 16;
|
||||
const MIN_EDGE_W = 1.5;
|
||||
const MAX_EDGE_W = 28;
|
||||
const MIN_EDGE_W = 3;
|
||||
let html = "";
|
||||
for (const e of edges) {
|
||||
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 };
|
||||
|
||||
// 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) {
|
||||
const d = node.data as FunnelNodeData;
|
||||
const h = s.h;
|
||||
const basePipeH = 20;
|
||||
let pipeY = Math.round(h * 0.55) - basePipeH / 2;
|
||||
if (d.currentValue > d.maxThreshold && d.maxCapacity > d.maxThreshold) {
|
||||
const er = Math.min(1, (d.currentValue - d.maxThreshold) / (d.maxCapacity - d.maxThreshold));
|
||||
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 };
|
||||
const zoneTop = 36, zoneBot = h - 6, zoneH = zoneBot - zoneTop;
|
||||
const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
|
||||
const maxLineY = zoneTop + zoneH * (1 - maxFrac);
|
||||
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + maxLineY };
|
||||
}
|
||||
|
||||
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");
|
||||
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") {
|
||||
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 panelH = 260;
|
||||
const panelX = s.w + 12;
|
||||
|
|
@ -2635,18 +2682,107 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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) ──
|
||||
|
||||
private renderFunnelThresholdMarkers(overlay: SVGGElement, node: FlowNode, s: { w: number; h: number }) {
|
||||
const d = node.data as FunnelNodeData;
|
||||
const zoneTop = 28;
|
||||
const zoneBot = s.h - 4;
|
||||
const zoneTop = 36;
|
||||
const zoneBot = s.h - 6;
|
||||
const zoneH = zoneBot - zoneTop;
|
||||
|
||||
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
|
||||
{ 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-sustained)", label: "Max" },
|
||||
{ key: "maxThreshold", value: d.maxThreshold, color: "var(--rflows-status-overflow)", label: "Max" },
|
||||
];
|
||||
|
||||
for (const t of thresholds) {
|
||||
|
|
@ -2815,7 +2951,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const pe = e as PointerEvent;
|
||||
const d = node.data as FunnelNodeData;
|
||||
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 deltaDollars = -(deltaY / zoneH) * (d.maxCapacity || 1);
|
||||
let newVal = this.inlineEditDragStartValue + deltaDollars;
|
||||
|
|
@ -2877,42 +3013,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private redrawNodeInlineEdit(node: FlowNode) {
|
||||
this.drawCanvasContent();
|
||||
const nodeLayer = this.shadow.getElementById("node-layer");
|
||||
const g = nodeLayer?.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
|
||||
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);
|
||||
// Re-enter inline edit to show appropriate handles/panel
|
||||
this.enterInlineEdit(node.id);
|
||||
}
|
||||
|
||||
private exitInlineEdit() {
|
||||
|
|
|
|||
|
|
@ -133,8 +133,8 @@ export const PORT_DEFS: Record<FlowNode["type"], PortDefinition[]> = {
|
|||
],
|
||||
funnel: [
|
||||
{ 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: 1.0, yFrac: 0.55, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
|
||||
{ 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.40, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
|
||||
{ kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] },
|
||||
],
|
||||
outcome: [
|
||||
|
|
|
|||
Loading…
Reference in New Issue