-
-
-
${Math.round(fillPct * 100)}% funded — ${dollarLabel}
-
- ${phaseHtml}
-
+
+
+
+
+
+
+
+ ${waterRect}
+ ${ripple}
+ ${phaseMarkers}
+
+ ${overflowSplash}
+
+
+
+ ${this.esc(d.label)}
+ ${statusLabel}
+ ${phaseSeg}
+
+
${Math.round(fillPct * 100)}%
+
${dollarLabel}
${this.renderPortsSvg(n)}
`;
}
@@ -2454,7 +2605,10 @@ class FolkFlowsApp extends HTMLElement {
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 };
+ // X position: fully outside the vessel walls (pipe extends outward)
+ const pipeW = 28;
+ const xPos = def.side === "left" ? node.position.x - pipeW : node.position.x + s.w + pipeW;
+ return { x: xPos, y: node.position.y + maxLineY };
}
return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
@@ -2471,8 +2625,19 @@ class FolkFlowsApp extends HTMLElement {
const s = this.getNodeSize(n);
const defs = this.getPortDefs(n.type);
return defs.map((p) => {
- const cx = s.w * p.xFrac;
- const cy = s.h * p.yFrac;
+ let cx = s.w * p.xFrac;
+ let cy = s.h * p.yFrac;
+
+ // Funnel overflow ports: position at pipe ends (max threshold line)
+ if (n.type === "funnel" && p.kind === "overflow" && p.side) {
+ const d = n.data as FunnelNodeData;
+ const zoneTop = 36, zoneBot = s.h - 6, zoneH = zoneBot - zoneTop;
+ const maxFrac = d.maxThreshold / (d.maxCapacity || 1);
+ cy = zoneTop + zoneH * (1 - maxFrac);
+ const pipeW = 28;
+ cx = p.side === "left" ? -pipeW : s.w + pipeW;
+ }
+
let arrow: string;
const sideAttr = p.side ? ` data-port-side="${p.side}"` : "";
if (p.side) {
@@ -2767,13 +2932,12 @@ 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 / 10000);
- const valveInset = 0.30 - outflowRatio * 0.18;
- const valveInsetPx = Math.round(s.w * valveInset);
- const drainWidth = s.w - 2 * valveInsetPx;
+ // Drain spout width for tapered vessel
+ const drainW = 60;
+ const drainInset = (s.w - drainW) / 2;
overlay.innerHTML = `
-
◁ ${this.formatDollar(outflow)}/mo ▷
@@ -3102,6 +3266,8 @@ class FolkFlowsApp extends HTMLElement {
const zoneTop = 36;
const zoneBot = s.h - 6;
const zoneH = zoneBot - zoneTop;
+ const drainW = 60;
+ const taperAtBottom = (s.w - drainW) / 2;
const thresholds: Array<{ key: string; value: number; color: string; label: string }> = [
{ key: "minThreshold", value: d.minThreshold, color: "var(--rflows-status-critical)", label: "Min" },
@@ -3111,10 +3277,12 @@ class FolkFlowsApp extends HTMLElement {
for (const t of thresholds) {
const frac = t.value / (d.maxCapacity || 1);
const markerY = zoneTop + zoneH * (1 - frac);
+ const yFrac = (markerY - zoneTop) / zoneH;
+ const inset = this.vesselWallInset(yFrac, taperAtBottom);
overlay.innerHTML += `
-
-
- ${t.label} ${this.formatDollar(t.value)}`;
+
+
+ ${t.label} ${this.formatDollar(t.value)}`;
}
}
@@ -4493,23 +4661,20 @@ class FolkFlowsApp extends HTMLElement {
const nodeLayer = this.shadow.getElementById("node-layer");
if (!nodeLayer) return;
- // Try to patch fill rects in-place for smooth CSS transitions
+ // Try to patch fill paths in-place for smooth CSS transitions
let didPatch = false;
for (const n of this.nodes) {
if (n.type !== "funnel") continue;
const d = n.data as FunnelNodeData;
const s = this.getNodeSize(n);
- const h = s.h;
- const zoneTop = 28;
- const zoneBot = h - 4;
- const zoneH = zoneBot - zoneTop;
+ const w = s.w, h = s.h;
const fillPct = Math.min(1, d.currentValue / (d.maxCapacity || 1));
- const totalFillH = zoneH * fillPct;
- const fillY = zoneTop + zoneH - totalFillH;
- const fillRect = nodeLayer.querySelector(`.funnel-fill-rect[data-node-id="${n.id}"]`) as SVGRectElement | null;
- if (fillRect) {
- fillRect.setAttribute("y", String(fillY));
- fillRect.setAttribute("height", String(totalFillH));
+ const drainW = 60;
+ const taperAtBottom = (w - drainW) / 2;
+ const fillPath = this.computeVesselFillPath(w, h, fillPct, taperAtBottom);
+ const fillEl = nodeLayer.querySelector(`.funnel-fill-path[data-node-id="${n.id}"]`) as SVGPathElement | null;
+ if (fillEl && fillPath) {
+ fillEl.setAttribute("d", fillPath);
didPatch = true;
}
// Patch value text
diff --git a/modules/rflows/lib/presets.ts b/modules/rflows/lib/presets.ts
index 67285e7..5174726 100644
--- a/modules/rflows/lib/presets.ts
+++ b/modules/rflows/lib/presets.ts
@@ -12,14 +12,14 @@ export const OVERFLOW_COLORS = ["#f59e0b", "#ef4444", "#f97316", "#eab308", "#dc
export const demoNodes: FlowNode[] = [
// ── Sources (Y=-300) ──
{
- id: "source-a", type: "source", position: { x: 440, y: -300 },
+ id: "source-a", type: "source", position: { x: 480, y: -300 },
data: {
label: "Grants & Donations", flowRate: 7500, sourceType: "card",
targetAllocations: [{ targetId: "bcrg", percentage: 100, color: "#10b981" }],
} as SourceNodeData,
},
{
- id: "source-b", type: "source", position: { x: 880, y: -300 },
+ id: "source-b", type: "source", position: { x: 900, y: -300 },
data: {
label: "Membership Fees", flowRate: 7500, sourceType: "card",
targetAllocations: [{ targetId: "bcrg", percentage: 100, color: "#10b981" }],
@@ -28,7 +28,7 @@ export const demoNodes: FlowNode[] = [
// ── BCRG central funnel (Y=0) ──
{
- id: "bcrg", type: "funnel", position: { x: 630, y: 0 },
+ id: "bcrg", type: "funnel", position: { x: 660, y: 0 },
data: {
label: "BCRG", currentValue: 95000, desiredOutflow: 25000,
minThreshold: 25000, sufficientThreshold: 100000, maxThreshold: 150000,
@@ -46,7 +46,7 @@ export const demoNodes: FlowNode[] = [
// ── Person funnels (Y=400) ──
{
- id: "alice", type: "funnel", position: { x: 100, y: 400 },
+ id: "alice", type: "funnel", position: { x: 80, y: 400 },
data: {
label: "Alice", currentValue: 18000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@@ -72,7 +72,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
- id: "carol", type: "funnel", position: { x: 660, y: 400 },
+ id: "carol", type: "funnel", position: { x: 680, y: 400 },
data: {
label: "Carol", currentValue: 22000, desiredOutflow: 6000,
minThreshold: 6000, sufficientThreshold: 24000, maxThreshold: 36000,
@@ -85,7 +85,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
- id: "dave", type: "funnel", position: { x: 940, y: 400 },
+ id: "dave", type: "funnel", position: { x: 980, y: 400 },
data: {
label: "Dave", currentValue: 10000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@@ -98,7 +98,7 @@ export const demoNodes: FlowNode[] = [
} as FunnelNodeData,
},
{
- id: "eve", type: "funnel", position: { x: 1220, y: 400 },
+ id: "eve", type: "funnel", position: { x: 1280, y: 400 },
data: {
label: "Eve", currentValue: 16000, desiredOutflow: 5000,
minThreshold: 5000, sufficientThreshold: 20000, maxThreshold: 30000,
@@ -115,7 +115,7 @@ export const demoNodes: FlowNode[] = [
// ── Outcome nodes (Y=850) — 11 total ──
// Alice's outcomes
- { id: "alice-comms", type: "outcome", position: { x: -10, y: 850 },
+ { id: "alice-comms", type: "outcome", position: { x: -20, y: 850 },
data: {
label: "Comms Strategy", description: "Community communications and outreach",
fundingReceived: 12000, fundingTarget: 12000, status: "completed",
@@ -133,7 +133,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "alice-events", type: "outcome", position: { x: 210, y: 850 },
+ { id: "alice-events", type: "outcome", position: { x: 260, y: 850 },
data: {
label: "Event Series", description: "Quarterly community gatherings",
fundingReceived: 6000, fundingTarget: 15000, status: "in-progress",
@@ -150,7 +150,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Bob's outcomes
- { id: "bob-research", type: "outcome", position: { x: 320, y: 850 },
+ { id: "bob-research", type: "outcome", position: { x: 400, y: 850 },
data: {
label: "Field Research", description: "Participatory action research in partner communities",
fundingReceived: 8000, fundingTarget: 20000, status: "in-progress",
@@ -165,7 +165,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "bob-writing", type: "outcome", position: { x: 490, y: 850 },
+ { id: "bob-writing", type: "outcome", position: { x: 680, y: 850 },
data: {
label: "Publications", description: "Research papers and policy briefs",
fundingReceived: 2000, fundingTarget: 10000, status: "not-started",
@@ -180,7 +180,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Carol's outcomes
- { id: "carol-ops", type: "outcome", position: { x: 600, y: 850 },
+ { id: "carol-ops", type: "outcome", position: { x: 820, y: 850 },
data: {
label: "Operations", description: "Day-to-day operational management",
fundingReceived: 18000, fundingTarget: 18000, status: "completed",
@@ -198,7 +198,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "carol-infra", type: "outcome", position: { x: 760, y: 850 },
+ { id: "carol-infra", type: "outcome", position: { x: 1100, y: 850 },
data: {
label: "Infrastructure", description: "Shared infrastructure and hosting",
fundingReceived: 10000, fundingTarget: 20000, status: "in-progress",
@@ -215,7 +215,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Dave's outcomes
- { id: "dave-design", type: "outcome", position: { x: 880, y: 850 },
+ { id: "dave-design", type: "outcome", position: { x: 1240, y: 850 },
data: {
label: "Design System", description: "Shared UI/UX design system",
fundingReceived: 15000, fundingTarget: 15000, status: "completed",
@@ -233,7 +233,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "dave-prototypes", type: "outcome", position: { x: 1050, y: 850 },
+ { id: "dave-prototypes", type: "outcome", position: { x: 1520, y: 850 },
data: {
label: "Prototypes", description: "Rapid prototyping of new tools",
fundingReceived: 3000, fundingTarget: 12000, status: "in-progress",
@@ -251,7 +251,7 @@ export const demoNodes: FlowNode[] = [
} as OutcomeNodeData },
// Eve's outcomes
- { id: "eve-legal", type: "outcome", position: { x: 1140, y: 850 },
+ { id: "eve-legal", type: "outcome", position: { x: 1660, y: 850 },
data: {
label: "Legal Framework", description: "Legal structure and agreements",
fundingReceived: 10000, fundingTarget: 10000, status: "completed",
@@ -266,7 +266,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "eve-compliance", type: "outcome", position: { x: 1280, y: 850 },
+ { id: "eve-compliance", type: "outcome", position: { x: 1940, y: 850 },
data: {
label: "Compliance", description: "Regulatory compliance and reporting",
fundingReceived: 4000, fundingTarget: 12000, status: "in-progress",
@@ -282,7 +282,7 @@ export const demoNodes: FlowNode[] = [
] },
],
} as OutcomeNodeData },
- { id: "eve-governance", type: "outcome", position: { x: 1430, y: 850 },
+ { id: "eve-governance", type: "outcome", position: { x: 2220, y: 850 },
data: {
label: "Governance Model", description: "Governance framework and voting mechanisms",
fundingReceived: 1000, fundingTarget: 8000, status: "not-started",
diff --git a/modules/rflows/lib/types.ts b/modules/rflows/lib/types.ts
index 619bce4..9ab42ad 100644
--- a/modules/rflows/lib/types.ts
+++ b/modules/rflows/lib/types.ts
@@ -129,12 +129,12 @@ export interface PortDefinition {
/** Single source of truth for port positions, colors, and connectivity rules. */
export const PORT_DEFS: Record = {
source: [
- { kind: "outflow", dir: "out", xFrac: 0.5, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
+ { kind: "outflow", dir: "out", xFrac: 0.75, yFrac: 1, color: "#10b981", connectsTo: ["inflow"] },
],
funnel: [
{ kind: "inflow", dir: "in", xFrac: 0.5, yFrac: 0, color: "#60a5fa" },
- { 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: "overflow", dir: "out", xFrac: 0.08, yFrac: 0.08, color: "#f59e0b", connectsTo: ["inflow"], side: "left" },
+ { kind: "overflow", dir: "out", xFrac: 0.92, yFrac: 0.08, color: "#f59e0b", connectsTo: ["inflow"], side: "right" },
{ kind: "spending", dir: "out", xFrac: 0.5, yFrac: 1, color: "#8b5cf6", connectsTo: ["inflow"] },
],
outcome: [