/** * — 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; } class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; private workspaces: any[] = []; private info: any = null; private nodes: GraphNode[] = []; 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: 42, company_count: 8, opportunity_count: 5 }; this.workspaces = [ { name: "Core Contributors", slug: "core-contributors", nodeCount: 12, edgeCount: 3 }, { name: "Extended Network", slug: "extended-network", nodeCount: 30, edgeCount: 5 }, ]; this.nodes = [ { id: "demo-p1", name: "Alice Chen", type: "person", workspace: "Core Contributors" }, { id: "demo-p2", name: "Bob Marley", type: "person", workspace: "Core Contributors" }, { id: "demo-p3", name: "Carol Danvers", type: "person", workspace: "Extended Network" }, { id: "demo-p4", name: "Diana Prince", type: "person", workspace: "Extended Network" }, { id: "demo-c1", name: "Radiant Hall Press", type: "company", workspace: "Core Contributors" }, { id: "demo-c2", name: "Tiny Splendor", type: "company", workspace: "Extended Network" }, { id: "demo-c3", name: "Commons Hub", type: "company", workspace: "Core Contributors" }, ]; 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) ); } 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

`; } // Render demo nodes as positioned circles inside the graph canvas const cx = 250; const cy = 250; const r = 180; const nodesSvg = filtered.map((node, i) => { const angle = (2 * Math.PI * i) / filtered.length - Math.PI / 2; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); const color = node.type === "person" ? "#3b82f6" : node.type === "company" ? "#22c55e" : "#f59e0b"; const radius = node.type === "company" ? 18 : 14; return ` ${this.esc(node.name)} `; }).join(""); // Draw edges between nodes in the same workspace const edgesSvg: string[] = []; for (let i = 0; i < filtered.length; i++) { for (let j = i + 1; j < filtered.length; j++) { if (filtered[i].workspace === filtered[j].workspace) { const a1 = (2 * Math.PI * i) / filtered.length - Math.PI / 2; const a2 = (2 * Math.PI * j) / filtered.length - Math.PI / 2; const x1 = cx + r * Math.cos(a1); const y1 = cy + r * Math.sin(a1); const x2 = cx + r * Math.cos(a2); const y2 = cy + r * Math.sin(a2); edgesSvg.push(``); } } } return `${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}
Members
${this.info.company_count || 0}
Companies
${this.info.opportunity_count || 0}
Opportunities
` : ""}
${(["all", "person", "company", "opportunity"] as const).map(f => `` ).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
Companies
Opportunities
${this.workspaces.length > 0 ? `
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);