fix: rNetwork graph viewer now fetches /api/graph and normalizes CRM data

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 13:25:11 -08:00
parent de4da8429f
commit fb26324929
1 changed files with 98 additions and 23 deletions

View File

@ -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<string, string>();
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<string, string>();
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<string, GraphEdge["type"]> = {
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<string, { x: number; y: number }> {
const pos: Record<string, { x: number; y: number }> = {};
// Initial positions: orgs in triangle, people around their org
const orgCenters: Record<string, { x: number; y: number }> = {
"org-1": { x: W / 2, y: 120 },
"org-2": { x: 160, y: 380 },
"org-3": { x: W - 160, y: 380 },
};
const orgNameToId: Record<string, string> = {
"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<string, { x: number; y: number }> = {};
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<string, string> = {};
for (const org of companies) orgNameToId[org.name] = org.id;
const peopleByOrg: Record<string, GraphNode[]> = {};
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<string, string> = {
"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<string, string> = {};
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 `<circle cx="${pos.x}" cy="${pos.y}" r="140" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
}).join("");
@ -320,6 +390,11 @@ class FolkGraphViewer extends HTMLElement {
if (edge.label) {
edgesSvg.push(`<text x="${mx}" y="${my - 6}" fill="#c084fc" font-size="8" text-anchor="middle" opacity="0.7">${this.esc(edge.label)}</text>`);
}
} else if (edge.type === "collaborates") {
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.4"/>`);
} else {
// Fallback for any unknown edge type
edgesSvg.push(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#555" stroke-width="1" opacity="0.25"/>`);
}
}