rspace-online/modules/network/components/folk-graph-viewer.ts

282 lines
10 KiB
TypeScript

/**
* <folk-graph-viewer> — 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 `<div class="placeholder"><p style="font-size:14px;color:#888">No nodes match current filter.</p></div>`;
}
if (filtered.length === 0) {
return `
<div class="placeholder">
<p style="font-size:48px">&#x1F578;&#xFE0F;</p>
<p style="font-size:16px">Community Relationship Graph</p>
<p>Connect the force-directed layout engine to visualize your network.</p>
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
</div>
`;
}
// 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 `
<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" opacity="0.8"/>
<text x="${x}" y="${y + radius + 14}" fill="#aaa" font-size="10" text-anchor="middle">${this.esc(node.name)}</text>
`;
}).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(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="#333" stroke-width="1" opacity="0.4"/>`);
}
}
}
return `<svg viewBox="0 0 500 500" width="100%" height="100%" style="max-height:500px">${edgesSvg.join("")}${nodesSvg}</svg>`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.search-input {
border: 1px solid #333; border-radius: 8px; padding: 8px 12px;
background: #16161e; color: #e0e0e0; font-size: 13px; width: 200px; outline: none;
}
.search-input:focus { border-color: #6366f1; }
.filter-btn {
padding: 6px 12px; border-radius: 8px; border: 1px solid #333;
background: #16161e; color: #888; cursor: pointer; font-size: 12px;
}
.filter-btn:hover { border-color: #555; }
.filter-btn.active { border-color: #6366f1; color: #6366f1; }
.graph-canvas {
width: 100%; height: 500px; border-radius: 12px;
background: #0d0d14; border: 1px solid #222;
display: flex; align-items: center; justify-content: center;
position: relative; overflow: hidden;
}
.placeholder { text-align: center; color: #555; padding: 40px; }
.placeholder p { margin: 6px 0; }
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
.ws-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 10px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.ws-card:hover { border-color: #555; }
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.ws-meta { font-size: 12px; color: #888; }
.legend { display: flex; gap: 16px; margin-top: 12px; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #888; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
.dot-person { background: #3b82f6; }
.dot-company { background: #22c55e; }
.dot-opportunity { background: #f59e0b; }
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
.stat { text-align: center; }
.stat-value { font-size: 24px; font-weight: 700; color: #6366f1; }
.stat-label { font-size: 11px; color: #888; }
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
@media (max-width: 768px) {
.graph-canvas { height: 350px; }
.workspace-list { grid-template-columns: 1fr; }
.stats { flex-wrap: wrap; gap: 12px; }
.toolbar { flex-direction: column; align-items: stretch; }
.search-input { width: 100%; }
}
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
<div class="rapp-nav">
<span class="rapp-nav__title">Network Graph${this.space === "demo" ? '<span class="demo-badge">Demo</span>' : ""}</span>
</div>
${this.info ? `
<div class="stats">
<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">Members</div></div>
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Companies</div></div>
<div class="stat"><div class="stat-value">${this.info.opportunity_count || 0}</div><div class="stat-label">Opportunities</div></div>
</div>
` : ""}
<div class="toolbar">
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
${(["all", "person", "company", "opportunity"] as const).map(f =>
`<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${f === "all" ? "All" : f.charAt(0).toUpperCase() + f.slice(1)}s</button>`
).join("")}
</div>
<div class="graph-canvas">
${this.nodes.length > 0 ? this.renderGraphNodes() : `
<div class="placeholder">
<p style="font-size:48px">&#x1F578;&#xFE0F;</p>
<p style="font-size:16px">Community Relationship Graph</p>
<p>Connect the force-directed layout engine to visualize your network.</p>
<p style="font-size:12px;color:#444">Automerge CRDT sync + d3-force layout</p>
</div>
`}
</div>
<div class="legend">
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
<div class="legend-item"><span class="legend-dot dot-company"></span> Companies</div>
<div class="legend-item"><span class="legend-dot dot-opportunity"></span> Opportunities</div>
</div>
${this.workspaces.length > 0 ? `
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#aaa">Workspaces</div>
<div class="workspace-list">
${this.workspaces.map(ws => `
<div class="ws-card">
<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
<div class="ws-meta">${ws.nodeCount || 0} nodes &middot; ${ws.edgeCount || 0} edges</div>
</div>
`).join("")}
</div>
` : ""}
`;
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);