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:
parent
c0d2276d46
commit
ee54ec219d
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue