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:
parent
91494a9c5c
commit
1f0ef59369
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue