From fb263249292242d4d258a466f38a4b8821a63dd7 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 13:25:11 -0800 Subject: [PATCH] fix: rNetwork graph viewer now fetches /api/graph and normalizes CRM data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folk-graph-viewer component was never calling /api/graph — it only fetched /api/workspaces and /api/info, leaving nodes/edges empty for non-demo spaces. Now loadData() fetches the graph endpoint and maps server field names (label→name, works_at→work_at) to match the client interface. Force layout and org colors are now dynamic instead of hardcoded for 3 demo orgs. Co-Authored-By: Claude Opus 4.6 --- .../rnetwork/components/folk-graph-viewer.ts | 121 ++++++++++++++---- 1 file changed, 98 insertions(+), 23 deletions(-) diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 961a1fa..d0bb372 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -18,7 +18,7 @@ interface GraphNode { interface GraphEdge { source: string; target: string; - type: "work_at" | "point_of_contact" | "collaborates"; + type: string; label?: string; } @@ -42,8 +42,8 @@ class FolkGraphViewer extends HTMLElement { connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } - this.loadData(); - this.render(); + this.render(); // Show loading state + this.loadData(); // Async — will re-render when data arrives } private loadDemoData() { @@ -114,16 +114,75 @@ class FolkGraphViewer extends HTMLElement { private async loadData() { const base = this.getApiBase(); try { - const [wsRes, infoRes] = await Promise.all([ + const [wsRes, infoRes, graphRes] = await Promise.all([ fetch(`${base}/api/workspaces`), fetch(`${base}/api/info`), + fetch(`${base}/api/graph`), ]); 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.render(); } + /** 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 = { + 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, + } as GraphNode; + }); + + this.edges = (graph.edges || []).map((e: any) => ({ + source: e.source, + target: e.target, + type: edgeTypeMap[e.type] || e.type, + label: e.label, + } as GraphEdge)); + + // Update info stats from actual data + 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") { @@ -145,18 +204,23 @@ class FolkGraphViewer extends HTMLElement { private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record { const pos: Record = {}; - // Initial positions: orgs in triangle, people around their org - const orgCenters: Record = { - "org-1": { x: W / 2, y: 120 }, - "org-2": { x: 160, y: 380 }, - "org-3": { x: W - 160, y: 380 }, - }; - const orgNameToId: Record = { - "Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3", - }; + // 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") { @@ -167,8 +231,7 @@ class FolkGraphViewer extends HTMLElement { for (const [oid, people] of Object.entries(peopleByOrg)) { const c = orgCenters[oid]; if (!c) continue; - const gcx = W / 2, gcy = 250; - const base = Math.atan2(c.y - gcy, c.x - gcx); + 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); @@ -176,6 +239,13 @@ class FolkGraphViewer extends HTMLElement { }); } + // 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++) { @@ -289,17 +359,17 @@ class FolkGraphViewer extends HTMLElement { // Force-directed layout const positions = this.computeForceLayout(this.nodes, this.edges, W, H); - // Org colors - const orgColors: Record = { - "org-1": "#6366f1", "org-2": "#22c55e", "org-3": "#f59e0b", - }; + // 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 orgIds = ["org-1", "org-2", "org-3"]; - const clustersSvg = orgIds.map(orgId => { - const pos = positions[orgId]; + const clustersSvg = companies.map(org => { + const pos = positions[org.id]; if (!pos) return ""; - const color = orgColors[orgId] || "#333"; + const color = orgColors[org.id] || "#333"; return ``; }).join(""); @@ -320,6 +390,11 @@ class FolkGraphViewer extends HTMLElement { if (edge.label) { edgesSvg.push(`${this.esc(edge.label)}`); } + } else if (edge.type === "collaborates") { + edgesSvg.push(``); + } else { + // Fallback for any unknown edge type + edgesSvg.push(``); } }