/** * — 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 = ""; 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 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)); // Cluster layout: position org nodes as hubs, people orbit around their org // Three orgs arranged in a triangle const orgCenters: Record = { "org-1": { x: W / 2, y: 120 }, // Commons DAO — top center "org-2": { x: 160, y: 380 }, // Mycelial Lab — bottom left "org-3": { x: W - 160, y: 380 }, // Regenerative Fund — bottom right }; // Build a position map for all nodes const positions: Record = {}; // Position org nodes at their centers for (const [id, pos] of Object.entries(orgCenters)) { positions[id] = pos; } // Group people by their workspace (org) const orgNameToId: Record = { "Commons DAO": "org-1", "Mycelial Lab": "org-2", "Regenerative Fund": "org-3", }; const peopleByOrg: Record = {}; for (const node of this.nodes) { if (node.type === "person") { const orgId = orgNameToId[node.workspace]; if (orgId) { if (!peopleByOrg[orgId]) peopleByOrg[orgId] = []; peopleByOrg[orgId].push(node); } } } // Position people in a semicircle around their org const orbitRadius = 110; for (const [orgId, people] of Object.entries(peopleByOrg)) { const center = orgCenters[orgId]; if (!center) continue; const count = people.length; // Spread people in an arc facing outward from the graph center const graphCx = W / 2; const graphCy = (120 + 380) / 2; // vertical center of the triangle const baseAngle = Math.atan2(center.y - graphCy, center.x - graphCx); const spread = Math.PI * 0.8; // 144 degrees arc for (let i = 0; i < count; i++) { const angle = baseAngle - spread / 2 + (spread * i) / Math.max(count - 1, 1); positions[people[i].id] = { x: center.x + orbitRadius * Math.cos(angle), y: center.y + orbitRadius * Math.sin(angle), }; } } // Org background cluster circles const orgColors: Record = { "org-1": "#6366f1", // indigo for Commons DAO "org-2": "#22c55e", // green for Mycelial Lab "org-3": "#f59e0b", // amber for Regenerative Fund }; const clustersSvg = Object.entries(orgCenters).map(([orgId, pos]) => { 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") { // Cross-org edges: dashed, brighter 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; 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) : ""}`; } return ` ${isOrg ? `${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}` : ""} ${label} ${sublabel} `; }).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

`}
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); }); } private esc(s: string): string { const d = document.createElement("div"); d.textContent = s || ""; return d.innerHTML; } } customElements.define("folk-graph-viewer", FolkGraphViewer);