+ `
${FLOW_LABELS[k]}
`
).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 += `
+
+ `;
+
+ const particleCount = Math.max(2, Math.round(flow.strength * 6));
for (let p = 0; p < particleCount; p++) {
const delay = (p / particleCount) * animDuration;
particlesHtml += `
-
`;
}
- }
+ });
// Flow legend
const activeKinds = new Set(this.#flows.map(f => f.kind));
@@ -449,12 +478,13 @@ export class RStackTabBar extends HTMLElement {
${layersHtml}
+ ${tubesHtml}
${particlesHtml}
${legendHtml}
${scrubberHtml}
- ${this.#layers.length >= 2 ? `Drag between layers to create a flow ยท Drag empty space to orbit
` : ""}
+ ${this.#layers.length >= 2 ? `Click an output port to wire, or drag between layers ยท Drag empty space to orbit
` : ""}
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
`;
@@ -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();
+ 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(".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(".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(".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(".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(".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(".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(".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(".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 {