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:
parent
de4da8429f
commit
fb26324929
|
|
@ -18,7 +18,7 @@ interface GraphNode {
|
||||||
interface GraphEdge {
|
interface GraphEdge {
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
type: "work_at" | "point_of_contact" | "collaborates";
|
type: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,8 +42,8 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.space = this.getAttribute("space") || "demo";
|
this.space = this.getAttribute("space") || "demo";
|
||||||
if (this.space === "demo") { this.loadDemoData(); return; }
|
if (this.space === "demo") { this.loadDemoData(); return; }
|
||||||
this.loadData();
|
this.render(); // Show loading state
|
||||||
this.render();
|
this.loadData(); // Async — will re-render when data arrives
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDemoData() {
|
private loadDemoData() {
|
||||||
|
|
@ -114,16 +114,75 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
private async loadData() {
|
private async loadData() {
|
||||||
const base = this.getApiBase();
|
const base = this.getApiBase();
|
||||||
try {
|
try {
|
||||||
const [wsRes, infoRes] = await Promise.all([
|
const [wsRes, infoRes, graphRes] = await Promise.all([
|
||||||
fetch(`${base}/api/workspaces`),
|
fetch(`${base}/api/workspaces`),
|
||||||
fetch(`${base}/api/info`),
|
fetch(`${base}/api/info`),
|
||||||
|
fetch(`${base}/api/graph`),
|
||||||
]);
|
]);
|
||||||
if (wsRes.ok) this.workspaces = await wsRes.json();
|
if (wsRes.ok) this.workspaces = await wsRes.json();
|
||||||
if (infoRes.ok) this.info = await infoRes.json();
|
if (infoRes.ok) this.info = await infoRes.json();
|
||||||
|
if (graphRes.ok) {
|
||||||
|
const graph = await graphRes.json();
|
||||||
|
this.importGraph(graph);
|
||||||
|
}
|
||||||
} catch { /* offline */ }
|
} catch { /* offline */ }
|
||||||
this.render();
|
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[] {
|
private getFilteredNodes(): GraphNode[] {
|
||||||
let filtered = this.nodes;
|
let filtered = this.nodes;
|
||||||
if (this.filter !== "all") {
|
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 }> {
|
private computeForceLayout(nodes: GraphNode[], edges: GraphEdge[], W: number, H: number): Record<string, { x: number; y: number }> {
|
||||||
const pos: Record<string, { x: number; y: number }> = {};
|
const pos: Record<string, { x: number; y: number }> = {};
|
||||||
|
|
||||||
// Initial positions: orgs in triangle, people around their org
|
// Collect company nodes and position them evenly around center
|
||||||
const orgCenters: Record<string, { x: number; y: number }> = {
|
const companies = nodes.filter(n => n.type === "company");
|
||||||
"org-1": { x: W / 2, y: 120 },
|
const orgCenters: Record<string, { x: number; y: number }> = {};
|
||||||
"org-2": { x: 160, y: 380 },
|
const cx = W / 2, cy = H / 2;
|
||||||
"org-3": { x: W - 160, y: 380 },
|
const orbitR = Math.min(W, H) * 0.3;
|
||||||
};
|
|
||||||
const orgNameToId: Record<string, string> = {
|
companies.forEach((org, i) => {
|
||||||
"Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3",
|
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 };
|
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[]> = {};
|
const peopleByOrg: Record<string, GraphNode[]> = {};
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.type === "person") {
|
if (n.type === "person") {
|
||||||
|
|
@ -167,8 +231,7 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
for (const [oid, people] of Object.entries(peopleByOrg)) {
|
for (const [oid, people] of Object.entries(peopleByOrg)) {
|
||||||
const c = orgCenters[oid];
|
const c = orgCenters[oid];
|
||||||
if (!c) continue;
|
if (!c) continue;
|
||||||
const gcx = W / 2, gcy = 250;
|
const base = Math.atan2(c.y - cy, c.x - cx);
|
||||||
const base = Math.atan2(c.y - gcy, c.x - gcx);
|
|
||||||
const spread = Math.PI * 0.8;
|
const spread = Math.PI * 0.8;
|
||||||
people.forEach((p, i) => {
|
people.forEach((p, i) => {
|
||||||
const angle = base - spread / 2 + (spread * i) / Math.max(people.length - 1, 1);
|
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
|
// Run force iterations
|
||||||
const allIds = nodes.map(n => n.id).filter(id => pos[id]);
|
const allIds = nodes.map(n => n.id).filter(id => pos[id]);
|
||||||
for (let iter = 0; iter < 80; iter++) {
|
for (let iter = 0; iter < 80; iter++) {
|
||||||
|
|
@ -289,17 +359,17 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
// Force-directed layout
|
// Force-directed layout
|
||||||
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
const positions = this.computeForceLayout(this.nodes, this.edges, W, H);
|
||||||
|
|
||||||
// Org colors
|
// Assign colors to companies dynamically
|
||||||
const orgColors: Record<string, string> = {
|
const palette = ["#6366f1", "#22c55e", "#f59e0b", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#06b6d4"];
|
||||||
"org-1": "#6366f1", "org-2": "#22c55e", "org-3": "#f59e0b",
|
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
|
// Cluster backgrounds based on computed positions
|
||||||
const orgIds = ["org-1", "org-2", "org-3"];
|
const clustersSvg = companies.map(org => {
|
||||||
const clustersSvg = orgIds.map(orgId => {
|
const pos = positions[org.id];
|
||||||
const pos = positions[orgId];
|
|
||||||
if (!pos) return "";
|
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"/>`;
|
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("");
|
}).join("");
|
||||||
|
|
||||||
|
|
@ -320,6 +390,11 @@ class FolkGraphViewer extends HTMLElement {
|
||||||
if (edge.label) {
|
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>`);
|
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"/>`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue