Merge branch 'dev'
This commit is contained in:
commit
034f4b0bb1
|
|
@ -375,7 +375,7 @@
|
||||||
/* ── Edge flow animation ──────────────────────────────── */
|
/* ── Edge flow animation ──────────────────────────────── */
|
||||||
@keyframes streamFlow { to { stroke-dashoffset: -24; } }
|
@keyframes streamFlow { to { stroke-dashoffset: -24; } }
|
||||||
.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; }
|
.edge-path-animated { stroke-dasharray: 8 4; animation: streamFlow 1s linear infinite; }
|
||||||
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.7s linear infinite; }
|
.edge-path-overflow { stroke-dasharray: 6 3; animation: streamFlow 0.5s linear infinite; stroke-linecap: round; }
|
||||||
.edge-glow { pointer-events: none; }
|
.edge-glow { pointer-events: none; }
|
||||||
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
|
.edge-group--highlight path:not(.edge-glow) { stroke-opacity: 1 !important; filter: brightness(1.4); }
|
||||||
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
|
.edge-group--highlight .edge-glow { stroke-opacity: 0.25 !important; }
|
||||||
|
|
@ -546,6 +546,44 @@
|
||||||
.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ }
|
.port-group[data-port-side="left"] .port-arrow { /* horizontal arrow left handled inline */ }
|
||||||
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
|
.port-group[data-port-side="right"] .port-arrow { /* horizontal arrow right handled inline */ }
|
||||||
|
|
||||||
|
/* ── Funnel fill animation ─────────────────────────── */
|
||||||
|
.funnel-fill-rect { transition: y 120ms ease-out, height 120ms ease-out; }
|
||||||
|
|
||||||
|
/* ── Simulation speed slider ──────────────────────── */
|
||||||
|
.flows-sim-speed {
|
||||||
|
position: absolute; bottom: 50px; right: 10px; z-index: 10;
|
||||||
|
flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
background: var(--rs-glass-bg); border-radius: 8px; padding: 8px 6px;
|
||||||
|
}
|
||||||
|
.flows-speed-slider {
|
||||||
|
writing-mode: vertical-lr; direction: rtl;
|
||||||
|
width: 24px; height: 100px; cursor: pointer;
|
||||||
|
accent-color: var(--rs-primary);
|
||||||
|
}
|
||||||
|
.flows-speed-label {
|
||||||
|
font-size: 9px; color: var(--rs-text-muted); white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Timeline bar ─────────────────────────────────── */
|
||||||
|
.flows-timeline {
|
||||||
|
position: absolute; bottom: 36px; left: 10px; right: 80px; z-index: 10;
|
||||||
|
align-items: center; gap: 8px;
|
||||||
|
background: var(--rs-glass-bg); border-radius: 6px; padding: 4px 10px;
|
||||||
|
}
|
||||||
|
.flows-timeline__track {
|
||||||
|
flex: 1; height: 4px; background: var(--rs-border-strong); border-radius: 2px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.flows-timeline__fill {
|
||||||
|
height: 100%; background: var(--rs-primary); border-radius: 2px;
|
||||||
|
transition: width 80ms linear;
|
||||||
|
}
|
||||||
|
.flows-timeline__tick {
|
||||||
|
font-size: 10px; color: var(--rs-text-secondary); white-space: nowrap; min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overflow splash animation */
|
||||||
|
.edge-splash { pointer-events: none; }
|
||||||
|
|
||||||
/* ── Light theme overrides ──────────────────────────── */
|
/* ── Light theme overrides ──────────────────────────── */
|
||||||
[data-theme="light"] {
|
[data-theme="light"] {
|
||||||
--rflows-source-text: #059669;
|
--rflows-source-text: #059669;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
private editingNodeId: string | null = null;
|
private editingNodeId: string | null = null;
|
||||||
private isSimulating = false;
|
private isSimulating = false;
|
||||||
private simInterval: ReturnType<typeof setInterval> | null = null;
|
private simInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private simSpeedMs = 100;
|
||||||
|
private simTickCount = 0;
|
||||||
private canvasInitialized = false;
|
private canvasInitialized = false;
|
||||||
|
|
||||||
// Edge selection & drag state
|
// Edge selection & drag state
|
||||||
|
|
@ -597,6 +599,16 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
|
<button class="flows-canvas-btn" data-canvas-action="zoom-in">+</button>
|
||||||
<button class="flows-canvas-btn" data-canvas-action="zoom-out">−</button>
|
<button class="flows-canvas-btn" data-canvas-action="zoom-out">−</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flows-sim-speed" id="sim-speed-container" style="display:${this.isSimulating ? "flex" : "none"}">
|
||||||
|
<input type="range" class="flows-speed-slider" id="sim-speed-slider" min="20" max="1000" value="${this.simSpeedMs}" step="10"/>
|
||||||
|
<span class="flows-speed-label" id="sim-speed-label">${this.simSpeedMs}ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="flows-timeline" id="sim-timeline" style="display:${this.isSimulating ? "flex" : "none"}">
|
||||||
|
<div class="flows-timeline__track">
|
||||||
|
<div class="flows-timeline__fill" id="timeline-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="flows-timeline__tick" id="timeline-tick">Tick 0</span>
|
||||||
|
</div>
|
||||||
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
|
<div class="flows-node-tooltip" id="node-tooltip" style="display:none"></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
@ -911,6 +923,17 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Speed slider
|
||||||
|
const speedSlider = this.shadow.getElementById("sim-speed-slider") as HTMLInputElement | null;
|
||||||
|
if (speedSlider) {
|
||||||
|
speedSlider.addEventListener("input", () => {
|
||||||
|
this.simSpeedMs = parseInt(speedSlider.value, 10);
|
||||||
|
const label = this.shadow.getElementById("sim-speed-label");
|
||||||
|
if (label) label.textContent = `${this.simSpeedMs}ms`;
|
||||||
|
if (this.isSimulating) this.startSimInterval();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Edge +/- buttons (delegated)
|
// Edge +/- buttons (delegated)
|
||||||
const edgeLayer = this.shadow.getElementById("edge-layer");
|
const edgeLayer = this.shadow.getElementById("edge-layer");
|
||||||
if (edgeLayer) {
|
if (edgeLayer) {
|
||||||
|
|
@ -1472,7 +1495,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
midY = waypoint.y;
|
midY = waypoint.y;
|
||||||
} else if (fromSide) {
|
} else if (fromSide) {
|
||||||
// Side port: curve outward horizontally first, then turn toward target
|
// Side port: curve outward horizontally first, then turn toward target
|
||||||
const outwardX = fromSide === "left" ? x1 - 60 : x1 + 60;
|
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}`;
|
d = `M ${x1} ${y1} C ${outwardX} ${y1}, ${x2} ${y1 + (y2 - y1) * 0.4}, ${x2} ${y2}`;
|
||||||
midX = (x1 + outwardX + x2) / 3;
|
midX = (x1 + outwardX + x2) / 3;
|
||||||
midY = (y1 + y2) / 2;
|
midY = (y1 + y2) / 2;
|
||||||
|
|
@ -1506,6 +1530,8 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
</g>`;
|
</g>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const overflowMul = dashed ? 1.3 : 1;
|
||||||
|
const finalStrokeW = strokeW * overflowMul;
|
||||||
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
const animClass = dashed ? "edge-path-overflow" : "edge-path-animated";
|
||||||
// Wider label box to fit dollar amounts
|
// Wider label box to fit dollar amounts
|
||||||
const labelW = Math.max(68, label.length * 7 + 36);
|
const labelW = Math.max(68, label.length * 7 + 36);
|
||||||
|
|
@ -1514,8 +1540,9 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
|
const dragHandle = `<circle cx="${midX}" cy="${midY - 18}" r="5" class="edge-drag-handle"/>`;
|
||||||
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
|
return `<g class="edge-group" data-from="${fromId}" data-to="${toId}" data-edge-type="${edgeType}">
|
||||||
${hitPath}
|
${hitPath}
|
||||||
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${finalStrokeW * 2.5}" stroke-opacity="0.12" class="edge-glow"/>
|
||||||
<path d="${d}" fill="none" stroke="${color}" stroke-width="${strokeW}" stroke-opacity="0.8" class="${animClass}"/>
|
<path d="${d}" fill="none" stroke="${color}" stroke-width="${finalStrokeW}" stroke-opacity="0.8" stroke-linecap="round" class="${animClass}"/>
|
||||||
|
${dashed ? `<circle cx="${x1}" cy="${y1}" r="${Math.max(4, finalStrokeW * 0.6)}" 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}
|
${dragHandle}
|
||||||
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
<g class="edge-ctrl-group" transform="translate(${midX},${midY})">
|
||||||
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
|
<rect x="${-halfW}" y="-12" width="${labelW}" height="24" rx="6" style="fill:var(--rs-bg-surface);stroke:var(--rs-bg-surface-raised)" stroke-width="1" opacity="0.9"/>
|
||||||
|
|
@ -2810,25 +2837,82 @@ class FolkFlowsApp extends HTMLElement {
|
||||||
const btn = this.shadow.getElementById("sim-btn");
|
const btn = this.shadow.getElementById("sim-btn");
|
||||||
if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";
|
if (btn) btn.textContent = this.isSimulating ? "Pause" : "Play";
|
||||||
|
|
||||||
|
// Show/hide speed slider and timeline
|
||||||
|
const speedContainer = this.shadow.getElementById("sim-speed-container");
|
||||||
|
const timelineContainer = this.shadow.getElementById("sim-timeline");
|
||||||
|
if (speedContainer) speedContainer.style.display = this.isSimulating ? "flex" : "none";
|
||||||
|
if (timelineContainer) timelineContainer.style.display = this.isSimulating ? "flex" : "none";
|
||||||
|
|
||||||
if (this.isSimulating) {
|
if (this.isSimulating) {
|
||||||
this.simInterval = setInterval(() => {
|
this.simTickCount = 0;
|
||||||
this.nodes = simulateTick(this.nodes);
|
this.startSimInterval();
|
||||||
this.updateCanvasLive();
|
|
||||||
}, 100);
|
|
||||||
} else {
|
} else {
|
||||||
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
|
if (this.simInterval) { clearInterval(this.simInterval); this.simInterval = null; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startSimInterval() {
|
||||||
|
if (this.simInterval) clearInterval(this.simInterval);
|
||||||
|
this.simInterval = setInterval(() => {
|
||||||
|
this.simTickCount++;
|
||||||
|
this.nodes = simulateTick(this.nodes);
|
||||||
|
this.updateCanvasLive();
|
||||||
|
}, this.simSpeedMs);
|
||||||
|
}
|
||||||
|
|
||||||
/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
|
/** Update canvas nodes and edges without full innerHTML rebuild during simulation */
|
||||||
private updateCanvasLive() {
|
private updateCanvasLive() {
|
||||||
const nodeLayer = this.shadow.getElementById("node-layer");
|
const nodeLayer = this.shadow.getElementById("node-layer");
|
||||||
if (!nodeLayer) return;
|
if (!nodeLayer) return;
|
||||||
|
|
||||||
// Rebuild node SVG content (can't do partial DOM updates easily for SVG text)
|
// Try to patch fill rects in-place for smooth CSS transitions
|
||||||
nodeLayer.innerHTML = this.renderAllNodes();
|
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 lipH = Math.round(h * 0.08);
|
||||||
|
const lipNotch = 14;
|
||||||
|
const zoneTop = lipH + lipNotch + 4;
|
||||||
|
const zoneBot = h - 4;
|
||||||
|
const zoneH = zoneBot - zoneTop;
|
||||||
|
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));
|
||||||
|
didPatch = true;
|
||||||
|
}
|
||||||
|
// Patch value text
|
||||||
|
const threshold = d.sufficientThreshold ?? d.maxThreshold;
|
||||||
|
const valText = nodeLayer.querySelector(`.funnel-value-text[data-node-id="${n.id}"]`) as SVGTextElement | null;
|
||||||
|
if (valText) {
|
||||||
|
valText.textContent = `$${Math.floor(d.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full rebuild for structural changes (new nodes, edges, text labels)
|
||||||
|
if (!didPatch) {
|
||||||
|
nodeLayer.innerHTML = this.renderAllNodes();
|
||||||
|
} else {
|
||||||
|
// Rebuild only things that change structurally
|
||||||
|
nodeLayer.innerHTML = this.renderAllNodes();
|
||||||
|
}
|
||||||
this.redrawEdges();
|
this.redrawEdges();
|
||||||
this.updateSufficiencyBadge();
|
this.updateSufficiencyBadge();
|
||||||
|
|
||||||
|
// Update timeline bar
|
||||||
|
const tickLabel = this.shadow.getElementById("timeline-tick");
|
||||||
|
const timelineFill = this.shadow.getElementById("timeline-fill");
|
||||||
|
if (tickLabel) tickLabel.textContent = `Tick ${this.simTickCount}`;
|
||||||
|
if (timelineFill) {
|
||||||
|
// Loop progress over 100 ticks
|
||||||
|
const pct = (this.simTickCount % 100);
|
||||||
|
timelineFill.style.width = `${pct}%`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSufficiencyBadge() {
|
private updateSufficiencyBadge() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue