feat: add flow tubes, stable particle animation, and interactive port wiring

Replace invisible particle-only flow visualization with colored 3D tubes
between layers, spread horizontally to avoid overlap. Particles now travel
along tube paths. Add click-to-wire interaction on I/O port chips with
visual feedback (glow/breathe/dim). Prevent animation restart on Automerge
sync by surgically updating flow elements in-place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 12:55:16 -08:00
parent 91494a9c5c
commit 1f0ef59369
1 changed files with 332 additions and 36 deletions

View File

@ -104,6 +104,12 @@ export class RStackTabBar extends HTMLElement {
#orbitLastY = 0;
#simSpeed = 1;
#simPlaying = true;
// Wiring mode state
#wiringActive = false;
#wiringSourceLayerId = "";
#wiringSourceFeedId = "";
#wiringSourceKind: FlowKind | null = null;
#escHandler: ((e: KeyboardEvent) => void) | null = null;
constructor() {
super();
@ -157,7 +163,11 @@ export class RStackTabBar extends HTMLElement {
/** Set the inter-layer flows (for stack view) */
setFlows(flows: LayerFlow[]) {
this.#flows = flows;
if (this.#viewMode === "stack") this.#render();
if (this.#viewMode === "stack") {
const scene = this.#shadow.getElementById("stack-scene");
if (scene) this.#updateFlowsInPlace(scene);
else this.#render();
}
}
/** Add a single layer */
@ -369,13 +379,16 @@ export class RStackTabBar extends HTMLElement {
const outChips = outFeeds.map(f => {
const contained = containedSet.has(f.id);
return `<span class="io-chip io-chip--out ${contained ? "io-chip--contained" : ""}"
data-feed-id="${f.id}" data-feed-kind="${f.kind}" data-layer-id="${layer.id}"
style="--chip-color:${FLOW_COLORS[f.kind]}" title="${f.description || f.name}">
<span class="io-dot"></span>${f.name}${contained ? '<span class="io-lock">🔒</span>' : ""}
</span>`;
}).join("");
const inChips = inKinds.map(k =>
`<span class="io-chip io-chip--in" style="--chip-color:${FLOW_COLORS[k]}" title="Accepts ${FLOW_LABELS[k]}">
`<span class="io-chip io-chip--in"
data-accept-kind="${k}" data-layer-id="${layer.id}"
style="--chip-color:${FLOW_COLORS[k]}" title="Accepts ${FLOW_LABELS[k]}">
<span class="io-dot"></span>${FLOW_LABELS[k]}
</span>`
).join("");
@ -398,28 +411,44 @@ export class RStackTabBar extends HTMLElement {
`;
});
// Build flow particles
// Build flow tubes and particles
const activeFlows = this.#flows.filter(f =>
f.active && layerZMap.has(f.sourceLayerId) && layerZMap.has(f.targetLayerId));
const flowSpacing = 35;
const flowStartX = -(activeFlows.length - 1) * flowSpacing / 2;
let tubesHtml = "";
let particlesHtml = "";
for (const flow of this.#flows) {
if (!flow.active) continue;
const srcZ = layerZMap.get(flow.sourceLayerId);
const tgtZ = layerZMap.get(flow.targetLayerId);
if (srcZ === undefined || tgtZ === undefined) continue;
activeFlows.forEach((flow, fi) => {
const srcZ = layerZMap.get(flow.sourceLayerId)!;
const tgtZ = layerZMap.get(flow.targetLayerId)!;
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
const particleCount = Math.max(2, Math.round(flow.strength * 6));
const minZ = Math.min(srcZ, tgtZ);
const maxZ = Math.max(srcZ, tgtZ);
const distance = maxZ - minZ;
const midZ = (minZ + maxZ) / 2;
const xOffset = activeFlows.length === 1 ? 0 : flowStartX + fi * flowSpacing;
const tubeWidth = 2 + Math.round(flow.strength * 4);
tubesHtml += `
<div class="flow-tube" data-flow-id="${flow.id}"
style="--color:${color}; width:${tubeWidth}px; height:${distance}px;
transform: translateZ(${midZ}px) rotateX(90deg);
margin-left:${xOffset - tubeWidth / 2}px;"></div>
`;
const particleCount = Math.max(2, Math.round(flow.strength * 6));
for (let p = 0; p < particleCount; p++) {
const delay = (p / particleCount) * animDuration;
particlesHtml += `
<div class="flow-particle"
data-flow-id="${flow.id}"
<div class="flow-particle" data-flow-id="${flow.id}"
style="--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color};
--duration:${animDuration}s; --delay:${delay}s;
--x-offset:${xOffset}px;
animation-play-state:${this.#simPlaying ? "running" : "paused"};"></div>
`;
}
}
});
// Flow legend
const activeKinds = new Set(this.#flows.map(f => f.kind));
@ -449,12 +478,13 @@ export class RStackTabBar extends HTMLElement {
<div class="stack-scene" id="stack-scene"
style="transform: rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg);">
${layersHtml}
${tubesHtml}
${particlesHtml}
</div>
</div>
<div class="stack-legend">${legendHtml}</div>
${scrubberHtml}
${this.#layers.length >= 2 ? `<div class="stack-hint">Drag between layers to create a flow · Drag empty space to orbit</div>` : ""}
${this.#layers.length >= 2 ? `<div class="stack-hint">Click an output port to wire, or drag between layers · Drag empty space to orbit</div>` : ""}
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
</div>
`;
@ -541,6 +571,165 @@ export class RStackTabBar extends HTMLElement {
this.#render();
}
// ── In-place flow update (prevents animation restart) ──
#updateFlowsInPlace(scene: HTMLElement) {
scene.querySelectorAll(".flow-tube, .flow-particle").forEach(el => el.remove());
const layerZMap = new Map<string, number>();
const layerSpacing = 80;
this.#layers.forEach((layer, i) => layerZMap.set(layer.id, i * layerSpacing));
const animDuration = 2 / this.#simSpeed;
const activeFlows = this.#flows.filter(f =>
f.active && layerZMap.has(f.sourceLayerId) && layerZMap.has(f.targetLayerId));
const flowSpacing = 35;
const flowStartX = -(activeFlows.length - 1) * flowSpacing / 2;
activeFlows.forEach((flow, fi) => {
const srcZ = layerZMap.get(flow.sourceLayerId)!;
const tgtZ = layerZMap.get(flow.targetLayerId)!;
const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8";
const minZ = Math.min(srcZ, tgtZ);
const maxZ = Math.max(srcZ, tgtZ);
const distance = maxZ - minZ;
const midZ = (minZ + maxZ) / 2;
const xOffset = activeFlows.length === 1 ? 0 : flowStartX + fi * flowSpacing;
const tubeWidth = 2 + Math.round(flow.strength * 4);
const tube = document.createElement("div");
tube.className = "flow-tube";
tube.dataset.flowId = flow.id;
tube.style.cssText = `--color:${color}; width:${tubeWidth}px; height:${distance}px; transform: translateZ(${midZ}px) rotateX(90deg); margin-left:${xOffset - tubeWidth / 2}px;`;
scene.appendChild(tube);
const particleCount = Math.max(2, Math.round(flow.strength * 6));
for (let p = 0; p < particleCount; p++) {
const delay = (p / particleCount) * animDuration;
const particle = document.createElement("div");
particle.className = "flow-particle";
particle.dataset.flowId = flow.id;
particle.style.cssText = `--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color}; --duration:${animDuration}s; --delay:${delay}s; --x-offset:${xOffset}px; animation-play-state:${this.#simPlaying ? "running" : "paused"};`;
scene.appendChild(particle);
}
});
this.#attachFlowEvents();
}
// ── Flow element events (shared between full render and in-place update) ──
#attachFlowEvents() {
this.#shadow.querySelectorAll<HTMLElement>(".flow-tube").forEach(tube => {
tube.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId: tube.dataset.flowId },
bubbles: true,
}));
});
tube.addEventListener("contextmenu", (e) => {
e.preventDefault();
const flowId = tube.dataset.flowId!;
const flow = this.#flows.find(f => f.id === flowId);
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
this.dispatchEvent(new CustomEvent("flow-remove", {
detail: { flowId },
bubbles: true,
}));
}
});
});
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
p.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId: p.dataset.flowId },
bubbles: true,
}));
});
p.addEventListener("contextmenu", (e) => {
e.preventDefault();
const flowId = p.dataset.flowId!;
const flow = this.#flows.find(f => f.id === flowId);
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
this.dispatchEvent(new CustomEvent("flow-remove", {
detail: { flowId },
bubbles: true,
}));
}
});
});
}
// ── Wiring mode ──
#enterWiring(layerId: string, feedId: string, kind: FlowKind) {
this.#wiringActive = true;
this.#wiringSourceLayerId = layerId;
this.#wiringSourceFeedId = feedId;
this.#wiringSourceKind = kind;
this.#applyWiringClasses();
this.#escHandler = (e: KeyboardEvent) => {
if (e.key === "Escape") this.#cancelWiring();
};
document.addEventListener("keydown", this.#escHandler);
}
#cancelWiring() {
this.#wiringActive = false;
this.#wiringSourceLayerId = "";
this.#wiringSourceFeedId = "";
this.#wiringSourceKind = null;
this.#shadow.querySelectorAll(".io-chip--wiring-source, .io-chip--wiring-target, .io-chip--wiring-dimmed")
.forEach(el => el.classList.remove("io-chip--wiring-source", "io-chip--wiring-target", "io-chip--wiring-dimmed"));
this.#shadow.querySelectorAll(".layer-plane--wiring-dimmed")
.forEach(el => el.classList.remove("layer-plane--wiring-dimmed"));
if (this.#escHandler) {
document.removeEventListener("keydown", this.#escHandler);
this.#escHandler = null;
}
}
#applyWiringClasses() {
const sourceKind = this.#wiringSourceKind;
const sourceLayerId = this.#wiringSourceLayerId;
const sourceFeedId = this.#wiringSourceFeedId;
// Output chips: highlight source, dim the rest
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--out").forEach(chip => {
if (chip.dataset.feedId === sourceFeedId && chip.dataset.layerId === sourceLayerId) {
chip.classList.add("io-chip--wiring-source");
} else {
chip.classList.add("io-chip--wiring-dimmed");
}
});
// Input chips: highlight compatible targets on other layers
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--in").forEach(chip => {
const layerId = chip.dataset.layerId!;
const acceptKind = chip.dataset.acceptKind as FlowKind;
if (layerId !== sourceLayerId && acceptKind === sourceKind) {
chip.classList.add("io-chip--wiring-target");
} else {
chip.classList.add("io-chip--wiring-dimmed");
}
});
// Dim layers with no compatible input chips
this.#shadow.querySelectorAll<HTMLElement>(".layer-plane").forEach(plane => {
const layerId = plane.dataset.layerId!;
if (layerId === sourceLayerId) return;
if (!plane.querySelector(".io-chip--wiring-target")) {
plane.classList.add("layer-plane--wiring-dimmed");
}
});
}
// ── Events ──
#attachEvents() {
@ -680,9 +869,10 @@ export class RStackTabBar extends HTMLElement {
}
});
// Drag-to-connect: mousedown starts a flow drag
// Drag-to-connect: mousedown starts a flow drag (skip if wiring)
plane.addEventListener("mousedown", (e) => {
if ((e as MouseEvent).button !== 0) return;
if (this.#wiringActive) return;
e.stopPropagation(); // prevent orbit
this.#flowDragSource = layerId;
plane.classList.add("flow-drag-source");
@ -757,31 +947,66 @@ export class RStackTabBar extends HTMLElement {
}, { passive: false });
}
// Flow particle clicks — select flow
this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
p.addEventListener("click", (e) => {
e.stopPropagation();
const flowId = p.dataset.flowId!;
this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId },
bubbles: true,
}));
});
// Flow tube + particle click/right-click events
this.#attachFlowEvents();
// Right-click to delete flow
p.addEventListener("contextmenu", (e) => {
e.preventDefault();
const flowId = p.dataset.flowId!;
const flow = this.#flows.find(f => f.id === flowId);
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
this.dispatchEvent(new CustomEvent("flow-remove", {
detail: { flowId },
bubbles: true,
}));
// Wiring mode: output chip clicks
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--out").forEach(chip => {
chip.style.cursor = "pointer";
chip.addEventListener("click", (e) => {
e.stopPropagation();
const feedId = chip.dataset.feedId!;
const feedKind = chip.dataset.feedKind as FlowKind;
const chipLayerId = chip.dataset.layerId!;
if (this.#wiringActive && this.#wiringSourceFeedId === feedId && this.#wiringSourceLayerId === chipLayerId) {
this.#cancelWiring();
} else {
if (this.#wiringActive) this.#cancelWiring();
this.#enterWiring(chipLayerId, feedId, feedKind);
}
});
});
// Wiring mode: input chip clicks
this.#shadow.querySelectorAll<HTMLElement>(".io-chip--in").forEach(chip => {
chip.addEventListener("click", (e) => {
e.stopPropagation();
if (!this.#wiringActive || !this.#wiringSourceKind) return;
const acceptKind = chip.dataset.acceptKind as FlowKind;
const targetLayerId = chip.dataset.layerId!;
if (targetLayerId === this.#wiringSourceLayerId) return;
if (acceptKind !== this.#wiringSourceKind) return;
const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
this.dispatchEvent(new CustomEvent("flow-create", {
detail: {
flow: {
id: flowId,
kind: this.#wiringSourceKind,
sourceLayerId: this.#wiringSourceLayerId,
targetLayerId,
strength: 0.5,
active: true,
meta: { sourceFeedId: this.#wiringSourceFeedId },
}
},
bubbles: true,
}));
this.#cancelWiring();
});
});
// Cancel wiring on empty space click
if (sceneContainer) {
sceneContainer.addEventListener("click", (e) => {
if (this.#wiringActive && !(e.target as HTMLElement).closest(".io-chip")) {
this.#cancelWiring();
}
});
}
// Time scrubber controls
const scrubberRange = this.#shadow.getElementById("scrubber-range") as HTMLInputElement | null;
const scrubberLabel = this.#shadow.getElementById("scrubber-label");
@ -1388,6 +1613,41 @@ const STYLES = `
opacity: 0.6;
}
/* ── Flow tubes ── */
.flow-tube {
position: absolute;
left: 50%;
top: 50%;
border-radius: 3px;
pointer-events: auto;
cursor: pointer;
transform-style: preserve-3d;
background: linear-gradient(to bottom, transparent, var(--color) 15%, var(--color) 85%, transparent);
opacity: 0.6;
transition: opacity 0.2s;
}
.flow-tube:hover {
opacity: 1;
}
.flow-tube::after {
content: '';
position: absolute;
inset: -2px;
border-radius: 3px;
background: var(--color);
filter: blur(4px);
opacity: 0;
animation: tube-pulse 3s ease-in-out infinite;
}
@keyframes tube-pulse {
0%, 100% { opacity: 0; }
50% { opacity: 0.4; }
}
/* ── Flow particles ── */
.flow-particle {
@ -1397,7 +1657,7 @@ const STYLES = `
border-radius: 50%;
background: var(--color);
box-shadow: 0 0 6px var(--color);
left: 50%;
left: calc(50% + var(--x-offset, 0px));
top: 50%;
margin-left: -3px;
margin-top: -3px;
@ -1414,6 +1674,42 @@ const STYLES = `
100% { transform: translateZ(var(--tgt-z)); opacity: 0; }
}
/* ── Wiring mode ── */
.io-chip--wiring-source {
animation: wiring-glow 1s ease-in-out infinite;
box-shadow: 0 0 12px var(--chip-color);
z-index: 10;
opacity: 1 !important;
}
@keyframes wiring-glow {
0%, 100% { box-shadow: 0 0 8px var(--chip-color); }
50% { box-shadow: 0 0 20px var(--chip-color); }
}
.io-chip--wiring-target {
animation: wiring-breathe 1.5s ease-in-out infinite;
border-style: solid !important;
cursor: pointer !important;
pointer-events: auto !important;
opacity: 1 !important;
}
@keyframes wiring-breathe {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; box-shadow: 0 0 10px var(--chip-color); }
}
.io-chip--wiring-dimmed {
opacity: 0.2 !important;
pointer-events: none !important;
}
.layer-plane--wiring-dimmed {
opacity: 0.3 !important;
}
/* ── Legend ── */
.stack-legend {