diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 310e981..07f778c 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -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 ` ${f.name}${contained ? '๐Ÿ”’' : ""} `; }).join(""); const inChips = inKinds.map(k => - ` + ` ${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 {