feat: rFlows visual redesign — HTML card nodes, Sankey widths, smooth drag
- Switch source/outcome nodes from SVG shapes to foreignObject HTML cards with white backgrounds, gradient headers, status badges, and progress bars - Add foreignObject text overlay to funnel nodes (keep SVG tank shape) - Fix Sankey edge widths: flow-value-relative formula instead of percentage - Smooth drag: rAF throttle + lightweight edge path patching during drag - Add dot grid canvas background, arrowhead markers, larger port dots - Fixed node sizes: source 220x120, outcome 220x180 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92dff99793
commit
d8f9f46515
|
|
@ -239,6 +239,9 @@
|
|||
.flows-canvas-svg {
|
||||
width: 100%; height: 100%; display: block;
|
||||
cursor: grab;
|
||||
background-color: #f8fafc;
|
||||
background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.flows-canvas-svg.panning { cursor: grabbing; }
|
||||
.flows-canvas-svg.dragging { cursor: move; }
|
||||
|
|
@ -267,10 +270,20 @@
|
|||
|
||||
/* SVG node styles */
|
||||
.flow-node { cursor: pointer; }
|
||||
.flow-node:hover .node-bg { filter: brightness(1.15); }
|
||||
.flow-node:hover .node-bg { filter: brightness(1.05); }
|
||||
.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)); }
|
||||
|
||||
/* HTML card nodes (foreignObject) */
|
||||
.node-card {
|
||||
background: white; border-radius: 12px; overflow: hidden;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
height: 100%; box-sizing: border-box;
|
||||
}
|
||||
.source-card { }
|
||||
.outcome-card { }
|
||||
.funnel-overlay { pointer-events: none; }
|
||||
|
||||
/* Editor panel — right side slide-in */
|
||||
.flows-editor-panel {
|
||||
position: absolute; top: 0; right: 0; bottom: 0; width: 320px; z-index: 20;
|
||||
|
|
@ -416,8 +429,8 @@
|
|||
/* ── Port & wiring ──────────────────────────────────── */
|
||||
.port-group { pointer-events: all; }
|
||||
.port-hit { cursor: crosshair; }
|
||||
.port-dot { transition: r 0.15s, filter 0.15s; }
|
||||
.port-group:hover .port-dot { r: 7; filter: drop-shadow(0 0 4px currentColor); }
|
||||
.port-dot { transition: r 0.15s, filter 0.15s; r: 7; stroke: white; stroke-width: 2; }
|
||||
.port-group:hover .port-dot { r: 9; filter: drop-shadow(0 0 4px currentColor); }
|
||||
|
||||
.port-group--wiring-source .port-dot { animation: port-glow 0.8s ease-in-out infinite; }
|
||||
.port-group--wiring-target .port-dot { animation: port-breathe 1s ease-in-out infinite; }
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
|
||||
private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
|
||||
private _dragRafId: number | null = null;
|
||||
|
||||
// Flow storage & switching
|
||||
private localFirstClient: FlowsLocalFirstClient | null = null;
|
||||
|
|
@ -900,6 +901,17 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<button class="flows-canvas-btn" data-canvas-action="share">Share</button>
|
||||
</div>
|
||||
<svg class="flows-canvas-svg" id="flow-canvas">
|
||||
<defs>
|
||||
<marker id="arrowhead-inflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-inflow)"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-spending" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-spending)"/>
|
||||
</marker>
|
||||
<marker id="arrowhead-overflow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M 0 0 L 8 3 L 0 6 Z" fill="var(--rflows-edge-overflow)"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="canvas-transform">
|
||||
<g id="edge-layer"></g>
|
||||
<g id="wire-layer"></g>
|
||||
|
|
@ -1030,23 +1042,16 @@ class FolkFlowsApp extends HTMLElement {
|
|||
|
||||
private getNodeSize(n: FlowNode): { w: number; h: number } {
|
||||
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 };
|
||||
return { w: 220, h: 120 };
|
||||
}
|
||||
if (n.type === "funnel") {
|
||||
const d = n.data as FunnelNodeData;
|
||||
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: 220, h: 120 }; // outcome (basin)
|
||||
return { w: 220, h: 180 }; // outcome card
|
||||
}
|
||||
|
||||
// ─── Canvas event wiring ──────────────────────────────
|
||||
|
|
@ -1139,15 +1144,20 @@ class FolkFlowsApp extends HTMLElement {
|
|||
nodeDragStarted = true;
|
||||
svg.classList.add("dragging");
|
||||
}
|
||||
const dx = rawDx / this.canvasZoom;
|
||||
const dy = rawDy / this.canvasZoom;
|
||||
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
|
||||
if (node) {
|
||||
node.position.x = this.dragNodeStartX + dx;
|
||||
node.position.y = this.dragNodeStartY + dy;
|
||||
this.updateNodePosition(node);
|
||||
this.redrawEdges();
|
||||
}
|
||||
// rAF throttle: skip if a frame is already queued
|
||||
if (this._dragRafId) return;
|
||||
this._dragRafId = requestAnimationFrame(() => {
|
||||
this._dragRafId = null;
|
||||
const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
|
||||
const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
|
||||
const node = this.nodes.find((n) => n.id === this.draggingNodeId);
|
||||
if (node) {
|
||||
node.position.x = this.dragNodeStartX + dx;
|
||||
node.position.y = this.dragNodeStartY + dy;
|
||||
this.updateNodePosition(node);
|
||||
this.updateEdgesDuringDrag(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
this._boundPointerUp = (e: PointerEvent) => {
|
||||
|
|
@ -1180,6 +1190,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.draggingNodeId = null;
|
||||
nodeDragStarted = false;
|
||||
svg.classList.remove("dragging");
|
||||
// Cancel any pending rAF
|
||||
if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; }
|
||||
|
||||
// Single click = select + open inline editor
|
||||
if (!wasDragged) {
|
||||
|
|
@ -1188,6 +1200,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.updateSelectionHighlight();
|
||||
this.enterInlineEdit(clickedNodeId);
|
||||
} else {
|
||||
// Full edge redraw for final accuracy
|
||||
this.redrawEdges();
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
|
|
@ -1584,16 +1598,32 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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 bodyH = h - 20;
|
||||
const stubW = 26, stubH = 20;
|
||||
const isSmall = d.flowRate < 1000;
|
||||
|
||||
// Allocation bar segments as inline HTML
|
||||
let allocBarHtml = "";
|
||||
if (d.targetAllocations && d.targetAllocations.length > 0) {
|
||||
const segs = d.targetAllocations.map(a =>
|
||||
`<div style="flex:${a.percentage};height:3px;background:${a.color};border-radius:1px;opacity:0.8"></div>`
|
||||
).join("");
|
||||
allocBarHtml = `<div style="display:flex;gap:1px;margin-top:6px;padding:0 8px">${segs}</div>`;
|
||||
}
|
||||
|
||||
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="${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)}
|
||||
<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}">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card source-card ${selected ? "selected" : ""}">
|
||||
<div class="card-header" style="background:linear-gradient(to right,#ecfdf5,#f0fdfa);padding:8px 12px;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span style="font-size:16px">${icon}</span>
|
||||
<span style="font-size:13px;font-weight:600;color:#1e293b">${this.esc(d.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px;text-align:center">
|
||||
<div style="font-size:20px;font-weight:700;color:#059669;font-family:ui-monospace,monospace">$${d.flowRate.toLocaleString()}<span style="font-size:12px;font-weight:400;color:#64748b">/mo</span></div>
|
||||
</div>
|
||||
${allocBarHtml}
|
||||
</div>
|
||||
</foreignObject>
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -1686,15 +1716,6 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<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 = 50;
|
||||
const satBarW = w - 48;
|
||||
|
|
@ -1708,17 +1729,20 @@ class FolkFlowsApp extends HTMLElement {
|
|||
: !isCritical ? "filter: drop-shadow(0 0 8px rgba(245,158,11,0.4))" : "";
|
||||
|
||||
// Rate labels
|
||||
const inflowLabel = `\u2193 ${this.formatDollar(d.inflowRate)}/mo`;
|
||||
const inflowLabel = `${this.formatDollar(d.inflowRate)}/mo`;
|
||||
const baseRate = d.desiredOutflow || d.inflowRate;
|
||||
let rateMultiplier: number;
|
||||
if (d.currentValue > d.maxThreshold) rateMultiplier = 0.8;
|
||||
else if (d.currentValue >= d.minThreshold) rateMultiplier = 0.5;
|
||||
else rateMultiplier = 0.1;
|
||||
const spendingRate = baseRate * rateMultiplier;
|
||||
const spendingLabel = `\u2193 ${this.formatDollar(spendingRate)}/mo`;
|
||||
const excess = Math.max(0, d.currentValue - d.maxThreshold);
|
||||
const overflowLabel = isOverflow ? this.formatDollar(excess) : "";
|
||||
|
||||
// Status badge colors for HTML
|
||||
const statusBadgeBg = isCritical ? "rgba(239,68,68,0.15)" : isOverflow ? "rgba(16,185,129,0.15)" : "rgba(245,158,11,0.15)";
|
||||
const statusBadgeColor = isCritical ? "#ef4444" : isOverflow ? "#10b981" : "#f59e0b";
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})" style="${glowStyle}">
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${tankPath}"/></clipPath>
|
||||
|
|
@ -1731,21 +1755,29 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<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="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>` : ""}
|
||||
<foreignObject x="${-pipeW - 10}" y="-28" width="${w + pipeW * 2 + 20}" height="${h + 56}" class="funnel-overlay">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;position:relative;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
||||
<div style="position:absolute;top:0;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:12px;font-weight:500;color:#10b981;opacity:0.9">\u2193 ${inflowLabel}</div>
|
||||
<div style="position:absolute;top:38px;left:${pipeW + 10}px;right:${pipeW + 10}px;display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:14px;font-weight:600;color:#1e293b">${this.esc(d.label)}</span>
|
||||
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusBadgeBg};color:${statusBadgeColor}">${statusLabel}</span>
|
||||
</div>
|
||||
<div style="position:absolute;top:${satBarY + 18 + 28}px;left:${pipeW + 10}px;right:${pipeW + 10}px;text-align:center;font-size:10px;color:#94a3b8">${satLabel}</div>
|
||||
${criticalH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH + criticalH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#ef4444;opacity:0.5">CRITICAL</div>` : ""}
|
||||
${sufficientH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH + sufficientH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">SUFFICIENT</div>` : ""}
|
||||
${overflowH > 20 ? `<div style="position:absolute;top:${zoneTop + overflowH / 2 + 28 - 6}px;width:100%;text-align:center;font-size:10px;font-weight:600;color:#f59e0b;opacity:0.5">OVERFLOW</div>` : ""}
|
||||
<div class="funnel-value-text" data-node-id="${n.id}" style="position:absolute;bottom:${insetPx + 56}px;width:100%;text-align:center;font-size:13px;font-weight:500;color:#64748b">$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(d.maxThreshold).toLocaleString()}</div>
|
||||
<div style="position:absolute;bottom:10px;width:100%;text-align:center;font-size:12px;font-weight:600;color:#34d399">${this.formatDollar(outflow)}/mo \u25BE</div>
|
||||
${isOverflow ? `<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;left:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>
|
||||
<div style="position:absolute;top:${pipeY + pipeH / 2 + 28 - 6}px;right:0;font-size:11px;font-weight:500;color:#6ee7b7;opacity:0.8">${overflowLabel}</div>` : ""}
|
||||
</div>
|
||||
</foreignObject>
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -1755,56 +1787,43 @@ class FolkFlowsApp extends HTMLElement {
|
|||
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)"
|
||||
: d.status === "in-progress" ? "var(--rflows-status-inprogress)" : "var(--rflows-status-notstarted)";
|
||||
const statusColors: Record<string, string> = { completed: "#10b981", blocked: "#ef4444", "in-progress": "#3b82f6", "not-started": "#64748b" };
|
||||
const statusColor = statusColors[d.status] || "#64748b";
|
||||
const statusLabel = d.status.replace("-", " ").replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
// Basin shape: slightly flared walls (8px wider at top)
|
||||
const flare = 8;
|
||||
const clipId = `basin-clip-${n.id}`;
|
||||
const basinPath = [
|
||||
`M ${-flare},0`,
|
||||
`L ${w + flare},0`,
|
||||
`Q ${w + flare},4 ${w + flare - 2},8`,
|
||||
`L ${w},${h - 8}`,
|
||||
`Q ${w},${h} ${w - 8},${h}`,
|
||||
`L 8,${h}`,
|
||||
`Q 0,${h} 0,${h - 8}`,
|
||||
`L ${-flare + 2},8`,
|
||||
`Q ${-flare},4 ${-flare},0`,
|
||||
`Z`,
|
||||
].join(" ");
|
||||
|
||||
// Fill level from bottom
|
||||
const fillZoneTop = 30;
|
||||
const fillZoneH = h - fillZoneTop - 4;
|
||||
const fillH = fillZoneH * fillPct;
|
||||
const fillY = fillZoneTop + fillZoneH - fillH;
|
||||
|
||||
let phaseBars = "";
|
||||
// Phase indicators
|
||||
let phaseHtml = "";
|
||||
if (d.phases && d.phases.length > 0) {
|
||||
const phaseW = (w - 20) / d.phases.length;
|
||||
phaseBars = d.phases.map((p, i) => {
|
||||
const unlockedCount = d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length;
|
||||
const phaseSegs = d.phases.map((p, i) => {
|
||||
const unlocked = d.fundingReceived >= p.fundingThreshold;
|
||||
return `<rect x="${10 + i * phaseW}" y="75" width="${phaseW - 2}" height="6" rx="2" style="fill:${unlocked ? "var(--rflows-phase-unlocked)" : "var(--rs-bg-surface-raised)"}" opacity="${unlocked ? 0.8 : 0.5}"/>`;
|
||||
return `<div style="flex:1;height:4px;border-radius:2px;background:${unlocked ? "#10b981" : "#e2e8f0"}"></div>`;
|
||||
}).join("");
|
||||
phaseBars += `<text x="${w / 2}" y="93" text-anchor="middle" style="fill:var(--rs-text-muted)" font-size="9">${d.phases.filter((p) => d.fundingReceived >= p.fundingThreshold).length}/${d.phases.length} phases</text>`;
|
||||
phaseHtml = `<div style="display:flex;gap:2px;margin:6px 0">${phaseSegs}</div>
|
||||
<div style="font-size:10px;color:#94a3b8;text-align:center">${unlockedCount}/${d.phases.length} phases unlocked</div>`;
|
||||
}
|
||||
|
||||
const dollarLabel = `${this.formatDollar(d.fundingReceived)} / ${this.formatDollar(d.fundingTarget)}`;
|
||||
|
||||
return `<g class="flow-node ${selected ? "selected" : ""}" data-node-id="${n.id}" transform="translate(${x},${y})">
|
||||
<defs>
|
||||
<clipPath id="${clipId}"><path d="${basinPath}"/></clipPath>
|
||||
</defs>
|
||||
<path class="node-bg" d="${basinPath}" style="fill:var(--rs-bg-surface)" stroke="${selected ? "var(--rflows-selected)" : statusColorVar}" stroke-width="${selected ? 3 : 1.5}"/>
|
||||
<g clip-path="url(#${clipId})">
|
||||
<rect class="basin-fill-rect" x="${-flare}" y="${fillY}" width="${w + flare * 2}" height="${fillH}" style="fill:${statusColorVar};opacity:0.25"/>
|
||||
</g>
|
||||
<circle cx="14" cy="18" r="5" style="fill:${statusColorVar}" opacity="0.7"/>
|
||||
<text x="26" y="22" style="fill:var(--rs-text-primary)" font-size="12" font-weight="600">${this.esc(d.label)}</text>
|
||||
<text x="${w / 2}" y="${fillY > 50 ? fillY - 4 : 50}" text-anchor="middle" style="fill:var(--rs-text-secondary)" font-size="10">${Math.round(fillPct * 100)}% — ${dollarLabel}</text>
|
||||
${phaseBars}
|
||||
<rect class="node-bg" x="0" y="0" width="${w}" height="${h}" rx="12" fill="white" stroke="${selected ? "var(--rflows-selected)" : statusColor}" stroke-width="${selected ? 3 : 2}"/>
|
||||
<foreignObject x="0" y="0" width="${w}" height="${h}">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="node-card outcome-card ${selected ? "selected" : ""}">
|
||||
<div class="card-header" style="background:linear-gradient(to right,#fdf2f8,#faf5ff);padding:8px 12px;border-bottom:1px solid #e2e8f0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span style="font-size:13px;font-weight:600;color:#1e293b">${this.esc(d.label)}</span>
|
||||
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${statusColor}20;color:${statusColor}">${statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:8px 12px">
|
||||
<div style="font-size:11px;color:#64748b;margin-bottom:4px">${Math.round(fillPct * 100)}% funded — ${dollarLabel}</div>
|
||||
<div style="height:6px;background:#e2e8f0;border-radius:3px;overflow:hidden">
|
||||
<div style="width:${fillPct * 100}%;height:100%;background:${statusColor};border-radius:3px;transition:width 0.3s"></div>
|
||||
</div>
|
||||
${phaseHtml}
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -1921,15 +1940,16 @@ class FolkFlowsApp extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
// Second pass: render edges with percentage-proportional widths
|
||||
// Second pass: render edges with flow-value-relative widths (Sankey-style)
|
||||
const MAX_EDGE_W = 28;
|
||||
const MIN_EDGE_W = 3;
|
||||
const maxFlow = Math.max(...edges.map(e => e.flowAmount), 1);
|
||||
let html = "";
|
||||
for (const e of edges) {
|
||||
const from = this.getPortPosition(e.fromNode, e.fromPort, e.fromSide);
|
||||
const to = this.getPortPosition(e.toNode, "inflow");
|
||||
const isGhost = e.flowAmount === 0;
|
||||
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.pct / 100) * (MAX_EDGE_W - MIN_EDGE_W);
|
||||
const strokeW = isGhost ? 1 : MIN_EDGE_W + (e.flowAmount / maxFlow) * (MAX_EDGE_W - MIN_EDGE_W);
|
||||
const label = isGhost ? `${e.pct}%` : `${this.formatDollar(e.flowAmount)} (${e.pct}%)`;
|
||||
html += this.renderEdgePath(
|
||||
from.x, from.y, to.x, to.y,
|
||||
|
|
@ -2014,10 +2034,12 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const halfW = labelW / 2;
|
||||
// Drag handle at midpoint
|
||||
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
|
||||
// Arrow marker
|
||||
const markerId = edgeType === "overflow" ? "arrowhead-overflow" : edgeType === "spending" ? "arrowhead-spending" : "arrowhead-inflow";
|
||||
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
|
||||
${hitPath}
|
||||
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
||||
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}"/>
|
||||
<path d="${d}" fill="none" style="stroke:${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}" marker-end="url(#${markerId})"/>
|
||||
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" style="fill:${color}" opacity="0.5" class="edge-splash"><animate attributeName="r" values="${Math.max(4, finalStrokeW * 0.6)};${Math.max(8, finalStrokeW)};${Math.max(4, finalStrokeW * 0.6)}" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0.2;0.5" dur="1.2s" repeatCount="indefinite"/></circle>` : ""}
|
||||
${dragHandle}
|
||||
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
||||
|
|
@ -2040,6 +2062,101 @@ class FolkFlowsApp extends HTMLElement {
|
|||
if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
|
||||
}
|
||||
|
||||
/** Pure path computation — returns { d, midX, midY } */
|
||||
private computeEdgePath(
|
||||
x1: number, y1: number, x2: number, y2: number,
|
||||
strokeW: number, fromSide?: "left" | "right",
|
||||
waypoint?: { x: number; y: number },
|
||||
): { d: string; midX: number; midY: number } {
|
||||
let d: string, midX: number, midY: number;
|
||||
if (waypoint) {
|
||||
const cx1 = (4 * waypoint.x - x1 - x2) / 3;
|
||||
const cy1 = (4 * waypoint.y - y1 - y2) / 3;
|
||||
const c1x = x1 + (cx1 - x1) * 0.8;
|
||||
const c1y = y1 + (cy1 - y1) * 0.8;
|
||||
const c2x = x2 + (cx1 - x2) * 0.8;
|
||||
const c2y = y2 + (cy1 - y2) * 0.8;
|
||||
d = `M ${x1} ${y1} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${x2} ${y2}`;
|
||||
midX = waypoint.x;
|
||||
midY = waypoint.y;
|
||||
} else if (fromSide) {
|
||||
const burst = Math.max(100, strokeW * 8);
|
||||
const outwardX = fromSide === "left" ? x1 - burst : x1 + burst;
|
||||
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
|
||||
midX = (x1 + outwardX + x2) / 3;
|
||||
midY = (y1 + y2) / 2;
|
||||
} else {
|
||||
const cy1 = y1 + (y2 - y1) * 0.4;
|
||||
const cy2 = y1 + (y2 - y1) * 0.6;
|
||||
d = `M ${x1} ${y1} C ${x1} ${cy1}, ${x2} ${cy2}, ${x2} ${y2}`;
|
||||
midX = (x1 + x2) / 2;
|
||||
midY = (y1 + y2) / 2;
|
||||
}
|
||||
return { d, midX, midY };
|
||||
}
|
||||
|
||||
/** Lightweight edge update during drag — only patches path `d` attrs and label positions for edges connected to dragged node */
|
||||
private updateEdgesDuringDrag(nodeId: string) {
|
||||
const edgeLayer = this.shadow.getElementById("edge-layer");
|
||||
if (!edgeLayer) return;
|
||||
const groups = edgeLayer.querySelectorAll(`.edge-group[data-from="${nodeId}"], .edge-group[data-to="${nodeId}"]`);
|
||||
for (const g of groups) {
|
||||
const el = g as SVGGElement;
|
||||
const fromId = el.dataset.from!;
|
||||
const toId = el.dataset.to!;
|
||||
const edgeType = el.dataset.edgeType || "source";
|
||||
const fromNode = this.nodes.find(n => n.id === fromId);
|
||||
const toNode = this.nodes.find(n => n.id === toId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
// Determine port kinds and side
|
||||
let fromPort: PortKind = "outflow";
|
||||
let fromSide: "left" | "right" | undefined;
|
||||
if (edgeType === "overflow") {
|
||||
fromPort = "overflow";
|
||||
fromSide = this.getOverflowSideForTarget(fromNode, toNode);
|
||||
} else if (edgeType === "spending") {
|
||||
fromPort = "spending";
|
||||
}
|
||||
|
||||
const from = this.getPortPosition(fromNode, fromPort, fromSide);
|
||||
const to = this.getPortPosition(toNode, "inflow");
|
||||
|
||||
// Get waypoint from allocation
|
||||
const alloc = this.findEdgeAllocation(fromId, toId, edgeType);
|
||||
const waypoint = alloc?.waypoint;
|
||||
|
||||
// Compute stroke width (approximate — use existing path width)
|
||||
const mainPath = el.querySelector<SVGPathElement>(".edge-path-animated, .edge-path-overflow, .edge-ghost");
|
||||
const existingStrokeW = mainPath ? parseFloat(mainPath.getAttribute("stroke-width") || "4") : 4;
|
||||
|
||||
const { d, midX, midY } = this.computeEdgePath(from.x, from.y, to.x, to.y, existingStrokeW, fromSide, waypoint);
|
||||
|
||||
// Update all path elements in this group
|
||||
el.querySelectorAll("path").forEach(path => {
|
||||
path.setAttribute("d", d);
|
||||
});
|
||||
|
||||
// Update label/control group position
|
||||
const ctrlGroup = el.querySelector(".edge-ctrl-group") as SVGGElement | null;
|
||||
if (ctrlGroup) ctrlGroup.setAttribute("transform", `translate(${midX},${midY})`);
|
||||
|
||||
// Update drag handle
|
||||
const dragHandle = el.querySelector(".edge-drag-handle") as SVGCircleElement | null;
|
||||
if (dragHandle) {
|
||||
dragHandle.setAttribute("cx", String(midX));
|
||||
dragHandle.setAttribute("cy", String(midY - 18));
|
||||
}
|
||||
|
||||
// Update splash circle for overflow edges
|
||||
const splash = el.querySelector(".edge-splash") as SVGCircleElement | null;
|
||||
if (splash) {
|
||||
splash.setAttribute("cx", String(from.x));
|
||||
splash.setAttribute("cy", String(from.y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Edge waypoint helpers ──────────────────────────────
|
||||
|
||||
private findEdgeAllocation(fromId: string, toId: string, edgeType: string): (OverflowAllocation | SpendingAllocation | SourceAllocation) | null {
|
||||
|
|
@ -2081,21 +2198,24 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const el = g as SVGGElement;
|
||||
const isSelected = el.dataset.nodeId === this.selectedNodeId;
|
||||
el.classList.toggle("selected", isSelected);
|
||||
// Update SVG rect stroke
|
||||
const bg = el.querySelector(".node-bg") as SVGElement | null;
|
||||
if (bg) {
|
||||
if (isSelected) {
|
||||
bg.setAttribute("stroke", "var(--rflows-selected)");
|
||||
bg.setAttribute("stroke-width", "3");
|
||||
} else {
|
||||
// Restore original color
|
||||
const node = this.nodes.find((n) => n.id === el.dataset.nodeId);
|
||||
if (node) {
|
||||
const origColor = this.getNodeBorderColor(node);
|
||||
bg.setAttribute("stroke", origColor);
|
||||
bg.setAttribute("stroke-width", node.type === "outcome" ? "1.5" : "2");
|
||||
bg.setAttribute("stroke-width", node.type === "outcome" ? "2" : "2");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update HTML card selected class
|
||||
const card = el.querySelector(".node-card") as HTMLElement | null;
|
||||
if (card) card.classList.toggle("selected", isSelected);
|
||||
});
|
||||
|
||||
// Edge selection highlight
|
||||
|
|
@ -2185,8 +2305,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
arrow = `<path class="port-arrow" d="M ${cx - 3} ${cy + (p.yFrac === 0 ? -4 : 8)} l 3 -4 l 3 4" style="fill:${p.color}" opacity="0.7"/>`;
|
||||
}
|
||||
return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${n.id}"${sideAttr}>
|
||||
<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
|
||||
<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" style="fill:${p.color};color:${p.color}"/>
|
||||
<circle class="port-hit" cx="${cx}" cy="${cy}" r="14" fill="transparent"/>
|
||||
<circle class="port-dot" cx="${cx}" cy="${cy}" r="7" style="fill:${p.color};color:${p.color}" stroke="white" stroke-width="2"/>
|
||||
${arrow}
|
||||
</g>`;
|
||||
}).join("");
|
||||
|
|
|
|||
Loading…
Reference in New Issue