/** * — 3D community relationship graph. * * Displays network nodes (people, companies, opportunities) * and edges in a 3D force-directed layout using 3d-force-graph (WebGL). * Left-drag pans, scroll zooms, right-drag orbits. */ interface WeightAccounting { delegatedAway: Record; // per authority: total weight delegated out receivedWeight: Record; // per authority: total weight received from others effectiveWeight: Record; // per authority: retained + received } interface GraphNode { id: string; name: string; type: "person" | "company" | "opportunity" | "rspace_user" | "space" | "module" | "feed"; workspace: string; role?: string; location?: string; description?: string; trustScore?: number; delegatedWeight?: number; // 0-1 normalized, computed from delegation edges baseWeights?: Record; // per-authority base weights (0-1 scale from server) weightAccounting?: WeightAccounting; // Layers mode fields layerId?: string; feedId?: string; feedKind?: string; // FlowKind moduleColor?: number; // 3d-force-graph internal properties x?: number; y?: number; z?: number; fx?: number; fy?: number; fz?: number; } interface GraphEdge { source: string | GraphNode; target: string | GraphNode; type: string; label?: string; weight?: number; authority?: string; flowKind?: string; // FlowKind, for cross_layer_flow edges strength?: number; // 0-1, for cross_layer_flow edges } type AxisPlane = "xy" | "xz" | "yz"; interface LayerInstance { moduleId: string; moduleName: string; moduleIcon: string; moduleColor: number; axis: AxisPlane; feeds: Array<{ id: string; name: string; kind: string }>; acceptsFeeds: string[]; } interface CrossLayerFlow { id: string; sourceLayerIdx: number; sourceFeedId: string; targetLayerIdx: number; targetFeedId: string; kind: string; // FlowKind strength: number; } const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number]; type AuthoritySelection = "all" | DelegationAuthority; // Authority display labels + colors (DB values unchanged) const AUTHORITY_DISPLAY: Record = { "gov-ops": { label: "Gov", color: "#a78bfa" }, // purple — governance "fin-ops": { label: "Econ", color: "#10b981" }, // green — economics "dev-ops": { label: "Tech", color: "#3b82f6" }, // blue — technology }; // Per-authority edge colors for "all" overlay mode const AUTHORITY_COLORS: Record = { "gov-ops": "#a78bfa", // purple "fin-ops": "#10b981", // green "dev-ops": "#3b82f6", // blue }; // Node colors by type const NODE_COLORS: Record = { person: 0x3b82f6, company: 0x22c55e, opportunity: 0xf59e0b, rspace_user: 0xa78bfa, space: 0x64748b, }; const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4]; // Layer/Flow constants (mirrored from lib/layer-types.ts for browser use) const LAYER_FLOW_COLORS: Record = { economic: "#4ade80", trust: "#c4b5fd", data: "#60a5fa", attention: "#fcd34d", governance: "#a78bfa", resource: "#6ee7b7", custom: "#94a3b8", }; const LAYER_FLOW_LABELS: Record = { economic: "Economic", trust: "Trust", data: "Data", attention: "Attention", governance: "Delegation", resource: "Resource", custom: "Custom", }; const MODULE_PALETTE = [0x6366f1, 0x10b981, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4]; // Edge colors/widths by type const EDGE_STYLES: Record = { work_at: { color: "#888888", width: 0.5, opacity: 0.35 }, point_of_contact: { color: "#c084fc", width: 1.5, opacity: 0.6, dashed: true }, collaborates: { color: "#f59e0b", width: 1, opacity: 0.4, dashed: true }, delegates_to: { color: "#a78bfa", width: 2, opacity: 0.6 }, member_of: { color: "#64748b", width: 0.4, opacity: 0.2 }, member_is: { color: "#38bdf8", width: 1.5, opacity: 0.5 }, default: { color: "#666666", width: 0.5, opacity: 0.25 }, }; class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private workspaces: any[] = []; private info: any = null; private nodes: GraphNode[] = []; private edges: GraphEdge[] = []; private filter: "all" | "person" | "company" | "opportunity" | "rspace_user" | "space" = "all"; private searchQuery = ""; private error = ""; private selectedNode: GraphNode | null = null; private trustMode = false; private authority: AuthoritySelection = "gov-ops"; private layoutMode: "force" | "rings" = "force"; private ringGuides: any[] = []; private demoDelegations: GraphEdge[] = []; private showMemberList = false; // Layers mode state private layersMode = false; private layerInstances: LayerInstance[] = []; private crossLayerFlows: CrossLayerFlow[] = []; private flowWiringSource: { layerIdx: number; feedId: string; feedKind: string } | null = null; private layersPanelOpen = false; private layerPlaneObjects: any[] = []; private savedGraphState: { nodes: GraphNode[]; edges: GraphEdge[] } | null = null; private savedCameraControls: { minPolar: number; maxPolar: number; minDist: number; maxDist: number } | null = null; // Multi-select delegation state private selectedDelegates: Map }> = new Map(); private delegateSearchQuery = ""; // 3D graph instance private graph: any = null; private graphContainer: HTMLDivElement | null = null; private resizeObserver: ResizeObserver | null = null; private companyColors: Map = new Map(); constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; this.renderDOM(); this.loadData(); } disconnectedCallback() { if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } if (this.graph) { this.graph._destructor?.(); this.graph = null; } } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rnetwork/); return match ? match[0] : ""; } private async loadData() { const base = this.getApiBase(); try { const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : ""; const [wsRes, infoRes, graphRes] = await Promise.all([ fetch(`${base}/api/workspaces`), fetch(`${base}/api/info`), fetch(`${base}/api/graph${trustParam}`), ]); if (wsRes.ok) this.workspaces = await wsRes.json(); if (infoRes.ok) this.info = await infoRes.json(); if (graphRes.ok) { const graph = await graphRes.json(); this.importGraph(graph); } } catch { /* offline */ } this.updateStatsBar(); this.updateAuthorityBar(); this.updateWorkspaceList(); this.updateGraphData(); } private async reloadWithAuthority(authority: AuthoritySelection) { this.authority = authority; this.trustMode = true; await this.loadData(); } private importGraph(graph: { nodes?: any[]; edges?: any[] }) { if (!graph.nodes?.length) return; const companyNames = new Map(); for (const n of graph.nodes) { if (n.type === "company") companyNames.set(n.id, n.label || n.name || "Unknown"); } const personCompany = new Map(); for (const e of graph.edges || []) { if (e.type === "works_at") personCompany.set(e.source, e.target); } const edgeTypeMap: Record = { works_at: "work_at", contact_of: "point_of_contact", involved_in: "point_of_contact", involves: "collaborates", }; this.nodes = graph.nodes.map((n: any) => { const name = n.label || n.name || "Unknown"; const companyId = personCompany.get(n.id); const workspace = n.type === "company" ? name : (companyId ? companyNames.get(companyId) || "" : ""); return { id: n.id, name, type: n.type, workspace, role: n.data?.role, location: n.data?.location, description: n.data?.email || n.data?.domain || n.data?.stage, trustScore: n.data?.trustScore, baseWeights: n.data?.trustScores || {}, } as GraphNode; }); this.edges = (graph.edges || []).map((e: any) => ({ source: e.source, target: e.target, type: edgeTypeMap[e.type] || e.type, label: e.label, weight: e.weight, authority: e.authority, } as GraphEdge)); // Weight accounting: per-authority delegated/received/effective const authorities = ["gov-ops", "fin-ops", "dev-ops"]; const acctMap = new Map(); for (const node of this.nodes) { acctMap.set(node.id, { delegatedAway: Object.fromEntries(authorities.map(a => [a, 0])), receivedWeight: Object.fromEntries(authorities.map(a => [a, 0])), effectiveWeight: Object.fromEntries(authorities.map(a => [a, 0])), }); } for (const e of this.edges) { if (e.type !== "delegates_to" || !e.authority) continue; const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; const w = e.weight || 0.5; const sAcct = acctMap.get(sid); const tAcct = acctMap.get(tid); if (sAcct) sAcct.delegatedAway[e.authority] = (sAcct.delegatedAway[e.authority] || 0) + w; if (tAcct) tAcct.receivedWeight[e.authority] = (tAcct.receivedWeight[e.authority] || 0) + w; } // Effective weight = retained base + received delegations (absolute tokens, ×100) const nodeById = new Map(this.nodes.map(n => [n.id, n])); acctMap.forEach((acct, nodeId) => { const node = nodeById.get(nodeId); for (const a of authorities) { const base = (node?.baseWeights?.[a] || 0) * 100; const away = acct.delegatedAway[a] * 100; const recv = acct.receivedWeight[a] * 100; acct.delegatedAway[a] = away; acct.receivedWeight[a] = recv; acct.effectiveWeight[a] = Math.max(0, base - away) + recv; } }); // Attach accounting to nodes let maxEW = 0; acctMap.forEach((acct) => { for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]); }); if (maxEW === 0) maxEW = 1; for (const node of this.nodes) { const acct = acctMap.get(node.id); if (acct) { node.weightAccounting = acct; const totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0); node.delegatedWeight = totalEW / (maxEW * 3); } } // Assign company colors const companies = this.nodes.filter(n => n.type === "company"); this.companyColors.clear(); companies.forEach((org, i) => { this.companyColors.set(org.id, COMPANY_PALETTE[i % COMPANY_PALETTE.length]); }); this.info = { ...this.info, member_count: this.nodes.filter(n => n.type === "person").length, company_count: this.nodes.filter(n => n.type === "company").length, rspace_member_count: this.nodes.filter(n => n.type === "rspace_user").length, }; } private getFilteredNodes(): GraphNode[] { let filtered = this.nodes; if (this.filter !== "all") { if (this.filter === "rspace_user") { // Include space hub nodes alongside members filtered = filtered.filter(n => n.type === "rspace_user" || n.type === "space"); } else { filtered = filtered.filter(n => n.type === this.filter); } } if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); filtered = filtered.filter(n => n.name.toLowerCase().includes(q) || n.workspace.toLowerCase().includes(q) || (n.role && n.role.toLowerCase().includes(q)) || (n.location && n.location.toLowerCase().includes(q)) || (n.description && n.description.toLowerCase().includes(q)) ); } return filtered; } private getTrustScore(nodeId: string): number { const node = this.nodes.find(n => n.id === nodeId); if (node?.trustScore != null) return Math.round(node.trustScore * 100); return Math.min(100, this.edges.filter(e => { const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; return sid === nodeId || tid === nodeId; }).length * 20); } 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) { const acct = node.weightAccounting; let ew: number; if (this.authority !== "all") { ew = acct.effectiveWeight[this.authority] || 0; } else { const vals = Object.values(acct.effectiveWeight); ew = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; } // Normalize against max in current filtered view — dramatic sizing const maxEW = this._currentMaxEffectiveWeight || 1; return 6 + (ew / maxEW) * 50; } if (node.type === "rspace_user") return 10; return 12; } private _currentMaxEffectiveWeight = 1; private getConnectedNodes(nodeId: string): GraphNode[] { const connIds = new Set(); for (const e of this.edges) { const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; if (sid === nodeId) connIds.add(tid); if (tid === nodeId) connIds.add(sid); } return this.nodes.filter(n => connIds.has(n.id)); } 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; } return NODE_COLORS[node.type] || NODE_COLORS.person; } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } // ── DOM structure (rendered once) ── private renderDOM() { this.shadow.innerHTML = `
${DELEGATION_AUTHORITIES.map(a => ``).join("")}
100%
People
Organizations
Works at
Point of contact
`; this.attachListeners(); this.initGraph3D(); } private attachListeners() { // Filter buttons this.shadow.querySelectorAll("[data-filter]").forEach(el => { el.addEventListener("click", () => { this.filter = (el as HTMLElement).dataset.filter as any; // Update active state this.shadow.querySelectorAll("[data-filter]").forEach(b => b.classList.remove("active")); el.classList.add("active"); this.updateGraphData(); }); }); // Search let searchTimeout: any; this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => this.updateGraphData(), 200); }); // Trust toggle this.shadow.getElementById("trust-toggle")?.addEventListener("click", () => { this.trustMode = !this.trustMode; const btn = this.shadow.getElementById("trust-toggle"); if (btn) btn.classList.toggle("active", this.trustMode); // Auto-enable rings when trust mode is turned on if (this.trustMode && this.layoutMode !== "rings") { this.layoutMode = "rings"; const ringsBtn = this.shadow.getElementById("rings-toggle"); if (ringsBtn) ringsBtn.classList.add("active"); } this.updateAuthorityBar(); this.loadData(); }); // Rings toggle this.shadow.getElementById("rings-toggle")?.addEventListener("click", () => { this.layoutMode = this.layoutMode === "rings" ? "force" : "rings"; const btn = this.shadow.getElementById("rings-toggle"); if (btn) btn.classList.toggle("active", this.layoutMode === "rings"); if (this.layoutMode === "rings") { this.applyRingLayout(); } else { this.clearFixedPositions(); this.removeRingGuides(); } }); // Authority buttons this.shadow.querySelectorAll("[data-authority]").forEach(el => { el.addEventListener("click", () => { const authority = (el as HTMLElement).dataset.authority as AuthoritySelection; this.reloadWithAuthority(authority); }); }); // Close detail panel this.shadow.getElementById("detail-panel")?.addEventListener("click", (e) => { if ((e.target as HTMLElement).id === "close-detail") { this.selectedNode = null; this.updateDetailPanel(); this.updateGraphData(); // refresh highlight } }); // Zoom controls — aggressive steps for fast navigation this.shadow.getElementById("zoom-in")?.addEventListener("click", () => { if (!this.graph) return; const cam = this.graph.camera(); const dist = cam.position.length(); this.animateCameraDistance(dist * 0.5); }); this.shadow.getElementById("zoom-out")?.addEventListener("click", () => { if (!this.graph) return; const cam = this.graph.camera(); const dist = cam.position.length(); this.animateCameraDistance(dist * 2); }); this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => { if (this.graph) this.graph.zoomToFit(300, 20); }); // Member list toggle this.shadow.getElementById("list-toggle")?.addEventListener("click", () => { this.showMemberList = !this.showMemberList; const btn = this.shadow.getElementById("list-toggle"); if (btn) btn.classList.toggle("active", this.showMemberList); this.updateMemberList(); // Resize graph when panel toggles requestAnimationFrame(() => { if (this.graph && this.graphContainer) { const rect = this.graphContainer.getBoundingClientRect(); if (rect.width > 0) this.graph.width(rect.width); } }); }); // Layers toggle this.shadow.getElementById("layers-toggle")?.addEventListener("click", () => { if (this.layersMode) { this.exitLayersMode(); } else { this.enterLayersMode(); } const btn = this.shadow.getElementById("layers-toggle"); if (btn) btn.classList.toggle("active", this.layersMode); }); } private animateCameraDistance(targetDist: number) { if (!this.graph) return; const cam = this.graph.camera(); const dir = cam.position.clone().normalize(); const target = dir.multiplyScalar(targetDist); this.graph.cameraPosition( { x: target.x, y: target.y, z: target.z }, undefined, 200 ); } // ── 3D Graph initialization ── private async initGraph3D() { const container = this.shadow.getElementById("graph-3d-container") as HTMLDivElement; if (!container) return; this.graphContainer = container; try { let ForceGraph3D = (window as any).ForceGraph3D; if (!ForceGraph3D) { // CDN script tag may have failed (503 etc) — try loading dynamically with fallback const cdns = [ "https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js", "https://cdn.jsdelivr.net/npm/3d-force-graph@1.73.4/dist/3d-force-graph.min.js", ]; for (const url of cdns) { try { await new Promise((resolve, reject) => { const s = document.createElement("script"); s.src = url; s.onload = () => resolve(); s.onerror = () => reject(); document.head.appendChild(s); }); ForceGraph3D = (window as any).ForceGraph3D; if (ForceGraph3D) break; } catch { /* try next CDN */ } } if (!ForceGraph3D) throw new Error("ForceGraph3D failed to load from all CDN sources"); } // Pre-load THREE so nodeThreeObject callback is synchronous. // Import from the same module the UMD build uses internally // to avoid "not an instance of THREE.Object3D" errors. const THREE = await import("three"); this._threeModule = THREE; (window as any).__THREE_CACHE__ = THREE; const graph = ForceGraph3D({ controlType: "orbit" })(container) .backgroundColor("rgba(0,0,0,0)") .showNavInfo(false) .nodeId("id") .nodeLabel("") // we use custom canvas objects .nodeThreeObject((node: GraphNode) => this.createNodeObject(node)) .nodeThreeObjectExtend(false) .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; } return EDGE_STYLES.delegates_to.color; } const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; 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) => { 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) => { if (link.type === "cross_layer_flow") return 3; return link.type === "delegates_to" ? 4 : 0; }) .linkDirectionalArrowRelPos(1) .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"; } 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 if (this.selectedNode?.id === node.id) { this.selectedNode = null; } else { this.selectedNode = node; } this.updateDetailPanel(); // Add to delegation selection if delegatable if (canDelegate && !this.selectedDelegates.has(node.id)) { this.selectedDelegates.set(node.id, { node, weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 }, }); this.renderDelegationPanel(); } this.updateGraphData(); }) .d3AlphaDecay(0.02) .d3VelocityDecay(0.3) .warmupTicks(120) .cooldownTicks(300); this.graph = graph; // Custom d3 forces for better clustering and readability // Stronger repulsion — hub nodes push harder const chargeForce = graph.d3Force('charge'); if (chargeForce) { chargeForce.strength((node: GraphNode) => { if (node.type === 'company' || node.type === 'space') return -300; return -120; }); } // Type-specific link distances const linkForce = graph.d3Force('link'); if (linkForce) { linkForce.distance((link: any) => { const type = link.type || ''; switch (type) { case 'work_at': return 40; case 'member_of': return 60; case 'member_is': return 20; case 'delegates_to': return 50; case 'collaborates': return 80; case 'point_of_contact': return 70; default: return 60; } }); } // Remap controls: LEFT=PAN, RIGHT=ROTATE, MIDDLE=DOLLY // THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2 const controls = graph.controls(); if (controls) { controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 }; controls.enableDamping = true; controls.dampingFactor = 0.12; controls.zoomSpeed = 2.5; // faster scroll wheel zoom } // ResizeObserver for responsive canvas this.resizeObserver = new ResizeObserver(() => { const rect = container.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { graph.width(rect.width); graph.height(rect.height); } }); this.resizeObserver.observe(container); // Initial size requestAnimationFrame(() => { const rect = container.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) { graph.width(rect.width); graph.height(rect.height); } }); } catch (e) { console.error("[folk-graph-viewer] Failed to load 3d-force-graph:", e); container.innerHTML = `

🕸️

Failed to load 3D graph renderer

${(e as Error).message || ""}

`; } } private createNodeObject(node: GraphNode): any { // Import THREE from the global importmap const THREE = (window as any).__THREE_CACHE__ || null; if (!THREE) { // Lazy-load THREE reference from import return this.createNodeObjectAsync(node); } return this.buildNodeMesh(THREE, node); } private _threeModule: any = null; private _pendingNodeRebuilds: GraphNode[] = []; private async createNodeObjectAsync(node: GraphNode): Promise { if (!this._threeModule) { this._threeModule = await import("three"); (window as any).__THREE_CACHE__ = this._threeModule; } return this.buildNodeMesh(this._threeModule, node); } private buildNodeMesh(THREE: any, node: GraphNode): any { const radius = this.getNodeRadius(node) / 10; // scale down for 3D world const color = this.getNodeColor(node); const isSelected = this.selectedNode?.id === node.id; // Create a group to hold sphere + label const group = new THREE.Group(); // Sphere geometry 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, }); 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); const ringMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.6, side: THREE.DoubleSide, }); const ring = new THREE.Mesh(ringGeo, ringMat); 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) { label.position.set(0, -(radius + 1.2), 0); group.add(label); } // Trust badge sprite — show per-authority effective weight in trust mode 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") { const ew = node.weightAccounting.effectiveWeight[this.authority] || 0; badgeText = String(Math.round(ew)); badgeColor = AUTHORITY_COLORS[this.authority] || "#7c3aed"; } else { const trust = this.getTrustScore(node.id); if (trust >= 0) badgeText = String(trust); } if (badgeText) { const badge = this.createBadgeSprite(THREE, badgeText, badgeColor); if (badge) { badge.position.set(radius - 0.2, radius - 0.2, 0); group.add(badge); } } } return group; } private createTextSprite(THREE: any, node: GraphNode): any { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return null; const text = node.name; 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" || node.type === "module") ? "600" : "500"} ${fontSize}px system-ui, sans-serif`; ctx.fillStyle = "#e2e8f0"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.shadowColor = "rgba(0,0,0,0.9)"; ctx.shadowBlur = 6; ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 256, 48); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(14, 3.5, 1); return sprite; } private createBadgeSprite(THREE: any, text: string, color = "#7c3aed"): any { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return null; canvas.width = 64; canvas.height = 64; ctx.beginPath(); ctx.arc(32, 32, 28, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.font = "bold 24px system-ui, sans-serif"; ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, 32, 33); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; const spriteMaterial = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.scale.set(1.5, 1.5, 1); return sprite; } // ── Data update (no DOM rebuild) ── private updateGraphData() { if (!this.graph) return; const filtered = this.getFilteredNodes(); const filteredIds = new Set(filtered.map(n => n.id)); // Compute max effective weight for filtered nodes (used by getNodeRadius) if (this.trustMode) { let maxEW = 0; for (const n of filtered) { if (!n.weightAccounting) continue; if (this.authority !== "all") { maxEW = Math.max(maxEW, n.weightAccounting.effectiveWeight[this.authority] || 0); } else { const vals = Object.values(n.weightAccounting.effectiveWeight); const avg = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; maxEW = Math.max(maxEW, avg); } } this._currentMaxEffectiveWeight = maxEW || 1; } // Filter edges to only include those between visible nodes let filteredEdges = this.edges.filter(e => { const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; return filteredIds.has(sid) && filteredIds.has(tid); }); // Authority-filtered edge view: in trust mode with specific authority, only show that authority's delegation edges if (this.trustMode && this.authority !== "all") { filteredEdges = filteredEdges.filter(e => { if (e.type !== "delegates_to") return true; return e.authority === this.authority; }); } this.graph.graphData({ nodes: filtered, links: filteredEdges, }); // Update legend visibility — members always visible when present, delegations only in trust mode const hasMembers = this.nodes.some(n => n.type === "rspace_user"); const membersLegend = this.shadow.getElementById("legend-members"); const delegatesLegend = this.shadow.getElementById("legend-delegates"); const authorityColors = this.shadow.getElementById("legend-authority-colors"); const spaceLegend = this.shadow.getElementById("legend-space"); const memberIsLegend = this.shadow.getElementById("legend-member-is"); if (membersLegend) membersLegend.style.display = hasMembers ? "" : "none"; if (spaceLegend) spaceLegend.style.display = hasMembers ? "" : "none"; if (memberIsLegend) memberIsLegend.style.display = (hasMembers && this.edges.some(e => e.type === "member_is")) ? "" : "none"; if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none"; if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none"; // Apply ring layout if active, otherwise fit view if (this.layoutMode === "rings") { this.applyRingLayout(); } setTimeout(() => { if (this.graph) this.graph.zoomToFit(400, 40); }, 500); // Refresh member list if visible if (this.showMemberList) this.updateMemberList(); } // ── Ring layout ── private applyRingLayout() { if (!this.graph) return; const data = this.graph.graphData(); const nodes = data.nodes as GraphNode[]; // Group by role const adminNodes = nodes.filter(n => n.role === "admin"); const memberNodes = nodes.filter(n => n.role === "member"); const viewerNodes = nodes.filter(n => n.role === "viewer"); const otherNodes = nodes.filter(n => n.type === "space"); // Place space hub at center for (const n of otherNodes) { n.fx = 0; n.fy = 0; n.fz = 0; } this.placeSphere(adminNodes, 30); this.placeSphere(memberNodes, 80); this.placeSphere(viewerNodes, 160); this.graph.graphData(data); this.graph.d3ReheatSimulation(); // Add ring guides this.removeRingGuides(); this.addRingGuides(); } private placeSphere(nodes: GraphNode[], radius: number) { const count = nodes.length; if (count === 0) return; // Fibonacci sphere — even distribution on a sphere surface const goldenAngle = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < count; i++) { const y = 1 - (2 * i) / (count - 1 || 1); // -1 to 1 const r = Math.sqrt(1 - y * y); const theta = goldenAngle * i; nodes[i].fx = Math.cos(theta) * r * radius; nodes[i].fy = y * radius; nodes[i].fz = Math.sin(theta) * r * radius; } } private clearFixedPositions() { if (!this.graph) return; const data = this.graph.graphData(); for (const n of data.nodes as GraphNode[]) { delete n.fx; delete n.fy; delete n.fz; } this.graph.graphData(data); this.graph.d3ReheatSimulation(); } private addRingGuides() { const THREE = this._threeModule; if (!THREE || !this.graph) return; const scene = this.graph.scene(); if (!scene) return; const sphereRadii = [ { r: 30, color: 0xa78bfa, label: "Admin" }, { r: 80, color: 0x10b981, label: "Member" }, { r: 160, color: 0x3b82f6, label: "Viewer" }, ]; for (const { r, color } of sphereRadii) { // Wireframe sphere guide const geometry = new THREE.SphereGeometry(r, 24, 16); const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.06, wireframe: true, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); this.ringGuides.push(mesh); } } private removeRingGuides() { const scene = this.graph?.scene(); if (!scene) return; for (const mesh of this.ringGuides) { scene.remove(mesh); mesh.geometry?.dispose(); mesh.material?.dispose(); } this.ringGuides = []; } // ── Incremental UI updates ── private updateStatsBar() { const bar = this.shadow.getElementById("stats-bar"); if (!bar || !this.info) return; const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length; const memberCount = this.info.rspace_member_count || 0; bar.innerHTML = `
${this.info.member_count || 0}
People
${this.info.company_count || 0}
Organizations
${memberCount > 0 ? `
${memberCount}
Members
` : ""}
${crossOrg}
Cross-org Links
`; } private updateAuthorityBar() { const bar = this.shadow.getElementById("authority-bar"); if (!bar) return; bar.classList.toggle("visible", this.trustMode); bar.querySelectorAll("[data-authority]").forEach(el => { const a = (el as HTMLElement).dataset.authority; el.classList.toggle("active", a === this.authority); }); } private updateDetailPanel() { const panel = this.shadow.getElementById("detail-panel"); if (!panel) return; if (!this.selectedNode) { panel.classList.remove("visible"); panel.innerHTML = ""; return; } const n = this.selectedNode; const connected = this.getConnectedNodes(n.id); const trust = (n.type !== "company" && n.type !== "space") ? this.getTrustScore(n.id) : -1; // Per-authority effective weight display let weightHtml = ""; if (n.weightAccounting && (n.type === "rspace_user" || n.type === "person")) { const acct = n.weightAccounting; // Find max effective weight across all nodes for bar scaling let detailMaxEW = 1; for (const nd of this.nodes) { if (!nd.weightAccounting) continue; for (const a of DELEGATION_AUTHORITIES) { detailMaxEW = Math.max(detailMaxEW, nd.weightAccounting.effectiveWeight[a] || 0); } } weightHtml = DELEGATION_AUTHORITIES.map(a => { const ew = Math.round(acct.effectiveWeight[a] || 0); const base = Math.round((n.baseWeights?.[a] || 0) * 100); const recv = Math.round(acct.receivedWeight[a] || 0); const away = Math.round(acct.delegatedAway[a] || 0); const disp = AUTHORITY_DISPLAY[a]; const barPct = Math.min((ew / detailMaxEW) * 100, 100); return `
${disp?.label || a}${ew}
base ${base} − ${away} delegated + ${recv} received
`; }).join(""); } panel.classList.add("visible"); panel.innerHTML = `
${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}
${this.esc(n.name)}
${this.esc(n.type === "company" ? "Organization" : n.type === "space" ? "Space" : n.type === "rspace_user" ? (n.role || "Member") : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
${n.description ? `

${this.esc(n.description)}

` : ""} ${trust >= 0 ? `
Trust Score${trust}
` : ""} ${weightHtml} ${connected.length > 0 ? `
Connected (${connected.length})
${connected.map(c => `
${this.esc(c.name)}${this.esc(c.role || c.type)}
`).join("")} ` : ""} `; } // ── Delegation panel (multi-select + fuzzy search) ── private renderDelegationPanel() { const panel = this.shadow.getElementById("deleg-panel"); if (!panel) return; if (this.selectedDelegates.size === 0) { panel.classList.remove("visible"); panel.innerHTML = ""; return; } // Compute remaining weight per authority const spent: Record = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 }; this.selectedDelegates.forEach(({ weights }) => { for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0; }); panel.classList.add("visible"); const rows: string[] = []; this.selectedDelegates.forEach(({ node, weights }, id) => { rows.push(`
${this.esc(node.name)}
${DELEGATION_AUTHORITIES.map(a => { const disp = AUTHORITY_DISPLAY[a]; const w = weights[a] || 0; return `${disp?.label} ${w}%`; }).join("")}
`); }); panel.innerHTML = `
Delegate Weight (${this.selectedDelegates.size} selected)
${rows.join("")}
${DELEGATION_AUTHORITIES.map(a => { const disp = AUTHORITY_DISPLAY[a]; return `${disp?.label}: ${Math.max(0, 100 - spent[a])}% left`; }).join(" · ")}
`; this.attachDelegationListeners(panel); } private attachDelegationListeners(panel: HTMLElement) { // Close this.shadow.getElementById("deleg-panel-close")?.addEventListener("click", () => { this.selectedDelegates.clear(); this.delegateSearchQuery = ""; this.renderDelegationPanel(); }); // Remove buttons panel.querySelectorAll("[data-remove]").forEach(el => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.remove!; this.selectedDelegates.delete(id); this.renderDelegationPanel(); }); }); // Per-delegate per-authority sliders panel.querySelectorAll("[data-did][data-auth]").forEach(el => { el.addEventListener("input", (e) => { const id = (el as HTMLElement).dataset.did!; const auth = (el as HTMLElement).dataset.auth!; const val = parseInt((e.target as HTMLInputElement).value); const entry = this.selectedDelegates.get(id); if (entry) { entry.weights[auth] = val; const valEl = panel.querySelector(`[data-did-val="${id}-${auth}"]`); if (valEl) valEl.textContent = val + "%"; // Update remaining display this.updateRemainingDisplay(panel); } }); }); // Fuzzy search const searchInput = this.shadow.getElementById("deleg-search") as HTMLInputElement | null; const resultsDiv = this.shadow.getElementById("deleg-results"); let searchTimeout: any; searchInput?.addEventListener("input", () => { this.delegateSearchQuery = searchInput.value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.updateSearchResults(searchInput.value, resultsDiv); }, 150); }); searchInput?.addEventListener("focus", () => { if (searchInput.value.trim()) { this.updateSearchResults(searchInput.value, resultsDiv); } }); // Confirm all this.shadow.getElementById("deleg-confirm-all")?.addEventListener("click", () => { this.confirmAllDelegations(); }); } private updateRemainingDisplay(panel: HTMLElement) { const spent: Record = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 }; this.selectedDelegates.forEach(({ weights }) => { for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0; }); const remainEl = panel.querySelector(".deleg-remaining"); if (remainEl) { remainEl.innerHTML = DELEGATION_AUTHORITIES.map(a => { const disp = AUTHORITY_DISPLAY[a]; const left = Math.max(0, 100 - spent[a]); return `${disp?.label}: ${left}% left`; }).join(" · "); } } private fuzzyMatch(query: string, name: string): boolean { const q = query.toLowerCase(); const n = name.toLowerCase(); // Substring match if (n.includes(q)) return true; // Fuzzy: all query chars appear in order let qi = 0; for (let i = 0; i < n.length && qi < q.length; i++) { if (n[i] === q[qi]) qi++; } return qi === q.length; } private updateSearchResults(query: string, resultsDiv: HTMLElement | null) { if (!resultsDiv) return; const q = query.trim(); if (!q) { resultsDiv.style.display = "none"; return; } const matches = this.nodes.filter(n => (n.type === "rspace_user" || n.type === "person") && !this.selectedDelegates.has(n.id) && this.fuzzyMatch(q, n.name) ).slice(0, 8); if (matches.length === 0) { resultsDiv.style.display = "none"; return; } resultsDiv.style.display = "block"; resultsDiv.innerHTML = matches.map(n => `
${this.esc(n.name)} ${n.role || n.type}
` ).join(""); resultsDiv.querySelectorAll("[data-add-id]").forEach(el => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.addId!; const node = this.nodes.find(n => n.id === id); if (node) { this.selectedDelegates.set(id, { node, weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 }, }); this.delegateSearchQuery = ""; this.renderDelegationPanel(); } }); }); } private confirmAllDelegations() { if (this.selectedDelegates.size === 0) return; // Ensure "me-demo" node exists if (!this.nodes.find(n => n.id === "me-demo")) { this.nodes.push({ id: "me-demo", name: "You", type: "rspace_user", workspace: "", role: "member", }); } // Create delegation edges for each selected delegate this.selectedDelegates.forEach(({ node, weights }) => { for (const a of DELEGATION_AUTHORITIES) { const weight = Math.round((weights[a] || 0) / 100 * 100) / 100; if (weight <= 0) continue; const edge: GraphEdge = { source: "me-demo", target: node.id, type: "delegates_to", weight, authority: a, }; this.edges.push(edge); this.demoDelegations.push(edge); } }); // Recompute weight accounting + refresh this.recomputeWeightAccounting(); this.selectedDelegates.clear(); this.delegateSearchQuery = ""; this.renderDelegationPanel(); this.updateGraphData(); } private recomputeWeightAccounting() { const authorities = ["gov-ops", "fin-ops", "dev-ops"]; const acctMap = new Map(); for (const node of this.nodes) { acctMap.set(node.id, { delegatedAway: Object.fromEntries(authorities.map(a => [a, 0])), receivedWeight: Object.fromEntries(authorities.map(a => [a, 0])), effectiveWeight: Object.fromEntries(authorities.map(a => [a, 0])), }); } for (const e of this.edges) { if (e.type !== "delegates_to" || !e.authority) continue; const sid = typeof e.source === "string" ? e.source : e.source.id; const tid = typeof e.target === "string" ? e.target : e.target.id; const w = e.weight || 0.5; const sAcct = acctMap.get(sid); const tAcct = acctMap.get(tid); if (sAcct) sAcct.delegatedAway[e.authority] = (sAcct.delegatedAway[e.authority] || 0) + w; if (tAcct) tAcct.receivedWeight[e.authority] = (tAcct.receivedWeight[e.authority] || 0) + w; } // Absolute token weights: base×100 - delegated + received const nodeById2 = new Map(this.nodes.map(n => [n.id, n])); acctMap.forEach((acct, nodeId) => { const node = nodeById2.get(nodeId); for (const a of authorities) { const base = (node?.baseWeights?.[a] || 0) * 100; const away = acct.delegatedAway[a] * 100; const recv = acct.receivedWeight[a] * 100; acct.delegatedAway[a] = away; acct.receivedWeight[a] = recv; acct.effectiveWeight[a] = Math.max(0, base - away) + recv; } }); let maxEW = 0; acctMap.forEach((acct) => { for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]); }); if (maxEW === 0) maxEW = 1; for (const node of this.nodes) { const acct = acctMap.get(node.id); if (acct) { node.weightAccounting = acct; const totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0); node.delegatedWeight = totalEW / (maxEW * 3); } } } private updateMemberList() { const panel = this.shadow.getElementById("member-list-panel"); if (!panel) return; if (!this.showMemberList) { panel.classList.remove("visible"); return; } panel.classList.add("visible"); const groups: { label: string; color: string; role: string; nodes: GraphNode[] }[] = [ { label: "Admins", color: "#a78bfa", role: "admin", nodes: [] }, { label: "Members", color: "#10b981", role: "member", nodes: [] }, { label: "Viewers", color: "#3b82f6", role: "viewer", nodes: [] }, ]; const filtered = this.getFilteredNodes().filter(n => n.type === "rspace_user" || n.type === "person"); for (const n of filtered) { const g = groups.find(g => g.role === n.role) || groups[2]; g.nodes.push(n); } // Sort each group by effective weight (descending) for (const g of groups) { g.nodes.sort((a, b) => { const aw = a.weightAccounting ? Object.values(a.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0; const bw = b.weightAccounting ? Object.values(b.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0; return bw - aw; }); } const authHeaders = DELEGATION_AUTHORITIES.map(a => { const d = AUTHORITY_DISPLAY[a]; return `${d?.label?.[0]}`; }).join(`/`); panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
${g.label} ${g.nodes.length}${authHeaders}
${g.nodes.map(n => { const wa = n.weightAccounting; const weights = wa ? DELEGATION_AUTHORITIES.map(a => { const disp = AUTHORITY_DISPLAY[a]; return `${Math.round(wa.effectiveWeight[a] || 0)}`; }).join(`/`) : ""; return `
${this.esc(n.name)} ${weights ? `${weights}` : ""}
`; }).join("")}
`).join(""); // Click to select/focus node in graph panel.querySelectorAll("[data-member-id]").forEach(el => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.memberId!; const node = this.nodes.find(n => n.id === id); if (node && this.graph) { this.selectedNode = node; this.updateDetailPanel(); this.updateGraphData(); // Fly camera to node if (node.x != null && node.y != null && node.z != null) { const dist = 60; this.graph.cameraPosition( { x: node.x + dist, y: node.y + dist * 0.3, z: node.z + dist }, { x: node.x, y: node.y, z: node.z }, 400 ); } } }); }); } private updateWorkspaceList() { const section = this.shadow.getElementById("workspace-section"); if (!section) return; if (this.workspaces.length === 0) { section.innerHTML = ""; return; } section.innerHTML = `
${this.space === "demo" ? "Organizations" : "Workspaces"}
${this.workspaces.map(ws => `
${this.esc(ws.name || ws.slug)}
${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges
`).join("")}
`; } // ══════════════════════════════════════════════════════════════ // ═══ 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);