From 1f4b28aee1c1ad261590d40b0320992c86503876 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 20:25:16 -0700 Subject: [PATCH] feat(rnetwork): convert graph viewer from 2D SVG to 3D WebGL Replace custom SVG force-directed layout with 3d-force-graph (Three.js) loaded via CDN importmap. Left-drag pans, scroll zooms, right-drag orbits. Nodes rendered as colored spheres with sprite labels and trust badges. Co-Authored-By: Claude Opus 4.6 --- .../rnetwork/components/folk-graph-viewer.ts | 1151 ++++++++--------- modules/rnetwork/mod.ts | 12 + vite.config.ts | 1 + 3 files changed, 526 insertions(+), 638 deletions(-) diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index f23229a..9164af4 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -1,9 +1,9 @@ /** - * — community relationship graph. + * — 3D community relationship graph. * * Displays network nodes (people, companies, opportunities) - * and edges in a force-directed layout with search and filtering. - * Interactive canvas with pan/zoom/drag (rFlows-style). + * and edges in a 3D force-directed layout using 3d-force-graph (WebGL). + * Left-drag pans, scroll zooms, right-drag orbits. */ interface GraphNode { @@ -15,11 +15,15 @@ interface GraphNode { location?: string; description?: string; trustScore?: number; + // 3d-force-graph internal properties + x?: number; + y?: number; + z?: number; } interface GraphEdge { - source: string; - target: string; + source: string | GraphNode; + target: string | GraphNode; type: string; label?: string; weight?: number; @@ -28,6 +32,25 @@ interface GraphEdge { const DELEGATION_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const; type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number]; +// 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 = ""; @@ -42,25 +65,11 @@ class FolkGraphViewer extends HTMLElement { private trustMode = false; private authority: DelegationAuthority = "voting"; - // Canvas state - private canvasZoom = 1; - private canvasPanX = 0; - private canvasPanY = 0; - private draggingNodeId: string | null = null; - private dragStartX = 0; - private dragStartY = 0; - private dragNodeStartX = 0; - private dragNodeStartY = 0; - private isPanning = false; - private panStartX = 0; - private panStartY = 0; - private panStartPanX = 0; - private panStartPanY = 0; - private isTouchPanning = false; - private lastTouchCenter: { x: number; y: number } | null = null; - private lastTouchDist: number | null = null; - private nodePositions: Record = {}; - private layoutDirty = true; // recompute layout when true + // 3D graph instance + private graph: any = null; + private graphContainer: HTMLDivElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private companyColors: Map = new Map(); constructor() { super(); @@ -69,8 +78,19 @@ class FolkGraphViewer extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; - this.render(); // Show loading state - this.loadData(); // Async — will re-render when data arrives + 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 { @@ -95,37 +115,32 @@ class FolkGraphViewer extends HTMLElement { this.importGraph(graph); } } catch { /* offline */ } - this.layoutDirty = true; - this.render(); - requestAnimationFrame(() => this.fitView()); + this.updateStatsBar(); + this.updateAuthorityBar(); + this.updateWorkspaceList(); + this.updateGraphData(); } - /** Reload graph with trust data for selected authority */ private async reloadWithAuthority(authority: DelegationAuthority) { this.authority = authority; this.trustMode = true; - this.layoutDirty = true; await this.loadData(); } - /** Map server /api/graph response to client GraphNode/GraphEdge format */ private importGraph(graph: { nodes?: any[]; edges?: any[] }) { if (!graph.nodes?.length) return; - // Build company name lookup for workspace assignment const companyNames = new Map(); for (const n of graph.nodes) { if (n.type === "company") companyNames.set(n.id, n.label || n.name || "Unknown"); } - // Build person→company lookup from edges const personCompany = new Map(); for (const e of graph.edges || []) { if (e.type === "works_at") personCompany.set(e.source, e.target); } - // Edge type normalization: server → client - const edgeTypeMap: Record = { + const edgeTypeMap: Record = { works_at: "work_at", contact_of: "point_of_contact", involved_in: "point_of_contact", @@ -144,6 +159,7 @@ class FolkGraphViewer extends HTMLElement { role: n.data?.role, location: n.data?.location, description: n.data?.email || n.data?.domain || n.data?.stage, + trustScore: n.data?.trustScore, } as GraphNode; }); @@ -152,9 +168,16 @@ class FolkGraphViewer extends HTMLElement { target: e.target, type: edgeTypeMap[e.type] || e.type, label: e.label, + weight: e.weight, } as GraphEdge)); - // Update info stats from actual data + // 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, @@ -180,121 +203,20 @@ class FolkGraphViewer extends HTMLElement { return filtered; } - private ensureLayout() { - if (!this.layoutDirty && Object.keys(this.nodePositions).length > 0) return; - const W = 800, H = 600; - this.nodePositions = this.computeForceLayout(this.nodes, this.edges, W, H); - this.layoutDirty = false; - } - - private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record { - const pos: Record = {}; - - // Collect company nodes and position them evenly around center - const companies = nodes.filter(n => n.type === "company"); - const orgCenters: Record = {}; - const cx = W / 2, cy = H / 2; - const orbitR = Math.min(W, H) * 0.3; - - companies.forEach((org, i) => { - const angle = -Math.PI / 2 + (2 * Math.PI * i) / Math.max(companies.length, 1); - orgCenters[org.id] = { x: cx + orbitR * Math.cos(angle), y: cy + orbitR * Math.sin(angle) }; - }); - - for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p }; - - // Build workspace→orgId lookup from actual data - const orgNameToId: Record = {}; - for (const org of companies) orgNameToId[org.name] = org.id; - - const peopleByOrg: Record = {}; - for (const n of nodes) { - if (n.type === "person") { - const oid = orgNameToId[n.workspace]; - if (oid) { (peopleByOrg[oid] ??= []).push(n); } - } - } - for (const [oid, people] of Object.entries(peopleByOrg)) { - const c = orgCenters[oid]; - if (!c) continue; - const base = Math.atan2(c.y - cy, c.x - cx); - const spread = Math.PI * 0.8; - people.forEach((p, i) => { - const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1); - pos[p.id] = { x: c.x + 110 * Math.cos(angle), y: c.y + 110 * Math.sin(angle) }; - }); - } - - // Position any remaining nodes (opportunities, unlinked people) randomly near center - for (const n of nodes) { - if (!pos[n.id]) { - pos[n.id] = { x: cx + (Math.random() - 0.5) * W * 0.4, y: cy + (Math.random() - 0.5) * H * 0.4 }; - } - } - - // Run force iterations - const allIds = nodes.map(n => n.id).filter(id => pos[id]); - for (let iter = 0; iter < 80; iter++) { - const force: Record = {}; - for (const id of allIds) force[id] = { fx: 0, fy: 0 }; - - // Repulsion between all nodes - for (let i = 0; i < allIds.length; i++) { - for (let j = i + 1; j < allIds.length; j++) { - const a = pos[allIds[i]], b = pos[allIds[j]]; - let dx = b.x - a.x, dy = b.y - a.y; - const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); - const repel = 3000 / (dist * dist); - dx /= dist; dy /= dist; - force[allIds[i]].fx -= dx * repel; - force[allIds[i]].fy -= dy * repel; - force[allIds[j]].fx += dx * repel; - force[allIds[j]].fy += dy * repel; - } - } - - // Attraction along edges - for (const edge of edges) { - const a = pos[edge.source], b = pos[edge.target]; - if (!a || !b) continue; - const dx = b.x - a.x, dy = b.y - a.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const idealLen = edge.type === "work_at" ? 100 : 200; - const attract = (dist - idealLen) * 0.01; - const ux = dx / Math.max(dist, 1), uy = dy / Math.max(dist, 1); - if (force[edge.source]) { force[edge.source].fx += ux * attract; force[edge.source].fy += uy * attract; } - if (force[edge.target]) { force[edge.target].fx -= ux * attract; force[edge.target].fy -= uy * attract; } - } - - // Center gravity - for (const id of allIds) { - const p = pos[id]; - force[id].fx += (W / 2 - p.x) * 0.002; - force[id].fy += (H / 2 - p.y) * 0.002; - } - - // Apply forces with damping - const damping = 0.4 * (1 - iter / 80); - for (const id of allIds) { - pos[id].x += force[id].fx * damping; - pos[id].y += force[id].fy * damping; - } - } - return pos; - } - private getTrustScore(nodeId: string): number { const node = this.nodes.find(n => n.id === nodeId); if (node?.trustScore != null) return Math.round(node.trustScore * 100); - // Fallback: edge-count heuristic - return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20); + 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); } - /** Get node radius based on trust score (8px min, 30px max for users) */ private getNodeRadius(node: GraphNode): number { if (node.type === "company") return 22; if (node.trustScore != null && this.trustMode) { - return 8 + (node.trustScore * 22); // 8px min → 30px max + return 8 + (node.trustScore * 22); } return 12; } @@ -302,282 +224,35 @@ class FolkGraphViewer extends HTMLElement { private getConnectedNodes(nodeId: string): GraphNode[] { const connIds = new Set(); for (const e of this.edges) { - if (e.source === nodeId) connIds.add(e.target); - if (e.target === nodeId) connIds.add(e.source); + 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 renderDetailPanel(): string { - if (!this.selectedNode) return ""; - const n = this.selectedNode; - const connected = this.getConnectedNodes(n.id); - const trust = n.type === "person" ? this.getTrustScore(n.id) : -1; - return ` -
-
- ${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 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; } - // ── Canvas transform helpers ── - - private updateCanvasTransform() { - const g = this.shadow.getElementById("canvas-transform"); - if (g) g.setAttribute("transform", `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`); + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; } - private fitView() { - const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; - if (!svg) return; - this.ensureLayout(); - const filtered = this.getFilteredNodes(); - const positions = filtered.map(n => this.nodePositions[n.id]).filter(Boolean); - if (positions.length === 0) return; + // ── DOM structure (rendered once) ── - const pad = 60; - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const p of positions) { - if (p.x < minX) minX = p.x; - if (p.y < minY) minY = p.y; - if (p.x > maxX) maxX = p.x; - if (p.y > maxY) maxY = p.y; - } - // Include cluster radius for org nodes - minX -= 40; minY -= 40; maxX += 40; maxY += 40; - - const contentW = maxX - minX; - const contentH = maxY - minY; - const rect = svg.getBoundingClientRect(); - const svgW = rect.width || 800; - const svgH = rect.height || 600; - - const zoom = Math.min((svgW - pad * 2) / Math.max(contentW, 1), (svgH - pad * 2) / Math.max(contentH, 1), 2); - this.canvasZoom = Math.max(0.1, Math.min(zoom, 4)); - this.canvasPanX = (svgW / 2) - ((minX + maxX) / 2) * this.canvasZoom; - this.canvasPanY = (svgH / 2) - ((minY + maxY) / 2) * this.canvasZoom; - this.updateCanvasTransform(); - this.updateZoomDisplay(); - } - - private updateZoomDisplay() { - const el = this.shadow.getElementById("zoom-level"); - if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`; - } - - // ── Incremental node update (drag) ── - - private updateNodePosition(nodeId: string) { - const pos = this.nodePositions[nodeId]; - if (!pos) return; - const g = this.shadow.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null; - if (!g) return; - - // Update all circles and texts that use absolute coordinates - const node = this.nodes.find(n => n.id === nodeId); - if (!node) return; - const isOrg = node.type === "company"; - const radius = isOrg ? 22 : 12; - - // Update circles - g.querySelectorAll("circle").forEach(circle => { - const rAttr = circle.getAttribute("r"); - const r = parseFloat(rAttr || "0"); - circle.setAttribute("cx", String(pos.x)); - circle.setAttribute("cy", String(pos.y)); - }); - - // Update text elements by relative offset from center - const texts = g.querySelectorAll("text"); - if (isOrg) { - // [0] inner label, [1] name below, [2] description, [3+] trust badge - if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + 4)); } - if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 13)); } - if (texts[2]) { texts[2].setAttribute("x", String(pos.x)); texts[2].setAttribute("y", String(pos.y + radius + 26)); } - } else { - // [0] name below, [1] role/location, [2] trust text - if (texts[0]) { texts[0].setAttribute("x", String(pos.x)); texts[0].setAttribute("y", String(pos.y + radius + 13)); } - if (texts[1]) { texts[1].setAttribute("x", String(pos.x)); texts[1].setAttribute("y", String(pos.y + radius + 24)); } - if (texts[2]) { texts[2].setAttribute("x", String(pos.x + radius - 2)); texts[2].setAttribute("y", String(pos.y - radius + 5.5)); } - } - - // Update connected edges - for (const edge of this.edges) { - if (edge.source !== nodeId && edge.target !== nodeId) continue; - const sp = this.nodePositions[edge.source]; - const tp = this.nodePositions[edge.target]; - if (!sp || !tp) continue; - - // Find the edge line element - const lines = this.shadow.querySelectorAll(`#edge-layer line`); - for (const line of lines) { - const x1 = parseFloat(line.getAttribute("x1") || "0"); - const y1 = parseFloat(line.getAttribute("y1") || "0"); - const x2 = parseFloat(line.getAttribute("x2") || "0"); - const y2 = parseFloat(line.getAttribute("y2") || "0"); - // Match by checking if this line connects these nodes (approximate) - if (edge.source === nodeId) { - // Check if endpoint matches old position pattern — just update all matching edges - line.setAttribute("x1", String(sp.x)); - line.setAttribute("y1", String(sp.y)); - } - if (edge.target === nodeId) { - line.setAttribute("x2", String(tp.x)); - line.setAttribute("y2", String(tp.y)); - } - } - } - } - - private renderGraphSVG(): string { - const filtered = this.getFilteredNodes(); - if (filtered.length === 0 && this.nodes.length > 0) { - return `

No nodes match current filter.

`; - } - if (filtered.length === 0) { - return ` -
-

🕸️

-

Community Relationship Graph

-

Connect the force-directed layout engine to visualize your network.

-

Automerge CRDT sync + d3-force layout

-
- `; - } - - this.ensureLayout(); - const positions = this.nodePositions; - const filteredIds = new Set(filtered.map(n => n.id)); - - // Assign colors to companies dynamically - const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"]; - const companies = this.nodes.filter(n => n.type === "company"); - const orgColors: Record = {}; - companies.forEach((org, i) => { orgColors[org.id] = palette[i % palette.length]; }); - - // Cluster backgrounds based on computed positions - const clustersSvg = companies.map(org => { - const pos = positions[org.id]; - if (!pos || !filteredIds.has(org.id)) return ""; - const color = orgColors[org.id] || "#333"; - return ``; - }).join(""); - - // Render edges - const edgesSvg: string[] = []; - for (const edge of this.edges) { - const sp = positions[edge.source]; - const tp = positions[edge.target]; - if (!sp || !tp) continue; - if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue; - - if (edge.type === "work_at") { - edgesSvg.push(``); - } else if (edge.type === "point_of_contact") { - const mx = (sp.x + tp.x) / 2; - const my = (sp.y + tp.y) / 2; - edgesSvg.push(``); - if (edge.label) { - edgesSvg.push(`${this.esc(edge.label)}`); - } - } else if (edge.type === "collaborates") { - edgesSvg.push(``); - } else { - edgesSvg.push(``); - } - } - - // Render delegation edges (purple, with arrows) - for (const edge of this.edges) { - if (edge.type !== "delegates_to") continue; - const sp = positions[edge.source]; - const tp = positions[edge.target]; - if (!sp || !tp) continue; - if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue; - const strokeWidth = 1 + (edge.weight || 0.5) * 3; - edgesSvg.push(``); - } - - // Render nodes - const nodesSvg = filtered.map(node => { - const pos = positions[node.id]; - if (!pos) return ""; - const isOrg = node.type === "company"; - const isUser = node.type === "rspace_user"; - const color = isOrg ? (orgColors[node.id] || "#22c55e") : isUser ? "#a78bfa" : "#3b82f6"; - const radius = this.getNodeRadius(node); - const isSelected = this.selectedNode?.id === node.id; - - let label = this.esc(node.name); - let sublabel = ""; - if (isOrg && node.description) { - sublabel = `${this.esc(node.description)}`; - } else if (!isOrg && node.role) { - sublabel = `${this.esc(node.role)}${node.location ? " · " + this.esc(node.location) : ""}`; - } - - // Trust score badge for people - const trust = !isOrg ? this.getTrustScore(node.id) : -1; - const trustBadge = trust >= 0 ? ` - - ${trust} - ` : ""; - - return ` - - ${isSelected ? `` : ""} - - ${isOrg ? `${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}` : ""} - ${label} - ${sublabel} - ${trustBadge} - - `; - }).join(""); - - return ` - - - - - - - - ${clustersSvg} - ${edgesSvg.join("")} - ${nodesSvg} - - -
- - ${Math.round(this.canvasZoom * 100)}% - - -
- `; - } - - private render() { + private renderDOM() { this.shadow.innerHTML = ` - ${this.error ? `
${this.esc(this.error)}
` : ""} - -
- Network Graph${this.space === "demo" ? 'Demo' : ""} +
+ Network Graph${this.space === "demo" ? 'Demo' : ""}
- ${this.info ? ` -
-
${this.info.member_count || 0}
People
-
${this.info.company_count || 0}
Organizations
-
${this.edges?.filter((e: GraphEdge) => e.type === "point_of_contact").length || 0}
Cross-org Links
-
- ` : ""} +
- - ${(["all", "person", "company", "opportunity", "rspace_user"] as const).map(f => { - const labels: Record = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities", rspace_user: "Members" }; - return ``; - }).join("")} - + + + + + + +
- ${this.trustMode ? ` -
- ${DELEGATION_AUTHORITIES.map(a => ``).join("")} -
` : ""} - +
+ ${DELEGATION_AUTHORITIES.map(a => ``).join("")} +
- ${this.nodes.length > 0 ? this.renderGraphSVG() : ` -
-

🕸️

-

Community Relationship Graph

-

Connect the force-directed layout engine to visualize your network.

-

Automerge CRDT sync + d3-force layout

+
+
+ + 100% + +
- `}
- ${this.renderDetailPanel()} +
-
+
People
Organizations
- ${this.trustMode ? `
Members
` : ""} -
Works at
-
Point of contact
- ${this.trustMode ? `
Delegates to
` : ""} + +
Works at
+
Point of contact
+
- ${this.workspaces.length > 0 ? ` -
${this.space === "demo" ? "Organizations" : "Workspaces"}
-
- ${this.workspaces.map(ws => ` -
-
${this.esc(ws.name || ws.slug)}
-
${ws.nodeCount || 0} nodes · ${ws.edgeCount || 0} edges
-
- `).join("")} -
- ` : ""} +
`; this.attachListeners(); + this.initGraph3D(); } private attachListeners() { @@ -757,8 +422,10 @@ class FolkGraphViewer extends HTMLElement { this.shadow.querySelectorAll("[data-filter]").forEach(el => { el.addEventListener("click", () => { this.filter = (el as HTMLElement).dataset.filter as any; - this.render(); - requestAnimationFrame(() => this.fitView()); + // Update active state + this.shadow.querySelectorAll("[data-filter]").forEach(b => b.classList.remove("active")); + el.classList.add("active"); + this.updateGraphData(); }); }); @@ -767,15 +434,15 @@ class FolkGraphViewer extends HTMLElement { this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); - searchTimeout = setTimeout(() => { - this.render(); - requestAnimationFrame(() => this.fitView()); - }, 200); + 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(); }); @@ -788,171 +455,379 @@ class FolkGraphViewer extends HTMLElement { }); // Close detail panel - this.shadow.getElementById("close-detail")?.addEventListener("click", () => { - this.selectedNode = null; - this.render(); + 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", () => { - const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const cx = rect.width / 2, cy = rect.height / 2; - this.zoomAt(cx, cy, 1.25); + 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", () => { - const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; - if (!svg) return; - const rect = svg.getBoundingClientRect(); - const cx = rect.width / 2, cy = rect.height / 2; - this.zoomAt(cx, cy, 0.8); + 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", () => this.fitView()); - - // Canvas interactions - const canvas = this.shadow.getElementById("graph-canvas"); - const svg = this.shadow.getElementById("graph-svg") as SVGSVGElement | null; - if (!svg || !canvas) return; - - // Wheel zoom - svg.addEventListener("wheel", (e: WheelEvent) => { - e.preventDefault(); - const rect = svg.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const factor = 1 - e.deltaY * 0.003; - this.zoomAt(mx, my, factor); - }, { passive: false }); - - // Pointer down — node drag or canvas pan - svg.addEventListener("pointerdown", (e: PointerEvent) => { - if (e.button !== 0) return; - const target = (e.target as Element).closest("[data-node-id]") as HTMLElement | null; - const rect = svg.getBoundingClientRect(); - - if (target) { - // Node drag - const nodeId = target.dataset.nodeId!; - this.draggingNodeId = nodeId; - this.dragStartX = e.clientX; - this.dragStartY = e.clientY; - const pos = this.nodePositions[nodeId]; - if (pos) { - this.dragNodeStartX = pos.x; - this.dragNodeStartY = pos.y; - } - svg.setPointerCapture(e.pointerId); - e.preventDefault(); - } else { - // Canvas pan - this.isPanning = true; - this.panStartX = e.clientX; - this.panStartY = e.clientY; - this.panStartPanX = this.canvasPanX; - this.panStartPanY = this.canvasPanY; - canvas.classList.add("grabbing"); - svg.setPointerCapture(e.pointerId); - e.preventDefault(); - } - }); - - svg.addEventListener("pointermove", (e: PointerEvent) => { - if (this.draggingNodeId) { - const dx = (e.clientX - this.dragStartX) / this.canvasZoom; - const dy = (e.clientY - this.dragStartY) / this.canvasZoom; - const pos = this.nodePositions[this.draggingNodeId]; - if (pos) { - pos.x = this.dragNodeStartX + dx; - pos.y = this.dragNodeStartY + dy; - this.updateNodePosition(this.draggingNodeId); - } - } else if (this.isPanning) { - this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX); - this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY); - this.updateCanvasTransform(); - } - }); - - svg.addEventListener("pointerup", (e: PointerEvent) => { - if (this.draggingNodeId) { - // If barely moved, treat as click → select node - const dx = Math.abs(e.clientX - this.dragStartX); - const dy = Math.abs(e.clientY - this.dragStartY); - if (dx < 4 && dy < 4) { - const id = this.draggingNodeId; - if (this.selectedNode?.id === id) { this.selectedNode = null; } - else { this.selectedNode = this.nodes.find(n => n.id === id) || null; } - this.draggingNodeId = null; - this.render(); - return; - } - this.draggingNodeId = null; - } - if (this.isPanning) { - this.isPanning = false; - canvas.classList.remove("grabbing"); - } - }); - - // Touch pinch-zoom - svg.addEventListener("touchstart", (e: TouchEvent) => { - if (e.touches.length === 2) { - e.preventDefault(); - this.isTouchPanning = true; - const t0 = e.touches[0], t1 = e.touches[1]; - this.lastTouchCenter = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; - this.lastTouchDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); - } - }, { passive: false }); - - svg.addEventListener("touchmove", (e: TouchEvent) => { - if (e.touches.length === 2 && this.isTouchPanning) { - e.preventDefault(); - const t0 = e.touches[0], t1 = e.touches[1]; - const center = { x: (t0.clientX + t1.clientX) / 2, y: (t0.clientY + t1.clientY) / 2 }; - const dist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); - - if (this.lastTouchCenter && this.lastTouchDist) { - // Pan - this.canvasPanX += center.x - this.lastTouchCenter.x; - this.canvasPanY += center.y - this.lastTouchCenter.y; - - // Zoom - const rect = svg.getBoundingClientRect(); - const mx = center.x - rect.left; - const my = center.y - rect.top; - const factor = dist / this.lastTouchDist; - this.zoomAt(mx, my, factor); - } - - this.lastTouchCenter = center; - this.lastTouchDist = dist; - } - }, { passive: false }); - - svg.addEventListener("touchend", () => { - this.isTouchPanning = false; - this.lastTouchCenter = null; - this.lastTouchDist = null; + this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => { + if (this.graph) this.graph.zoomToFit(400, 40); }); } - private zoomAt(screenX: number, screenY: number, factor: number) { - const oldZoom = this.canvasZoom; - const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor)); - // Adjust pan so the point under the cursor stays fixed - this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom); - this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom); - this.canvasZoom = newZoom; - this.updateCanvasTransform(); - this.updateZoomDisplay(); + 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 + ); } - private esc(s: string): string { - const d = document.createElement("div"); - d.textContent = s || ""; - return d.innerHTML; + // ── 3D Graph initialization ── + + private async initGraph3D() { + const container = this.shadow.getElementById("graph-3d-container") as HTMLDivElement; + if (!container) return; + this.graphContainer = container; + + try { + const ForceGraph3DModule = await import("3d-force-graph"); + const ForceGraph3D = ForceGraph3DModule.default || ForceGraph3DModule; + + 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) => { + 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) * 3; + } + const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; + return style.width; + }) + .linkOpacity(0.6) + .linkDirectionalArrowLength((link: GraphEdge) => + link.type === "delegates_to" ? 4 : 0 + ) + .linkDirectionalArrowRelPos(1) + .linkLineDash((link: GraphEdge) => { + const style = EDGE_STYLES[link.type] || EDGE_STYLES.default; + return style.dashed ? [4, 2] : null; + }) + .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"); + if (membersLegend) membersLegend.style.display = this.trustMode ? "" : "none"; + if (delegatesLegend) delegatesLegend.style.display = this.trustMode ? "" : "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("")} +
+ `; } } diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index c3e9705..e5de31a 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -12,6 +12,17 @@ import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; +// ── CDN importmap for Three.js + 3d-force-graph ── +const GRAPH3D_IMPORTMAP = ``; + const routes = new Hono(); const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online"; @@ -375,6 +386,7 @@ routes.get("/", (c) => { moduleId: "rnetwork", spaceSlug: space, modules: getModuleInfoList(), + head: GRAPH3D_IMPORTMAP, body: ` `, scripts: ``, diff --git a/vite.config.ts b/vite.config.ts index 29273b0..380e4e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -644,6 +644,7 @@ export default defineConfig({ fileName: () => "folk-graph-viewer.js", }, rollupOptions: { + external: ["three", "three/addons/", "3d-force-graph"], output: { entryFileNames: "folk-graph-viewer.js", },