419 lines
17 KiB
TypeScript
419 lines
17 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;
|
|
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 `<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">🕸️</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>
|
|
`;
|
|
}
|
|
|
|
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<string, { x: number; y: number }> = {
|
|
"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<string, { x: number; y: number }> = {};
|
|
|
|
// 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<string, string> = {
|
|
"Commons DAO": "org-1",
|
|
"Mycelial Lab": "org-2",
|
|
"Regenerative Fund": "org-3",
|
|
};
|
|
|
|
const peopleByOrg: Record<string, GraphNode[]> = {};
|
|
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<string, string> = {
|
|
"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 `<circle cx="${pos.x}" cy="${pos.y}" r="${orbitRadius + 30}" fill="${color}" opacity="0.04" stroke="${color}" stroke-width="1" stroke-opacity="0.15" stroke-dasharray="4 4"/>`;
|
|
}).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(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#555" stroke-width="1" opacity="0.35"/>`);
|
|
} 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(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#c084fc" stroke-width="1.5" stroke-dasharray="6 3" opacity="0.6"/>`);
|
|
if (edge.label) {
|
|
edgesSvg.push(`<text x="${mx}" y="${my - 6}" fill="#c084fc" font-size="8" text-anchor="middle" opacity="0.7">${this.esc(edge.label)}</text>`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 = `<text x="${pos.x}" y="${pos.y + radius + 26}" fill="#888" font-size="8" text-anchor="middle">${this.esc(node.description)}</text>`;
|
|
} else if (!isOrg && node.role) {
|
|
sublabel = `<text x="${pos.x}" y="${pos.y + radius + 24}" fill="#666" font-size="8" text-anchor="middle">${this.esc(node.role)}${node.location ? " \u00b7 " + this.esc(node.location) : ""}</text>`;
|
|
}
|
|
|
|
return `
|
|
<circle cx="${pos.x}" cy="${pos.y}" r="${radius}" fill="${color}" opacity="${isOrg ? 0.9 : 0.75}" stroke="${isOrg ? color : "none"}" stroke-width="${isOrg ? 2 : 0}" stroke-opacity="0.3"/>
|
|
${isOrg ? `<text x="${pos.x}" y="${pos.y + 4}" fill="#fff" font-size="9" font-weight="600" text-anchor="middle">${label.length > 14 ? label.slice(0, 12) + "\u2026" : label}</text>` : ""}
|
|
<text x="${pos.x}" y="${pos.y + radius + 13}" fill="#ccc" font-size="${isOrg ? 11 : 10}" font-weight="${isOrg ? 600 : 400}" text-anchor="middle">${label}</text>
|
|
${sublabel}
|
|
`;
|
|
}).join("");
|
|
|
|
return `<svg viewBox="0 0 ${W} ${H}" width="100%" height="100%" style="max-height:500px">${clustersSvg}${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%; }
|
|
.legend-line { display: inline-block; }
|
|
.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">People</div></div>
|
|
<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div>
|
|
<div class="stat"><div class="stat-value">${this.edges?.filter((e: GraphEdge) => e.type === "point_of_contact").length || 0}</div><div class="stat-label">Cross-org Links</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 => {
|
|
const labels: Record<string, string> = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" };
|
|
return `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${labels[f]}</button>`;
|
|
}).join("")}
|
|
</div>
|
|
|
|
<div class="graph-canvas">
|
|
${this.nodes.length > 0 ? this.renderGraphNodes() : `
|
|
<div class="placeholder">
|
|
<p style="font-size:48px">🕸️</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> Organizations</div>
|
|
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#555" stroke-width="2"/></svg> Works at</div>
|
|
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"/></svg> Point of contact</div>
|
|
</div>
|
|
|
|
${this.workspaces.length > 0 ? `
|
|
<div style="margin-top:20px;font-size:14px;font-weight:600;color:#aaa">${this.space === "demo" ? "Organizations" : "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 · ${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);
|