feat(rnetwork): add Layers mode for multi-rApp 3D visualization

Adds a "Layers" toggle to the graph viewer that lets users select 2-3
rApps and visualize them as labeled planes on user-assignable axes
(XY/XZ/YZ) with hub+feed nodes, cross-layer flow wiring via compatible
FlowKinds, animated particle edges, and unrestricted camera orbit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-15 17:44:50 -07:00
parent c0d2276d46
commit ee54ec219d
1 changed files with 679 additions and 17 deletions

View File

@ -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 {
</div>
`;
}
// ══════════════════════════════════════════════════════════════
// ═══ 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<string> {
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<string> {
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<string> {
const srcOutputs = this.getModuleOutputKinds(srcModuleId);
const tgtInputs = this.getModuleInputKinds(tgtModuleId);
if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set();
const compatible = new Set<string>();
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 `<button class="module-pick-btn ${isSelected ? 'selected' : ''} ${atMax ? 'disabled' : ''}" data-mod-id="${m.id}" ${atMax ? 'disabled' : ''}>${m.icon} ${this.esc(m.name)}</button>`;
}).join("");
const layerRows = this.layerInstances.map((layer, idx) => {
const feedList = layer.feeds.map(f =>
`<span style="color:${LAYER_FLOW_COLORS[f.kind] || '#94a3b8'}">${f.name}</span>`
).join(", ");
return `
<div class="layer-row">
<span class="layer-row-icon">${layer.moduleIcon}</span>
<span class="layer-row-name">${this.esc(layer.moduleName)}</span>
<select class="axis-select" data-layer-idx="${idx}">
<option value="xy" ${layer.axis === "xy" ? "selected" : ""}>XY (front)</option>
<option value="xz" ${layer.axis === "xz" ? "selected" : ""}>XZ (floor)</option>
<option value="yz" ${layer.axis === "yz" ? "selected" : ""}>YZ (side)</option>
</select>
<button class="layer-row-remove" data-remove-idx="${idx}" title="Remove layer">\u2715</button>
</div>
${feedList ? `<div class="layer-feeds-hint">Feeds: ${feedList}</div>` : ""}
`;
}).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 `<div class="layer-row">
<span style="color:${LAYER_FLOW_COLORS[flow.kind] || '#94a3b8'}">\u25CF</span>
<span class="layer-row-name" style="font-size:11px">${srcLayer.moduleIcon} \u2192 ${tgtLayer.moduleIcon} (${LAYER_FLOW_LABELS[flow.kind] || flow.kind})</span>
<span style="font-size:10px;color:var(--rs-text-muted)">${Math.round(flow.strength * 100)}%</span>
<button class="layer-row-remove" data-remove-flow="${idx}" title="Remove flow">\u2715</button>
</div>`;
}).join("");
panel.innerHTML = `
<div class="layers-panel-header">
<span class="layers-panel-title">Layer Visualization</span>
<button class="layers-panel-close" id="layers-panel-close">\u2715</button>
</div>
<div class="module-picker">${moduleButtons}</div>
${layerRows}
${this.crossLayerFlows.length > 0 ? `<div style="font-size:11px;font-weight:600;color:var(--rs-text-muted);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em">Flows</div>${flowRows}` : ""}
${this.layerInstances.length >= 2 ? '<div class="layer-feeds-hint" style="margin-top:8px">Click a feed node, then click a compatible target to create a flow.</div>' : ""}
`;
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<string>,
) {
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 = `
<div class="flow-dialog-title">Create Flow</div>
<div class="flow-dialog-pair">${srcLayer.moduleIcon} ${this.esc(srcLayer.moduleName)} \u2192 ${tgtLayer.moduleIcon} ${this.esc(tgtLayer.moduleName)}</div>
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:8px">Select flow type:</div>
${kindsArr.map((kind, i) => `
<div class="flow-kind-option ${i === 0 ? 'selected' : ''}" data-flow-kind="${kind}">
<span class="flow-kind-dot" style="background:${LAYER_FLOW_COLORS[kind] || '#94a3b8'}"></span>
<span class="flow-kind-label">${LAYER_FLOW_LABELS[kind] || kind}</span>
</div>
`).join("")}
<div class="flow-strength-row">
<span class="flow-strength-label">Strength</span>
<input type="range" class="flow-strength-slider" id="flow-strength" min="10" max="100" value="50">
<span class="flow-strength-val" id="flow-strength-val">50%</span>
</div>
<div class="flow-dialog-actions">
<button class="flow-dialog-btn flow-dialog-cancel" id="flow-cancel">Cancel</button>
<button class="flow-dialog-btn flow-dialog-create" id="flow-create">Create</button>
</div>
`;
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);