163 lines
5.7 KiB
TypeScript
163 lines
5.7 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.
|
|
*/
|
|
|
|
class FolkGraphViewer extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "";
|
|
private workspaces: any[] = [];
|
|
private info: any = null;
|
|
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";
|
|
this.loadData();
|
|
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 render() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.header { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
|
|
.header-title { font-size: 18px; font-weight: 600; flex: 1; }
|
|
|
|
.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; }
|
|
</style>
|
|
|
|
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
|
|
|
|
<div class="header">
|
|
<span class="header-title">\u{1F310} Network Graph</span>
|
|
</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">
|
|
<div class="placeholder">
|
|
<p style="font-size:48px">\u{1F578}\u{FE0F}</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 \u00B7 ${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();
|
|
});
|
|
});
|
|
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
|
this.searchQuery = (e.target as HTMLInputElement).value;
|
|
});
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s || "";
|
|
return d.innerHTML;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-graph-viewer", FolkGraphViewer);
|