/** * — 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 GraphNode { id: string; name: string; type: "person" | "company" | "opportunity" | "rspace_user"; workspace: string; role?: string; location?: string; description?: string; trustScore?: number; delegatedWeight?: number; // 0-1 normalized, computed from delegation edges // 3d-force-graph internal properties x?: number; y?: number; z?: number; } interface GraphEdge { source: string | GraphNode; target: string | GraphNode; type: string; label?: string; weight?: number; authority?: string; } const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const; type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number]; type AuthoritySelection = "all" | DelegationAuthority; // Per-authority edge colors for "all" overlay mode (governance, economics, technology) const AUTHORITY_COLORS: Record = { "gov-ops": "#a78bfa", // purple — governance decisions "fin-ops": "#fbbf24", // amber — economic/financial decisions "dev-ops": "#34d399", // green — technical decisions }; // Node colors by type const NODE_COLORS: Record = { person: 0x3b82f6, company: 0x22c55e, opportunity: 0xf59e0b, rspace_user: 0xa78bfa, }; const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 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 }, 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" = "all"; private searchQuery = ""; private error = ""; private selectedNode: GraphNode | null = null; private trustMode = false; private authority: AuthoritySelection = "gov-ops"; // 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, } 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)); // Compute delegatedWeight for every node from delegation edges const nodeWeights = new Map(); for (const e of this.edges) { if (e.type !== "delegates_to") 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; nodeWeights.set(sid, (nodeWeights.get(sid) || 0) + w); nodeWeights.set(tid, (nodeWeights.get(tid) || 0) + w); } const maxWeight = Math.max(...nodeWeights.values(), 1); for (const node of this.nodes) { const w = nodeWeights.get(node.id); if (w != null) { node.delegatedWeight = w / maxWeight; } } // 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, }; } private getFilteredNodes(): GraphNode[] { let filtered = this.nodes; if (this.filter !== "all") { 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 === "company") return 22; if (this.trustMode) { // Prefer edge-computed delegatedWeight, fall back to trustScore if (node.delegatedWeight != null) { return 6 + node.delegatedWeight * 24; } if (node.trustScore != null) { return 6 + node.trustScore * 24; } } return 12; } 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 === "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 = `
Network Graph${this.space === "demo" ? 'Demo' : ""}
${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); this.updateAuthorityBar(); this.loadData(); }); // 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 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.75); }); this.shadow.getElementById("zoom-out")?.addEventListener("click", () => { if (!this.graph) return; const cam = this.graph.camera(); const dist = cam.position.length(); this.animateCameraDistance(dist * 1.33); }); this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => { if (this.graph) this.graph.zoomToFit(400, 40); }); } 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, 600 ); } // ── 3D Graph initialization ── private async initGraph3D() { const container = this.shadow.getElementById("graph-3d-container") as HTMLDivElement; if (!container) return; this.graphContainer = container; try { const ForceGraph3D = (window as any).ForceGraph3D; if (!ForceGraph3D) throw new Error("ForceGraph3D not loaded — check UMD script tag"); // 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 === "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 === "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 ) .linkCurveRotation("rotation") .linkOpacity(0.6) .linkDirectionalArrowLength((link: GraphEdge) => 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 ) .linkDirectionalParticleColor((link: GraphEdge) => { if (link.type !== "delegates_to") return null; if (this.authority === "all" && link.authority) { return AUTHORITY_COLORS[link.authority] || "#c4b5fd"; } return "#c4b5fd"; }) .onNodeClick((node: GraphNode) => { if (this.selectedNode?.id === node.id) { this.selectedNode = null; } else { this.selectedNode = node; } this.updateDetailPanel(); this.updateGraphData(); // refresh highlight }) .d3AlphaDecay(0.03) .d3VelocityDecay(0.4) .warmupTicks(80) .cooldownTicks(200); this.graph = graph; // 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; } // 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 geometry = new THREE.SphereGeometry(radius, 16, 12); const material = new THREE.MeshLambertMaterial({ color, transparent: true, opacity: node.type === "company" ? 0.9 : 0.75, }); const sphere = new THREE.Mesh(geometry, material); group.add(sphere); // 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); } // 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 if (node.type !== "company") { const trust = this.getTrustScore(node.id); if (trust >= 0) { const badge = this.createBadgeSprite(THREE, String(trust)); 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 === "company" ? 28 : 24; canvas.width = 256; canvas.height = 64; ctx.font = `${node.type === "company" ? "600" : "400"} ${fontSize}px 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(text.length > 20 ? text.slice(0, 18) + "\u2026" : text, 128, 32); 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(8, 2, 1); return sprite; } private createBadgeSprite(THREE: any, text: string): 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 = "#7c3aed"; 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)); // Filter edges to only include those between visible nodes const 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); }); this.graph.graphData({ nodes: filtered, links: filteredEdges, }); // Update legend visibility for trust mode const membersLegend = this.shadow.getElementById("legend-members"); const delegatesLegend = this.shadow.getElementById("legend-delegates"); const authorityColors = this.shadow.getElementById("legend-authority-colors"); if (membersLegend) membersLegend.style.display = this.trustMode ? "" : "none"; if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none"; if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none"; // Fit view after data settles setTimeout(() => { if (this.graph) this.graph.zoomToFit(400, 40); }, 500); } // ── 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; bar.innerHTML = `
${this.info.member_count || 0}
People
${this.info.company_count || 0}
Organizations
${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" ? this.getTrustScore(n.id) : -1; panel.classList.add("visible"); panel.innerHTML = `
${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}
${this.esc(n.name)}
${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}
${n.description ? `

${this.esc(n.description)}

` : ""} ${trust >= 0 ? `
Trust Score${trust}
` : ""} ${connected.length > 0 ? `
Connected (${connected.length})
${connected.map(c => `
${this.esc(c.name)}${this.esc(c.role || c.type)}
`).join("")} ` : ""} `; } 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("")}
`; } } customElements.define("folk-graph-viewer", FolkGraphViewer);