feat(rflows): close source→funnel flow gap, interactive sizing + drag handles
Source nodes now drive funnel inflow rates via computeInflowRates() which sums source allocations before each simulation tick. Source width scales with flowRate, funnel height scales linearly with capacity, and valve/ capacity drag handles are always visible on hover (no inline-edit needed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b2862afd7
commit
eeae7d2aa1
|
|
@ -274,6 +274,13 @@
|
|||
.flow-node.selected .node-bg { stroke: var(--rs-primary-hover); stroke-width: 3; }
|
||||
.node-glow { filter: drop-shadow(0 0 6px rgba(251,191,36,0.5)); }
|
||||
|
||||
/* Funnel drag handles — hidden by default, visible on hover */
|
||||
.funnel-valve-handle, .funnel-height-handle { opacity: 0; transition: opacity 0.2s; }
|
||||
.flow-node:hover .funnel-valve-handle,
|
||||
.flow-node:hover .funnel-height-handle { opacity: 0.8; }
|
||||
.funnel-valve-handle:hover { opacity: 1 !important; }
|
||||
.funnel-height-handle:hover { opacity: 1 !important; }
|
||||
|
||||
/* HTML card nodes (foreignObject) */
|
||||
.node-card {
|
||||
background: white; border-radius: 12px; overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, OutcomePhase, SourceNodeData, PortDefinition, PortKind, OverflowAllocation, SpendingAllocation, SourceAllocation } from "../lib/types";
|
||||
import { PORT_DEFS, deriveThresholds } from "../lib/types";
|
||||
import { computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||
import { computeInflowRates, computeSufficiencyState, computeSystemSufficiency, simulateTick } from "../lib/simulation";
|
||||
import { demoNodes, SPENDING_COLORS, OVERFLOW_COLORS } from "../lib/presets";
|
||||
import { mapFlowToNodes } from "../lib/map-flow";
|
||||
import { flowsSchema, flowsDocId, type FlowsDoc, type CanvasFlow } from "../schemas";
|
||||
|
|
@ -215,7 +215,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const flow = doc.canvasFlows?.[this.currentFlowId];
|
||||
if (flow && !this.saveTimer) {
|
||||
// Only update if we're not in the middle of saving
|
||||
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
|
||||
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
|
||||
this.drawCanvasContent();
|
||||
}
|
||||
});
|
||||
|
|
@ -259,7 +259,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (!flow) return;
|
||||
this.currentFlowId = flow.id;
|
||||
this.flowName = flow.name;
|
||||
this.nodes = flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } }));
|
||||
this.nodes = computeInflowRates(flow.nodes.map((n: any) => ({ ...n, data: { ...n.data } })));
|
||||
this.localFirstClient?.setActiveFlow(flowId);
|
||||
this.restoreViewport(flowId);
|
||||
this.loading = false;
|
||||
|
|
@ -1042,14 +1042,17 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
||||
if (n.type === "source") {
|
||||
return { w: 220, h: 120 };
|
||||
const d = n.data as SourceNodeData;
|
||||
const baseW = 180;
|
||||
const w = Math.round(baseW + Math.min(120, Math.sqrt(d.flowRate / 100) * 20));
|
||||
return { w, h: 120 };
|
||||
}
|
||||
if (n.type === "funnel") {
|
||||
const d = n.data as FunnelNodeData;
|
||||
const baseW = 280, baseH = 250;
|
||||
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)) };
|
||||
const baseW = 280;
|
||||
const cap = d.maxCapacity || 9000;
|
||||
const h = Math.round(200 + Math.min(200, (cap / 50000) * 200));
|
||||
return { w: baseW, h: Math.max(200, h) };
|
||||
}
|
||||
return { w: 220, h: 180 }; // outcome card
|
||||
}
|
||||
|
|
@ -1083,6 +1086,75 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.updateCanvasTransform();
|
||||
}, { passive: false });
|
||||
|
||||
// Delegated funnel valve + height drag handles
|
||||
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
const target = e.target as Element;
|
||||
const valveG = target.closest(".funnel-valve-handle") as SVGGElement | null;
|
||||
const heightG = target.closest(".funnel-height-handle") as SVGGElement | null;
|
||||
const handleG = valveG || heightG;
|
||||
if (!handleG) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const nodeId = handleG.getAttribute("data-node-id");
|
||||
const node = this.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.type !== "funnel") return;
|
||||
const fd = node.data as FunnelNodeData;
|
||||
const s = this.getNodeSize(node);
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
if (valveG) {
|
||||
const startOutflow = fd.desiredOutflow || 0;
|
||||
handleG.setPointerCapture(e.pointerId);
|
||||
const label = handleG.querySelector("text");
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const deltaX = (ev.clientX - startX) / this.canvasZoom;
|
||||
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
||||
newOutflow = Math.max(0, Math.min(10000, 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);
|
||||
}
|
||||
if (label) label.textContent = `◁ ${this.formatDollar(newOutflow)}/mo ▷`;
|
||||
};
|
||||
const onUp = () => {
|
||||
handleG.removeEventListener("pointermove", onMove as EventListener);
|
||||
handleG.removeEventListener("pointerup", onUp);
|
||||
handleG.removeEventListener("lostpointercapture", onUp);
|
||||
this.drawCanvasContent();
|
||||
this.redrawEdges();
|
||||
this.scheduleSave();
|
||||
};
|
||||
handleG.addEventListener("pointermove", onMove as EventListener);
|
||||
handleG.addEventListener("pointerup", onUp);
|
||||
handleG.addEventListener("lostpointercapture", onUp);
|
||||
} else {
|
||||
const startCapacity = fd.maxCapacity || 9000;
|
||||
handleG.setPointerCapture(e.pointerId);
|
||||
const label = handleG.querySelector("text");
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const deltaY = (ev.clientY - startY) / this.canvasZoom;
|
||||
let newCapacity = Math.round((startCapacity + deltaY * 80) / 500) * 500;
|
||||
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
||||
fd.maxCapacity = newCapacity;
|
||||
if (label) label.textContent = `⇕ ${this.formatDollar(newCapacity)}`;
|
||||
};
|
||||
const onUp = () => {
|
||||
handleG.removeEventListener("pointermove", onMove as EventListener);
|
||||
handleG.removeEventListener("pointerup", onUp);
|
||||
handleG.removeEventListener("lostpointercapture", onUp);
|
||||
this.drawCanvasContent();
|
||||
this.redrawEdges();
|
||||
this.scheduleSave();
|
||||
};
|
||||
handleG.addEventListener("pointermove", onMove as EventListener);
|
||||
handleG.addEventListener("pointerup", onUp);
|
||||
handleG.addEventListener("lostpointercapture", onUp);
|
||||
}
|
||||
}, { capture: true });
|
||||
|
||||
// Panning — pointerdown on SVG background
|
||||
svg.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
const target = e.target as Element;
|
||||
|
|
@ -1608,6 +1680,10 @@ class FolkFlowsApp extends HTMLElement {
|
|||
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
|
||||
}
|
||||
|
||||
// Flow-width bar: visual river-width proportional to flowRate
|
||||
const flowBarMaxW = w - 24;
|
||||
const flowBarW = Math.round(12 + Math.min(flowBarMaxW - 12, Math.sqrt(d.flowRate / 100) * (flowBarMaxW / 6)));
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : "#6ee7b7"}" stroke-width="${selected ? 3 : 2}"/>
|
||||
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
||||
|
|
@ -1624,6 +1700,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
${allocBarHtml}
|
||||
</div>
|
||||
</foreignObject>
|
||||
<rect x="${(w - flowBarW) / 2}" y="${h - 6}" width="${flowBarW}" height="4" rx="2" style="fill:#10b981;opacity:0.5"/>
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -1647,7 +1724,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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 outflowRatio = Math.min(1, outflow / 10000);
|
||||
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);
|
||||
|
|
@ -1761,6 +1838,18 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<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}/>
|
||||
<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"/>
|
||||
<g class="funnel-valve-handle" data-handle="valve" data-node-id="${n.id}">
|
||||
<rect x="${insetPx - 8}" y="${h - 16}" width="${w - insetPx * 2 + 16}" height="18" rx="5"
|
||||
style="fill:var(--rflows-label-spending);cursor:ew-resize;stroke:white;stroke-width:1.5"/>
|
||||
<text x="${w / 2}" y="${h - 4}" text-anchor="middle" fill="white" font-size="11" font-weight="600" pointer-events="none">
|
||||
◁ ${this.formatDollar(outflow)}/mo ▷
|
||||
</text>
|
||||
</g>
|
||||
<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>
|
||||
|
|
@ -2579,7 +2668,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (node.type === "funnel") {
|
||||
const d = node.data as FunnelNodeData;
|
||||
const outflow = d.desiredOutflow || 0;
|
||||
const outflowRatio = Math.min(1, outflow / 3000);
|
||||
const outflowRatio = Math.min(1, outflow / 10000);
|
||||
const valveInset = 0.30 - outflowRatio * 0.18;
|
||||
const valveInsetPx = Math.round(s.w * valveInset);
|
||||
const drainWidth = s.w - 2 * valveInsetPx;
|
||||
|
|
@ -2828,8 +2917,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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));
|
||||
let newOutflow = Math.round((startOutflow + (deltaX / s.w) * 10000) / 50) * 50;
|
||||
newOutflow = Math.max(0, Math.min(10000, newOutflow));
|
||||
fd.desiredOutflow = newOutflow;
|
||||
fd.minThreshold = newOutflow;
|
||||
fd.maxThreshold = newOutflow * 6;
|
||||
|
|
@ -2874,7 +2963,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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));
|
||||
newCapacity = Math.max(Math.round(fd.maxThreshold * 1.2), Math.min(100000, newCapacity));
|
||||
fd.maxCapacity = newCapacity;
|
||||
// Update label
|
||||
const label = overlay.querySelector(".height-drag-label");
|
||||
|
|
@ -3855,6 +3944,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (this.simInterval) clearInterval(this.simInterval);
|
||||
this.simInterval = setInterval(() => {
|
||||
this.simTickCount++;
|
||||
this.nodes = computeInflowRates(this.nodes);
|
||||
this.nodes = simulateTick(this.nodes);
|
||||
this.accumulateNodeAnalytics();
|
||||
this.updateCanvasLive();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Ported from rflows-online/lib/simulation.ts.
|
||||
*/
|
||||
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types";
|
||||
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types";
|
||||
import { deriveThresholds } from "./types";
|
||||
|
||||
export interface SimulationConfig {
|
||||
|
|
@ -85,6 +85,31 @@ export function computeSystemSufficiency(nodes: FlowNode[]): number {
|
|||
return count > 0 ? sum / count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync source→funnel allocations into each funnel's inflowRate.
|
||||
* Funnels with no source wired keep their manual inflowRate (backward compat).
|
||||
*/
|
||||
export function computeInflowRates(nodes: FlowNode[]): FlowNode[] {
|
||||
const computed = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
if (n.type === "source") {
|
||||
const d = n.data as SourceNodeData;
|
||||
for (const alloc of d.targetAllocations) {
|
||||
computed.set(
|
||||
alloc.targetId,
|
||||
(computed.get(alloc.targetId) ?? 0) + d.flowRate * (alloc.percentage / 100),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes.map((n) => {
|
||||
if (n.type === "funnel" && computed.has(n.id)) {
|
||||
return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
|
||||
export function simulateTick(
|
||||
nodes: FlowNode[],
|
||||
config: SimulationConfig = DEFAULT_CONFIG,
|
||||
|
|
|
|||
Loading…
Reference in New Issue