Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-03 13:25:39 -08:00
commit c675025f63
1 changed files with 98 additions and 23 deletions

View File

@ -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"/>`);
} }
} }