diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 7fa4dce..48c8976 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -359,6 +359,8 @@ class FolkGraphViewer extends HTMLElement { } private getNodeRadius(node: GraphNode): number { + if (node.type === "module") return 30; + if (node.type === "feed") return 15; if (node.type === "company") return 22; if (node.type === "space") return 16; if (this.trustMode && node.weightAccounting) { @@ -392,6 +394,12 @@ class FolkGraphViewer extends HTMLElement { } private getNodeColor(node: GraphNode): number { + if (node.type === "module") return node.moduleColor || 0x6366f1; + if (node.type === "feed") { + const flowColor = LAYER_FLOW_COLORS[node.feedKind || "custom"]; + if (flowColor) return parseInt(flowColor.replace("#", ""), 16); + return 0x94a3b8; + } if (node.type === "company") { return this.companyColors.get(node.id) || NODE_COLORS.company; } @@ -926,6 +934,10 @@ class FolkGraphViewer extends HTMLElement { .linkSource("source") .linkTarget("target") .linkColor((link: GraphEdge) => { + if (link.type === "cross_layer_flow" && link.flowKind) { + return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8"; + } + if (link.type === "layer_internal") return "rgba(255,255,255,0.15)"; if (link.type === "delegates_to") { if (this.authority === "all" && link.authority) { return AUTHORITY_COLORS[link.authority] || EDGE_STYLES.delegates_to.color; @@ -936,29 +948,40 @@ class FolkGraphViewer extends HTMLElement { return style.color; }) .linkWidth((link: GraphEdge) => { + if (link.type === "cross_layer_flow") return 1.5 + (link.strength || 0.5) * 3; + if (link.type === "layer_internal") return 0.4; if (link.type === "delegates_to") { return 1 + (link.weight || 0.5) * 8; } const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; return style.width; }) - .linkCurvature((link: GraphEdge) => - link.type === "delegates_to" ? 0.15 : 0 - ) + .linkCurvature((link: GraphEdge) => { + if (link.type === "cross_layer_flow") return 0.2; + return link.type === "delegates_to" ? 0.15 : 0; + }) .linkCurveRotation("rotation") .linkOpacity(0.6) - .linkDirectionalArrowLength((link: GraphEdge) => - link.type === "delegates_to" ? 4 : 0 - ) + .linkDirectionalArrowLength((link: GraphEdge) => { + if (link.type === "cross_layer_flow") return 3; + return link.type === "delegates_to" ? 4 : 0; + }) .linkDirectionalArrowRelPos(1) - .linkDirectionalParticles((link: GraphEdge) => - link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0 - ) - .linkDirectionalParticleSpeed(0.004) - .linkDirectionalParticleWidth((link: GraphEdge) => - link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0 + .linkDirectionalParticles((link: GraphEdge) => { + if (link.type === "cross_layer_flow") return Math.ceil((link.strength || 0.5) * 3); + return link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0; + }) + .linkDirectionalParticleSpeed((link: GraphEdge) => + link.type === "cross_layer_flow" ? 0.006 : 0.004 ) + .linkDirectionalParticleWidth((link: GraphEdge) => { + if (link.type === "cross_layer_flow") return 1.5; + return link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0; + }) .linkDirectionalParticleColor((link: GraphEdge) => { + if (link.type === "cross_layer_flow" && link.flowKind) { + return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8"; + } if (link.type !== "delegates_to") return null; if (this.authority === "all" && link.authority) { return AUTHORITY_COLORS[link.authority] || "#c4b5fd"; @@ -966,6 +989,12 @@ class FolkGraphViewer extends HTMLElement { return "#c4b5fd"; }) .onNodeClick((node: GraphNode) => { + // Layers mode: handle feed wiring + if (this.layersMode && node.type === "feed") { + this.handleLayerNodeClick(node); + return; + } + const canDelegate = node.type === "rspace_user" || node.type === "person"; // Toggle detail panel for inspection @@ -1089,15 +1118,27 @@ class FolkGraphViewer extends HTMLElement { const group = new THREE.Group(); // Sphere geometry - const geometry = new THREE.SphereGeometry(radius, 16, 12); + const segments = (node.type === "module" || node.type === "feed") ? 24 : 16; + const geometry = new THREE.SphereGeometry(radius, segments, segments * 3 / 4); + const opacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75; const material = new THREE.MeshLambertMaterial({ color, transparent: true, - opacity: node.type === "company" ? 0.9 : 0.75, + opacity, }); const sphere = new THREE.Mesh(geometry, material); group.add(sphere); + // Glow ring for module hub nodes + if (node.type === "module") { + const glowGeo = new THREE.RingGeometry(radius + 0.2, radius + 0.6, 32); + const glowMat = new THREE.MeshBasicMaterial({ + color, transparent: true, opacity: 0.3, side: THREE.DoubleSide, + }); + const glow = new THREE.Mesh(glowGeo, glowMat); + group.add(glow); + } + // Selection ring if (isSelected) { const ringGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.5, 32); @@ -1111,6 +1152,27 @@ class FolkGraphViewer extends HTMLElement { group.add(ring); } + // Wiring highlight for compatible feed targets + if (this.layersMode && this.flowWiringSource && node.type === "feed" && node.layerId) { + const nodeLayerIdx = parseInt(node.layerId); + if (nodeLayerIdx !== this.flowWiringSource.layerIdx) { + const srcLayer = this.layerInstances[this.flowWiringSource.layerIdx]; + const tgtLayer = this.layerInstances[nodeLayerIdx]; + if (srcLayer && tgtLayer) { + const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId); + if (compat.size > 0) { + // Pulse ring for compatible target + const pulseGeo = new THREE.RingGeometry(radius + 0.4, radius + 0.8, 32); + const pulseMat = new THREE.MeshBasicMaterial({ + color: 0x4ade80, transparent: true, opacity: 0.5, side: THREE.DoubleSide, + }); + const pulseRing = new THREE.Mesh(pulseGeo, pulseMat); + group.add(pulseRing); + } + } + } + } + // Text label as sprite const label = this.createTextSprite(THREE, node); if (label) { @@ -1119,7 +1181,7 @@ class FolkGraphViewer extends HTMLElement { } // Trust badge sprite — show per-authority effective weight in trust mode - if (node.type !== "company" && node.type !== "space") { + if (node.type !== "company" && node.type !== "space" && node.type !== "module" && node.type !== "feed") { let badgeText = ""; let badgeColor = "#7c3aed"; if (this.trustMode && node.weightAccounting && this.authority !== "all") { @@ -1148,11 +1210,11 @@ class FolkGraphViewer extends HTMLElement { if (!ctx) return null; const text = node.name; - const fontSize = node.type === "company" ? 42 : 36; + const fontSize = node.type === "module" ? 44 : node.type === "company" ? 42 : node.type === "feed" ? 30 : 36; canvas.width = 512; canvas.height = 96; - ctx.font = `${node.type === "company" ? "600" : "500"} ${fontSize}px system-ui, sans-serif`; + ctx.font = `${(node.type === "company" || node.type === "module") ? "600" : "500"} ${fontSize}px system-ui, sans-serif`; ctx.fillStyle = "#e2e8f0"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; @@ -1828,6 +1890,606 @@ class FolkGraphViewer extends HTMLElement { `; } + + // ══════════════════════════════════════════════════════════════ + // ═══ LAYERS MODE ═══════════════════════════════════════════ + // ══════════════════════════════════════════════════════════════ + + private getAvailableModules(): Array<{ id: string; name: string; icon: string; feeds: any[]; acceptsFeeds: string[] }> { + const modList = (window as any).__rspaceModuleList as any[] || []; + return modList + .filter((m: any) => !m.hidden && (m.feeds?.length > 0 || m.acceptsFeeds?.length > 0)) + .map((m: any) => ({ + id: m.id, + name: m.name || m.id, + icon: m.icon || "\u{1F4E6}", + feeds: m.feeds || [], + acceptsFeeds: m.acceptsFeeds || [], + })); + } + + private getModuleOutputKinds(moduleId: string): Set { + const mods = (window as any).__rspaceModuleList as any[] || []; + const mod = mods.find((m: any) => m.id === moduleId); + if (!mod?.feeds) return new Set(); + return new Set(mod.feeds.map((f: any) => f.kind)); + } + + private getModuleInputKinds(moduleId: string): Set { + const mods = (window as any).__rspaceModuleList as any[] || []; + const mod = mods.find((m: any) => m.id === moduleId); + if (!mod?.acceptsFeeds) return new Set(); + return new Set(mod.acceptsFeeds); + } + + private getCompatibleFlowKinds(srcModuleId: string, tgtModuleId: string): Set { + const srcOutputs = this.getModuleOutputKinds(srcModuleId); + const tgtInputs = this.getModuleInputKinds(tgtModuleId); + if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set(); + const compatible = new Set(); + Array.from(srcOutputs).forEach(kind => { + if (tgtInputs.has(kind)) compatible.add(kind); + }); + return compatible; + } + + private enterLayersMode() { + if (this.layersMode) return; + this.layersMode = true; + this.layersPanelOpen = true; + + // Save current graph state + this.savedGraphState = { + nodes: [...this.nodes], + edges: [...this.edges], + }; + + // Save camera constraints + const controls = this.graph?.controls(); + if (controls) { + this.savedCameraControls = { + minPolar: controls.minPolarAngle, + maxPolar: controls.maxPolarAngle, + minDist: controls.minDistance, + maxDist: controls.maxDistance, + }; + // Free camera + controls.minPolarAngle = 0; + controls.maxPolarAngle = Math.PI; + controls.minDistance = 20; + controls.maxDistance = 2000; + } + + // Clear existing ring guides + this.removeRingGuides(); + + // Show layers panel + this.renderLayersPanel(); + this.rebuildLayerGraph(); + } + + private exitLayersMode() { + if (!this.layersMode) return; + this.layersMode = false; + this.layersPanelOpen = false; + this.flowWiringSource = null; + + // Remove layer plane objects + this.removeLayerPlanes(); + + // Restore camera constraints + const controls = this.graph?.controls(); + if (controls && this.savedCameraControls) { + controls.minPolarAngle = this.savedCameraControls.minPolar; + controls.maxPolarAngle = this.savedCameraControls.maxPolar; + controls.minDistance = this.savedCameraControls.minDist; + controls.maxDistance = this.savedCameraControls.maxDist; + } + this.savedCameraControls = null; + + // Restore original graph data + if (this.savedGraphState) { + this.nodes = this.savedGraphState.nodes; + this.edges = this.savedGraphState.edges; + this.savedGraphState = null; + } + + // Clear layer state + this.layerInstances = []; + this.crossLayerFlows = []; + + // Hide panels + const panel = this.shadow.getElementById("layers-panel"); + if (panel) panel.classList.remove("visible"); + const flowOverlay = this.shadow.getElementById("flow-dialog-overlay"); + if (flowOverlay) flowOverlay.style.display = "none"; + + this.updateGraphData(); + } + + private renderLayersPanel() { + const panel = this.shadow.getElementById("layers-panel"); + if (!panel) return; + + if (!this.layersPanelOpen) { + panel.classList.remove("visible"); + return; + } + panel.classList.add("visible"); + + const available = this.getAvailableModules(); + const selectedIds = new Set(this.layerInstances.map(l => l.moduleId)); + + const moduleButtons = available.map(m => { + const isSelected = selectedIds.has(m.id); + const atMax = this.layerInstances.length >= 3 && !isSelected; + return ``; + }).join(""); + + const layerRows = this.layerInstances.map((layer, idx) => { + const feedList = layer.feeds.map(f => + `${f.name}` + ).join(", "); + return ` +
+ ${layer.moduleIcon} + ${this.esc(layer.moduleName)} + + +
+ ${feedList ? `
Feeds: ${feedList}
` : ""} + `; + }).join(""); + + const flowRows = this.crossLayerFlows.map((flow, idx) => { + const srcLayer = this.layerInstances[flow.sourceLayerIdx]; + const tgtLayer = this.layerInstances[flow.targetLayerIdx]; + if (!srcLayer || !tgtLayer) return ""; + return `
+ \u25CF + ${srcLayer.moduleIcon} \u2192 ${tgtLayer.moduleIcon} (${LAYER_FLOW_LABELS[flow.kind] || flow.kind}) + ${Math.round(flow.strength * 100)}% + +
`; + }).join(""); + + panel.innerHTML = ` +
+ Layer Visualization + +
+
${moduleButtons}
+ ${layerRows} + ${this.crossLayerFlows.length > 0 ? `
Flows
${flowRows}` : ""} + ${this.layerInstances.length >= 2 ? '
Click a feed node, then click a compatible target to create a flow.
' : ""} + `; + + this.attachLayersPanelListeners(panel); + } + + private attachLayersPanelListeners(panel: HTMLElement) { + // Close + this.shadow.getElementById("layers-panel-close")?.addEventListener("click", () => { + this.layersPanelOpen = false; + this.renderLayersPanel(); + }); + + // Module pick buttons + panel.querySelectorAll("[data-mod-id]").forEach(el => { + el.addEventListener("click", () => { + const modId = (el as HTMLElement).dataset.modId!; + const existIdx = this.layerInstances.findIndex(l => l.moduleId === modId); + if (existIdx >= 0) { + // Deselect + this.layerInstances.splice(existIdx, 1); + // Remove any flows referencing this layer + this.crossLayerFlows = this.crossLayerFlows.filter(f => + f.sourceLayerIdx !== existIdx && f.targetLayerIdx !== existIdx + ).map(f => ({ + ...f, + sourceLayerIdx: f.sourceLayerIdx > existIdx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx, + targetLayerIdx: f.targetLayerIdx > existIdx ? f.targetLayerIdx - 1 : f.targetLayerIdx, + })); + } else if (this.layerInstances.length < 3) { + const mods = this.getAvailableModules(); + const mod = mods.find(m => m.id === modId); + if (mod) { + const axes: AxisPlane[] = ["xy", "xz", "yz"]; + const usedAxes = new Set(this.layerInstances.map(l => l.axis)); + const axis = axes.find(a => !usedAxes.has(a)) || "xy"; + this.layerInstances.push({ + moduleId: mod.id, + moduleName: mod.name, + moduleIcon: mod.icon, + moduleColor: MODULE_PALETTE[this.layerInstances.length % MODULE_PALETTE.length], + axis, + feeds: mod.feeds.map((f: any) => ({ id: f.id, name: f.name, kind: f.kind })), + acceptsFeeds: mod.acceptsFeeds, + }); + } + } + this.renderLayersPanel(); + this.rebuildLayerGraph(); + }); + }); + + // Axis select + panel.querySelectorAll("[data-layer-idx]").forEach(el => { + el.addEventListener("change", () => { + const idx = parseInt((el as HTMLElement).dataset.layerIdx!); + const val = (el as HTMLSelectElement).value as AxisPlane; + if (this.layerInstances[idx]) { + this.layerInstances[idx].axis = val; + this.rebuildLayerGraph(); + } + }); + }); + + // Remove layer + panel.querySelectorAll("[data-remove-idx]").forEach(el => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.removeIdx!); + this.layerInstances.splice(idx, 1); + this.crossLayerFlows = this.crossLayerFlows.filter(f => + f.sourceLayerIdx !== idx && f.targetLayerIdx !== idx + ).map(f => ({ + ...f, + sourceLayerIdx: f.sourceLayerIdx > idx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx, + targetLayerIdx: f.targetLayerIdx > idx ? f.targetLayerIdx - 1 : f.targetLayerIdx, + })); + this.renderLayersPanel(); + this.rebuildLayerGraph(); + }); + }); + + // Remove flow + panel.querySelectorAll("[data-remove-flow]").forEach(el => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.removeFlow!); + this.crossLayerFlows.splice(idx, 1); + this.renderLayersPanel(); + this.rebuildLayerGraph(); + }); + }); + } + + private getPlaneOffset(axis: AxisPlane, layerIdx: number): { x: number; y: number; z: number } { + const spacing = 100; + const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing; + switch (axis) { + case "xy": return { x: 0, y: 0, z: offset }; + case "xz": return { x: 0, y: offset, z: 0 }; + case "yz": return { x: offset, y: 0, z: 0 }; + } + } + + private rebuildLayerGraph() { + if (!this.graph) return; + + // Remove old planes + this.removeLayerPlanes(); + + if (this.layerInstances.length === 0) { + this.graph.graphData({ nodes: [], links: [] }); + return; + } + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + + // Build nodes/edges for each layer + for (let i = 0; i < this.layerInstances.length; i++) { + const layer = this.layerInstances[i]; + const offset = this.getPlaneOffset(layer.axis, i); + + // Hub node (module center) + const hubId = `layer-${i}-hub`; + nodes.push({ + id: hubId, + name: layer.moduleName, + type: "module", + workspace: "", + layerId: String(i), + moduleColor: layer.moduleColor, + fx: offset.x, + fy: offset.y, + fz: offset.z, + }); + + // Feed nodes arranged in circle around hub + const feedCount = layer.feeds.length; + const feedRadius = 25; + for (let j = 0; j < feedCount; j++) { + const feed = layer.feeds[j]; + const angle = (2 * Math.PI * j) / feedCount; + + // Calculate feed position on the plane + let fx: number, fy: number, fz: number; + switch (layer.axis) { + case "xy": + fx = offset.x + Math.cos(angle) * feedRadius; + fy = offset.y + Math.sin(angle) * feedRadius; + fz = offset.z; + break; + case "xz": + fx = offset.x + Math.cos(angle) * feedRadius; + fy = offset.y; + fz = offset.z + Math.sin(angle) * feedRadius; + break; + case "yz": + fx = offset.x; + fy = offset.y + Math.cos(angle) * feedRadius; + fz = offset.z + Math.sin(angle) * feedRadius; + break; + } + + const feedNodeId = `layer-${i}-feed-${feed.id}`; + nodes.push({ + id: feedNodeId, + name: feed.name, + type: "feed", + workspace: "", + layerId: String(i), + feedId: feed.id, + feedKind: feed.kind, + moduleColor: layer.moduleColor, + fx, fy, fz, + }); + + // Internal edge: hub → feed + edges.push({ + source: hubId, + target: feedNodeId, + type: "layer_internal", + }); + } + + // Add plane visual + this.addLayerPlane(layer, i, offset); + } + + // Cross-layer flow edges + for (const flow of this.crossLayerFlows) { + const srcFeedId = `layer-${flow.sourceLayerIdx}-feed-${flow.sourceFeedId}`; + const tgtFeedId = `layer-${flow.targetLayerIdx}-feed-${flow.targetFeedId}`; + edges.push({ + source: srcFeedId, + target: tgtFeedId, + type: "cross_layer_flow", + flowKind: flow.kind, + strength: flow.strength, + }); + } + + this.graph.graphData({ nodes, links: edges }); + + // Fly camera to overview + setTimeout(() => { + if (this.graph) this.graph.zoomToFit(500, 40); + }, 300); + } + + private addLayerPlane(layer: LayerInstance, idx: number, offset: { x: number; y: number; z: number }) { + const THREE = this._threeModule; + if (!THREE || !this.graph) return; + const scene = this.graph.scene(); + if (!scene) return; + + const planeSize = 80; + const geometry = new THREE.PlaneGeometry(planeSize, planeSize); + const colorHex = layer.moduleColor; + const material = new THREE.MeshBasicMaterial({ + color: colorHex, + transparent: true, + opacity: 0.06, + side: THREE.DoubleSide, + }); + const plane = new THREE.Mesh(geometry, material); + plane.position.set(offset.x, offset.y, offset.z); + + // Rotate based on axis + switch (layer.axis) { + case "xy": break; // default orientation faces Z + case "xz": plane.rotation.x = Math.PI / 2; break; // floor + case "yz": plane.rotation.y = Math.PI / 2; break; // side wall + } + + // Disable raycasting so clicks pass through + plane.raycast = () => {}; + + scene.add(plane); + this.layerPlaneObjects.push(plane); + + // Grid overlay + const gridHelper = new THREE.GridHelper(planeSize, 8, colorHex, colorHex); + gridHelper.material.transparent = true; + gridHelper.material.opacity = 0.08; + gridHelper.position.set(offset.x, offset.y, offset.z); + + switch (layer.axis) { + case "xy": gridHelper.rotation.x = Math.PI / 2; break; + case "xz": break; // GridHelper is already on XZ + case "yz": gridHelper.rotation.z = Math.PI / 2; break; + } + gridHelper.raycast = () => {}; + scene.add(gridHelper); + this.layerPlaneObjects.push(gridHelper); + + // Module name label sprite above plane + const labelCanvas = document.createElement("canvas"); + const ctx = labelCanvas.getContext("2d"); + if (ctx) { + labelCanvas.width = 512; + labelCanvas.height = 96; + ctx.font = "bold 36px system-ui, sans-serif"; + ctx.fillStyle = "#e2e8f0"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.shadowColor = "rgba(0,0,0,0.8)"; + ctx.shadowBlur = 4; + ctx.fillText(`${layer.moduleIcon} ${layer.moduleName}`, 256, 48); + + const texture = new THREE.CanvasTexture(labelCanvas); + texture.needsUpdate = true; + const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false }); + const sprite = new THREE.Sprite(spriteMat); + sprite.scale.set(20, 5, 1); + + // Position label above plane + switch (layer.axis) { + case "xy": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break; + case "xz": sprite.position.set(offset.x, offset.y + 5, offset.z - planeSize / 2 - 5); break; + case "yz": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break; + } + sprite.raycast = () => {}; + scene.add(sprite); + this.layerPlaneObjects.push(sprite); + } + } + + private removeLayerPlanes() { + const scene = this.graph?.scene(); + if (!scene) return; + for (const obj of this.layerPlaneObjects) { + scene.remove(obj); + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + if (obj.material.map) obj.material.map.dispose(); + obj.material.dispose(); + } + } + this.layerPlaneObjects = []; + } + + private handleLayerNodeClick(node: GraphNode) { + if (!node.layerId || !node.feedId || !node.feedKind) return; + const layerIdx = parseInt(node.layerId); + const layer = this.layerInstances[layerIdx]; + if (!layer) return; + + if (!this.flowWiringSource) { + // Start wiring — set source + this.flowWiringSource = { + layerIdx, + feedId: node.feedId, + feedKind: node.feedKind, + }; + // Highlight compatible targets by refreshing graph + this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); + this.graph?.refresh(); + } else { + // Check compatibility + const src = this.flowWiringSource; + if (src.layerIdx === layerIdx) { + // Same layer — cancel wiring + this.flowWiringSource = null; + this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); + this.graph?.refresh(); + return; + } + + const srcLayer = this.layerInstances[src.layerIdx]; + const tgtLayer = this.layerInstances[layerIdx]; + if (!srcLayer || !tgtLayer) return; + + const compatible = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId); + if (compatible.size === 0) { + // No compatible flows — cancel + this.flowWiringSource = null; + this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); + this.graph?.refresh(); + return; + } + + // Show flow creation dialog + this.showFlowDialog(src, { layerIdx, feedId: node.feedId, feedKind: node.feedKind }, compatible); + } + } + + private showFlowDialog( + src: { layerIdx: number; feedId: string; feedKind: string }, + tgt: { layerIdx: number; feedId: string; feedKind: string }, + compatibleKinds: Set, + ) { + const overlay = this.shadow.getElementById("flow-dialog-overlay"); + const dialog = this.shadow.getElementById("flow-dialog"); + if (!overlay || !dialog) return; + + const srcLayer = this.layerInstances[src.layerIdx]; + const tgtLayer = this.layerInstances[tgt.layerIdx]; + const kindsArr = Array.from(compatibleKinds); + + dialog.innerHTML = ` +
Create Flow
+
${srcLayer.moduleIcon} ${this.esc(srcLayer.moduleName)} \u2192 ${tgtLayer.moduleIcon} ${this.esc(tgtLayer.moduleName)}
+
Select flow type:
+ ${kindsArr.map((kind, i) => ` +
+ + ${LAYER_FLOW_LABELS[kind] || kind} +
+ `).join("")} +
+ Strength + + 50% +
+
+ + +
+ `; + overlay.style.display = "flex"; + + let selectedKind = kindsArr[0]; + + // Kind selection + dialog.querySelectorAll("[data-flow-kind]").forEach(el => { + el.addEventListener("click", () => { + dialog.querySelectorAll("[data-flow-kind]").forEach(e => e.classList.remove("selected")); + el.classList.add("selected"); + selectedKind = (el as HTMLElement).dataset.flowKind!; + }); + }); + + // Strength slider + const slider = this.shadow.getElementById("flow-strength") as HTMLInputElement; + const valEl = this.shadow.getElementById("flow-strength-val"); + slider?.addEventListener("input", () => { + if (valEl) valEl.textContent = slider.value + "%"; + }); + + // Cancel + this.shadow.getElementById("flow-cancel")?.addEventListener("click", () => { + overlay.style.display = "none"; + this.flowWiringSource = null; + this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n)); + this.graph?.refresh(); + }); + + // Create + this.shadow.getElementById("flow-create")?.addEventListener("click", () => { + const strength = parseInt(slider?.value || "50") / 100; + this.crossLayerFlows.push({ + id: `flow-${Date.now()}`, + sourceLayerIdx: src.layerIdx, + sourceFeedId: src.feedId, + targetLayerIdx: tgt.layerIdx, + targetFeedId: tgt.feedId, + kind: selectedKind, + strength, + }); + overlay.style.display = "none"; + this.flowWiringSource = null; + this.renderLayersPanel(); + this.rebuildLayerGraph(); + }); + } + } customElements.define("folk-graph-viewer", FolkGraphViewer);