/** * — community relationship graph. * * Displays network nodes (people, companies, opportunities) * and edges in a force-directed layout with search and filtering. */ interface GraphNode { id: string; name: string; type: "person" | "company" | "opportunity"; workspace: string; role?: string; location?: string; description?: string; } interface GraphEdge { source: string; target: string; type: "work_at" | "point_of_contact" | "collaborates"; label?: string; } class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private workspaces: any[] = []; private info: any = null; private nodes: GraphNode[] = []; private edges: GraphEdge[] = []; private filter: "all" | "person" | "company" | "opportunity" = "all"; private searchQuery = ""; private error = ""; private selectedNode: GraphNode | null = null; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); } connectedCallback() { this.space = this.getAttribute("space") || "demo"; if (this.space === "demo") { this.loadDemoData(); return; } this.loadData(); this.render(); } private loadDemoData() { this.info = { name: "rSpace Community", member_count: 10, company_count: 3, opportunity_count: 0 }; this.workspaces = [ { name: "Commons DAO", slug: "commons-dao", nodeCount: 5, edgeCount: 4 }, { name: "Mycelial Lab", slug: "mycelial-lab", nodeCount: 5, edgeCount: 4 }, { name: "Regenerative Fund", slug: "regenerative-fund", nodeCount: 5, edgeCount: 4 }, ]; // Organizations this.nodes = [ { id: "org-1", name: "Commons DAO", type: "company", workspace: "Commons DAO", description: "Decentralized governance cooperative" }, { id: "org-2", name: "Mycelial Lab", type: "company", workspace: "Mycelial Lab", description: "Protocol research collective" }, { id: "org-3", name: "Regenerative Fund", type: "company", workspace: "Regenerative Fund", description: "Impact funding vehicle" }, // People — Commons DAO { id: "p-1", name: "Alice Chen", type: "person", workspace: "Commons DAO", role: "Lead Engineer", location: "Vancouver" }, { id: "p-2", name: "Bob Nakamura", type: "person", workspace: "Commons DAO", role: "Community Lead", location: "Tokyo" }, { id: "p-3", name: "Carol Santos", type: "person", workspace: "Commons DAO", role: "Treasury Steward", location: "S\u00e3o Paulo" }, { id: "p-4", name: "Dave Okafor", type: "person", workspace: "Commons DAO", role: "Governance Facilitator", location: "Lagos" }, // People — Mycelial Lab { id: "p-5", name: "Eva Larsson", type: "person", workspace: "Mycelial Lab", role: "Ops Coordinator", location: "Stockholm" }, { id: "p-6", name: "Frank M\u00fcller", type: "person", workspace: "Mycelial Lab", role: "Protocol Designer", location: "Berlin" }, { id: "p-7", name: "Grace Kim", type: "person", workspace: "Mycelial Lab", role: "Strategy Lead", location: "Seoul" }, // People — Regenerative Fund { id: "p-8", name: "Hiro Tanaka", type: "person", workspace: "Regenerative Fund", role: "Research Lead", location: "Osaka" }, { id: "p-9", name: "Iris Patel", type: "person", workspace: "Regenerative Fund", role: "Developer Relations", location: "Mumbai" }, { id: "p-10", name: "James Wright", type: "person", workspace: "Regenerative Fund", role: "Security Auditor", location: "London" }, ]; // Edges: work_at links + cross-org point_of_contact this.edges = [ // Work_at — Commons DAO { source: "p-1", target: "org-1", type: "work_at" }, { source: "p-2", target: "org-1", type: "work_at" }, { source: "p-3", target: "org-1", type: "work_at" }, { source: "p-4", target: "org-1", type: "work_at" }, // Work_at — Mycelial Lab { source: "p-5", target: "org-2", type: "work_at" }, { source: "p-6", target: "org-2", type: "work_at" }, { source: "p-7", target: "org-2", type: "work_at" }, // Work_at — Regenerative Fund { source: "p-8", target: "org-3", type: "work_at" }, { source: "p-9", target: "org-3", type: "work_at" }, { source: "p-10", target: "org-3", type: "work_at" }, // Cross-org point_of_contact edges { source: "p-1", target: "p-6", type: "point_of_contact", label: "Alice \u2194 Frank" }, { source: "p-2", target: "p-3", type: "point_of_contact", label: "Bob \u2194 Carol" }, { source: "p-4", target: "p-7", type: "point_of_contact", label: "Dave \u2194 Grace" }, ]; this.render(); } private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^\/([^/]+)\/network/); return match ? `/${match[1]}/network` : ""; } private async loadData() { const base = this.getApiBase(); try { const [wsRes, infoRes] = await Promise.all([ fetch(`${base}/api/workspaces`), fetch(`${base}/api/info`), ]); if (wsRes.ok) this.workspaces = await wsRes.json(); if (infoRes.ok) this.info = await infoRes.json(); } catch { /* offline */ } this.render(); } private getFilteredNodes(): GraphNode[] { let filtered = this.nodes; if (this.filter !== "all") { filtered = filtered.filter(n => n.type === this.filter); } if (this.searchQuery.trim()) { const q = this.searchQuery.toLowerCase(); filtered = filtered.filter(n => n.name.toLowerCase().includes(q) || n.workspace.toLowerCase().includes(q) || (n.role && n.role.toLowerCase().includes(q)) || (n.location && n.location.toLowerCase().includes(q)) || (n.description && n.description.toLowerCase().includes(q)) ); } return filtered; } 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", }; for (const [id, p] of Object.entries(orgCenters)) pos[id] = { ...p }; 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 gcx = W / 2, gcy = 250; const base = Math.atan2(c.y - gcy, c.x - gcx); 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) }; }); } // 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; pos[id].x = Math.max(30, Math.min(W - 30, pos[id].x)); pos[id].y = Math.max(30, Math.min(H - 30, pos[id].y)); } } return pos; } private getTrustScore(nodeId: string): number { return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20); } 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); } 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 renderGraphNodes(): 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

`; } const W = 700; const H = 500; const filteredIds = new Set(filtered.map(n => n.id)); // 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", }; // Cluster backgrounds based on computed positions const orgIds = ["org-1", "org-2", "org-3"]; const clustersSvg = orgIds.map(orgId => { const pos = positions[orgId]; if (!pos) return ""; const color = orgColors[orgId] || "#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)}`); } } } // Render nodes const nodesSvg = filtered.map(node => { const pos = positions[node.id]; if (!pos) return ""; const isOrg = node.type === "company"; const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6"; const radius = isOrg ? 22 : 12; 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 ? " \u00b7 " + 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}`; } private render() { this.shadow.innerHTML = ` ${this.error ? `
${this.esc(this.error)}
` : ""}
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"] as const).map(f => { const labels: Record = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" }; return ``; }).join("")}
${this.nodes.length > 0 ? this.renderGraphNodes() : `

🕸️

Community Relationship Graph

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

Automerge CRDT sync + d3-force layout

`}
${this.renderDetailPanel()}
People
Organizations
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(); } private attachListeners() { this.shadow.querySelectorAll("[data-filter]").forEach(el => { el.addEventListener("click", () => { this.filter = (el as HTMLElement).dataset.filter as any; this.render(); }); }); let searchTimeout: any; this.shadow.getElementById("search-input")?.addEventListener("input", (e) => { this.searchQuery = (e.target as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => this.render(), 200); }); // Node click → detail panel this.shadow.querySelectorAll("[data-node-id]").forEach(el => { el.addEventListener("click", () => { const id = (el as HTMLElement).dataset.nodeId!; if (this.selectedNode?.id === id) { this.selectedNode = null; } else { this.selectedNode = this.nodes.find(n => n.id === id) || null; } this.render(); }); }); // Close detail panel this.shadow.getElementById("close-detail")?.addEventListener("click", () => { this.selectedNode = null; this.render(); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-graph-viewer", FolkGraphViewer);