835 lines
29 KiB
TypeScript
835 lines
29 KiB
TypeScript
/**
|
|
* <folk-graph-viewer> — 3D community relationship graph.
|
|
*
|
|
* Displays network nodes (people, companies, opportunities)
|
|
* and edges in a 3D force-directed layout using 3d-force-graph (WebGL).
|
|
* Left-drag pans, scroll zooms, right-drag orbits.
|
|
*/
|
|
|
|
interface GraphNode {
|
|
id: string;
|
|
name: string;
|
|
type: "person" | "company" | "opportunity" | "rspace_user";
|
|
workspace: string;
|
|
role?: string;
|
|
location?: string;
|
|
description?: string;
|
|
trustScore?: number;
|
|
// 3d-force-graph internal properties
|
|
x?: number;
|
|
y?: number;
|
|
z?: number;
|
|
}
|
|
|
|
interface GraphEdge {
|
|
source: string | GraphNode;
|
|
target: string | GraphNode;
|
|
type: string;
|
|
label?: string;
|
|
weight?: number;
|
|
}
|
|
|
|
const DELEGATION_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const;
|
|
type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
|
|
|
|
// Node colors by type
|
|
const NODE_COLORS: Record<string, number> = {
|
|
person: 0x3b82f6,
|
|
company: 0x22c55e,
|
|
opportunity: 0xf59e0b,
|
|
rspace_user: 0xa78bfa,
|
|
};
|
|
|
|
const COMPANY_PALETTE = [0x6366f1, 0x22c55e, 0xf59e0b, 0xec4899, 0x14b8a6, 0xf97316, 0x8b5cf6, 0x06b6d4];
|
|
|
|
// Edge colors/widths by type
|
|
const EDGE_STYLES: Record<string, { color: string; width: number; opacity: number; dashed?: boolean }> = {
|
|
work_at: { color: "#888888", width: 0.5, opacity: 0.35 },
|
|
point_of_contact: { color: "#c084fc", width: 1.5, opacity: 0.6, dashed: true },
|
|
collaborates: { color: "#f59e0b", width: 1, opacity: 0.4, dashed: true },
|
|
delegates_to: { color: "#a78bfa", width: 2, opacity: 0.6 },
|
|
default: { color: "#666666", width: 0.5, opacity: 0.25 },
|
|
};
|
|
|
|
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" | "rspace_user" = "all";
|
|
private searchQuery = "";
|
|
private error = "";
|
|
private selectedNode: GraphNode | null = null;
|
|
private trustMode = false;
|
|
private authority: DelegationAuthority = "voting";
|
|
|
|
// 3D graph instance
|
|
private graph: any = null;
|
|
private graphContainer: HTMLDivElement | null = null;
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
private companyColors: Map<string, number> = new Map();
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
this.renderDOM();
|
|
this.loadData();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.resizeObserver) {
|
|
this.resizeObserver.disconnect();
|
|
this.resizeObserver = null;
|
|
}
|
|
if (this.graph) {
|
|
this.graph._destructor?.();
|
|
this.graph = null;
|
|
}
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/rnetwork/);
|
|
return match ? match[0] : "";
|
|
}
|
|
|
|
private async loadData() {
|
|
const base = this.getApiBase();
|
|
try {
|
|
const trustParam = this.trustMode ? `?trust=true&authority=${this.authority}` : "";
|
|
const [wsRes, infoRes, graphRes] = await Promise.all([
|
|
fetch(`${base}/api/workspaces`),
|
|
fetch(`${base}/api/info`),
|
|
fetch(`${base}/api/graph${trustParam}`),
|
|
]);
|
|
if (wsRes.ok) this.workspaces = await wsRes.json();
|
|
if (infoRes.ok) this.info = await infoRes.json();
|
|
if (graphRes.ok) {
|
|
const graph = await graphRes.json();
|
|
this.importGraph(graph);
|
|
}
|
|
} catch { /* offline */ }
|
|
this.updateStatsBar();
|
|
this.updateAuthorityBar();
|
|
this.updateWorkspaceList();
|
|
this.updateGraphData();
|
|
}
|
|
|
|
private async reloadWithAuthority(authority: DelegationAuthority) {
|
|
this.authority = authority;
|
|
this.trustMode = true;
|
|
await this.loadData();
|
|
}
|
|
|
|
private importGraph(graph: { nodes?: any[]; edges?: any[] }) {
|
|
if (!graph.nodes?.length) return;
|
|
|
|
const companyNames = new Map<string, string>();
|
|
for (const n of graph.nodes) {
|
|
if (n.type === "company") companyNames.set(n.id, n.label || n.name || "Unknown");
|
|
}
|
|
|
|
const personCompany = new Map<string, string>();
|
|
for (const e of graph.edges || []) {
|
|
if (e.type === "works_at") personCompany.set(e.source, e.target);
|
|
}
|
|
|
|
const edgeTypeMap: Record<string, string> = {
|
|
works_at: "work_at",
|
|
contact_of: "point_of_contact",
|
|
involved_in: "point_of_contact",
|
|
involves: "collaborates",
|
|
};
|
|
|
|
this.nodes = graph.nodes.map((n: any) => {
|
|
const name = n.label || n.name || "Unknown";
|
|
const companyId = personCompany.get(n.id);
|
|
const workspace = n.type === "company" ? name : (companyId ? companyNames.get(companyId) || "" : "");
|
|
return {
|
|
id: n.id,
|
|
name,
|
|
type: n.type,
|
|
workspace,
|
|
role: n.data?.role,
|
|
location: n.data?.location,
|
|
description: n.data?.email || n.data?.domain || n.data?.stage,
|
|
trustScore: n.data?.trustScore,
|
|
} as GraphNode;
|
|
});
|
|
|
|
this.edges = (graph.edges || []).map((e: any) => ({
|
|
source: e.source,
|
|
target: e.target,
|
|
type: edgeTypeMap[e.type] || e.type,
|
|
label: e.label,
|
|
weight: e.weight,
|
|
} as GraphEdge));
|
|
|
|
// Assign company colors
|
|
const companies = this.nodes.filter(n => n.type === "company");
|
|
this.companyColors.clear();
|
|
companies.forEach((org, i) => {
|
|
this.companyColors.set(org.id, COMPANY_PALETTE[i % COMPANY_PALETTE.length]);
|
|
});
|
|
|
|
this.info = {
|
|
...this.info,
|
|
member_count: this.nodes.filter(n => n.type === "person").length,
|
|
company_count: this.nodes.filter(n => n.type === "company").length,
|
|
};
|
|
}
|
|
|
|
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 getTrustScore(nodeId: string): number {
|
|
const node = this.nodes.find(n => n.id === nodeId);
|
|
if (node?.trustScore != null) return Math.round(node.trustScore * 100);
|
|
return Math.min(100, this.edges.filter(e => {
|
|
const sid = typeof e.source === "string" ? e.source : e.source.id;
|
|
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
|
return sid === nodeId || tid === nodeId;
|
|
}).length * 20);
|
|
}
|
|
|
|
private getNodeRadius(node: GraphNode): number {
|
|
if (node.type === "company") return 22;
|
|
if (node.trustScore != null && this.trustMode) {
|
|
return 8 + (node.trustScore * 22);
|
|
}
|
|
return 12;
|
|
}
|
|
|
|
private getConnectedNodes(nodeId: string): GraphNode[] {
|
|
const connIds = new Set<string>();
|
|
for (const e of this.edges) {
|
|
const sid = typeof e.source === "string" ? e.source : e.source.id;
|
|
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
|
if (sid === nodeId) connIds.add(tid);
|
|
if (tid === nodeId) connIds.add(sid);
|
|
}
|
|
return this.nodes.filter(n => connIds.has(n.id));
|
|
}
|
|
|
|
private getNodeColor(node: GraphNode): number {
|
|
if (node.type === "company") {
|
|
return this.companyColors.get(node.id) || NODE_COLORS.company;
|
|
}
|
|
return NODE_COLORS[node.type] || NODE_COLORS.person;
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s || "";
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// ── DOM structure (rendered once) ──
|
|
|
|
private renderDOM() {
|
|
this.shadow.innerHTML = `
|
|
<style>
|
|
:host { display: flex; flex-direction: column; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); height: 100%; }
|
|
* { box-sizing: border-box; }
|
|
|
|
.toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
|
|
.search-input {
|
|
border: 1px solid var(--rs-input-border); border-radius: 8px; padding: 8px 12px;
|
|
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 13px; width: 200px; outline: none;
|
|
}
|
|
.search-input:focus { border-color: var(--rs-primary-hover); }
|
|
.filter-btn {
|
|
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--rs-input-border);
|
|
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer; font-size: 12px;
|
|
}
|
|
.filter-btn:hover { border-color: var(--rs-border-strong); }
|
|
.filter-btn.active { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
|
|
|
|
.graph-canvas {
|
|
width: 100%; flex: 1; min-height: 400px; border-radius: 12px;
|
|
background: var(--rs-canvas-bg); border: 1px solid var(--rs-border);
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.graph-canvas canvas { border-radius: 12px; }
|
|
|
|
.zoom-controls {
|
|
position: absolute; bottom: 12px; right: 12px;
|
|
display: flex; align-items: center; gap: 4px;
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
|
border-radius: 8px; padding: 4px 6px; z-index: 5;
|
|
}
|
|
.zoom-btn {
|
|
width: 28px; height: 28px; border: none; border-radius: 6px;
|
|
background: transparent; color: var(--rs-text-primary); font-size: 16px;
|
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
transition: background 0.15s;
|
|
}
|
|
.zoom-btn:hover { background: var(--rs-bg-surface-raised); }
|
|
.zoom-btn--fit { font-size: 14px; }
|
|
.zoom-level { font-size: 11px; color: var(--rs-text-muted); min-width: 36px; text-align: center; }
|
|
|
|
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
|
.stat { text-align: center; }
|
|
.stat-value { font-size: 24px; font-weight: 700; color: var(--rs-primary-hover); }
|
|
.stat-label { font-size: 11px; color: var(--rs-text-muted); }
|
|
|
|
.demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; }
|
|
|
|
.authority-bar {
|
|
display: none; gap: 4px; margin-bottom: 10px; flex-wrap: wrap;
|
|
}
|
|
.authority-bar.visible { display: flex; }
|
|
.authority-btn {
|
|
padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border);
|
|
background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer;
|
|
font-size: 11px; text-transform: capitalize;
|
|
}
|
|
.authority-btn:hover { border-color: var(--rs-border-strong); }
|
|
.authority-btn.active { border-color: #a78bfa; color: #a78bfa; background: rgba(167, 139, 250, 0.1); }
|
|
|
|
.legend { display: flex; gap: 16px; margin-top: 12px; }
|
|
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); }
|
|
.legend-dot { width: 10px; height: 10px; border-radius: 50%; }
|
|
.dot-person { background: #3b82f6; }
|
|
.dot-company { background: #22c55e; }
|
|
.dot-opportunity { background: #f59e0b; }
|
|
|
|
.detail-panel {
|
|
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
|
|
border-radius: 10px; padding: 16px; margin-top: 12px;
|
|
}
|
|
.detail-panel.visible { display: block; }
|
|
.detail-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
.detail-icon { font-size: 24px; }
|
|
.detail-info { flex: 1; }
|
|
.detail-name { font-size: 15px; font-weight: 600; color: var(--rs-text-primary); }
|
|
.detail-type { font-size: 12px; color: var(--rs-text-secondary); }
|
|
.detail-close { background: none; border: none; color: var(--rs-text-muted); font-size: 16px; cursor: pointer; padding: 4px; }
|
|
.detail-close:hover { color: var(--rs-text-primary); }
|
|
.detail-desc { font-size: 13px; color: var(--rs-text-secondary); line-height: 1.5; margin: 8px 0; }
|
|
.detail-trust { display: flex; align-items: center; gap: 8px; margin: 10px 0; }
|
|
.trust-label { font-size: 11px; color: var(--rs-text-muted); min-width: 70px; }
|
|
.trust-bar { flex: 1; height: 6px; background: var(--rs-bg-surface-raised); border-radius: 3px; overflow: hidden; }
|
|
.trust-fill { display: block; height: 100%; background: #7c3aed; border-radius: 3px; transition: width 0.3s; }
|
|
.trust-val { font-size: 12px; font-weight: 700; color: #a78bfa; min-width: 24px; text-align: right; }
|
|
.detail-section { font-size: 11px; font-weight: 600; color: var(--rs-text-muted); margin: 12px 0 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.detail-conn { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-primary); padding: 4px 0; }
|
|
.conn-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
.conn-role { font-size: 11px; color: var(--rs-text-muted); margin-left: auto; }
|
|
|
|
.workspace-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
|
.ws-card {
|
|
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong); border-radius: 10px;
|
|
padding: 16px; cursor: pointer; transition: border-color 0.2s;
|
|
}
|
|
.ws-card:hover { border-color: var(--rs-text-muted); }
|
|
.ws-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
|
|
.ws-meta { font-size: 12px; color: var(--rs-text-muted); }
|
|
|
|
/* Node label sprites rendered by 3d-force-graph CSS2D */
|
|
.node-label {
|
|
font-size: 10px; color: var(--rs-text-primary, #e2e8f0);
|
|
text-align: center; pointer-events: none;
|
|
text-shadow: 0 0 4px rgba(0,0,0,0.8);
|
|
white-space: nowrap;
|
|
}
|
|
.node-label .trust-badge {
|
|
display: inline-block; background: #7c3aed; color: #fff;
|
|
font-size: 8px; font-weight: 700; border-radius: 8px;
|
|
padding: 1px 4px; margin-left: 4px; vertical-align: top;
|
|
}
|
|
.node-label-org { font-size: 11px; font-weight: 600; }
|
|
|
|
@media (max-width: 768px) {
|
|
.graph-canvas { min-height: 300px; }
|
|
.workspace-list { grid-template-columns: 1fr; }
|
|
.stats { flex-wrap: wrap; gap: 12px; }
|
|
.toolbar { flex-direction: column; align-items: stretch; }
|
|
.search-input { width: 100%; }
|
|
}
|
|
</style>
|
|
|
|
<div class="rapp-nav" style="display:flex;gap:8px;margin-bottom:16px;align-items:center;min-height:36px">
|
|
<span style="font-size:15px;font-weight:600;flex:1;color:var(--rs-text-primary)">Network Graph${this.space === "demo" ? '<span class="demo-badge">Demo</span>' : ""}</span>
|
|
</div>
|
|
|
|
<div class="stats" id="stats-bar"></div>
|
|
|
|
<div class="toolbar">
|
|
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="">
|
|
<button class="filter-btn active" data-filter="all">All</button>
|
|
<button class="filter-btn" data-filter="person">People</button>
|
|
<button class="filter-btn" data-filter="company">Organizations</button>
|
|
<button class="filter-btn" data-filter="opportunity">Opportunities</button>
|
|
<button class="filter-btn" data-filter="rspace_user">Members</button>
|
|
<button class="filter-btn" id="trust-toggle" title="Toggle trust-weighted view">Trust</button>
|
|
</div>
|
|
|
|
<div class="authority-bar" id="authority-bar">
|
|
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${a}</button>`).join("")}
|
|
</div>
|
|
|
|
<div class="graph-canvas" id="graph-canvas">
|
|
<div id="graph-3d-container" style="width:100%;height:100%"></div>
|
|
<div class="zoom-controls">
|
|
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
|
|
<span class="zoom-level" id="zoom-level">100%</span>
|
|
<button class="zoom-btn" id="zoom-out" title="Zoom out">−</button>
|
|
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-panel" id="detail-panel"></div>
|
|
|
|
<div class="legend" id="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" id="legend-members" style="display:none"><span class="legend-dot" style="background:#a78bfa"></span> Members</div>
|
|
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#888" stroke-width="2"></line></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"></line></svg> Point of contact</div>
|
|
<div class="legend-item" id="legend-delegates" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"></line></svg> Delegates to</div>
|
|
</div>
|
|
|
|
<div id="workspace-section"></div>
|
|
`;
|
|
this.attachListeners();
|
|
this.initGraph3D();
|
|
}
|
|
|
|
private attachListeners() {
|
|
// Filter buttons
|
|
this.shadow.querySelectorAll("[data-filter]").forEach(el => {
|
|
el.addEventListener("click", () => {
|
|
this.filter = (el as HTMLElement).dataset.filter as any;
|
|
// Update active state
|
|
this.shadow.querySelectorAll("[data-filter]").forEach(b => b.classList.remove("active"));
|
|
el.classList.add("active");
|
|
this.updateGraphData();
|
|
});
|
|
});
|
|
|
|
// Search
|
|
let searchTimeout: any;
|
|
this.shadow.getElementById("search-input")?.addEventListener("input", (e) => {
|
|
this.searchQuery = (e.target as HTMLInputElement).value;
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => this.updateGraphData(), 200);
|
|
});
|
|
|
|
// Trust toggle
|
|
this.shadow.getElementById("trust-toggle")?.addEventListener("click", () => {
|
|
this.trustMode = !this.trustMode;
|
|
const btn = this.shadow.getElementById("trust-toggle");
|
|
if (btn) btn.classList.toggle("active", this.trustMode);
|
|
this.updateAuthorityBar();
|
|
this.loadData();
|
|
});
|
|
|
|
// Authority buttons
|
|
this.shadow.querySelectorAll("[data-authority]").forEach(el => {
|
|
el.addEventListener("click", () => {
|
|
const authority = (el as HTMLElement).dataset.authority as DelegationAuthority;
|
|
this.reloadWithAuthority(authority);
|
|
});
|
|
});
|
|
|
|
// Close detail panel
|
|
this.shadow.getElementById("detail-panel")?.addEventListener("click", (e) => {
|
|
if ((e.target as HTMLElement).id === "close-detail") {
|
|
this.selectedNode = null;
|
|
this.updateDetailPanel();
|
|
this.updateGraphData(); // refresh highlight
|
|
}
|
|
});
|
|
|
|
// Zoom controls
|
|
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
|
|
if (!this.graph) return;
|
|
const cam = this.graph.camera();
|
|
const dist = cam.position.length();
|
|
this.animateCameraDistance(dist * 0.75);
|
|
});
|
|
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
|
|
if (!this.graph) return;
|
|
const cam = this.graph.camera();
|
|
const dist = cam.position.length();
|
|
this.animateCameraDistance(dist * 1.33);
|
|
});
|
|
this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => {
|
|
if (this.graph) this.graph.zoomToFit(400, 40);
|
|
});
|
|
}
|
|
|
|
private animateCameraDistance(targetDist: number) {
|
|
if (!this.graph) return;
|
|
const cam = this.graph.camera();
|
|
const dir = cam.position.clone().normalize();
|
|
const target = dir.multiplyScalar(targetDist);
|
|
this.graph.cameraPosition(
|
|
{ x: target.x, y: target.y, z: target.z },
|
|
undefined,
|
|
600
|
|
);
|
|
}
|
|
|
|
// ── 3D Graph initialization ──
|
|
|
|
private async initGraph3D() {
|
|
const container = this.shadow.getElementById("graph-3d-container") as HTMLDivElement;
|
|
if (!container) return;
|
|
this.graphContainer = container;
|
|
|
|
try {
|
|
const ForceGraph3DModule = await import("3d-force-graph");
|
|
const ForceGraph3D = ForceGraph3DModule.default || ForceGraph3DModule;
|
|
|
|
const graph = ForceGraph3D({ controlType: "orbit" })(container)
|
|
.backgroundColor("rgba(0,0,0,0)")
|
|
.showNavInfo(false)
|
|
.nodeId("id")
|
|
.nodeLabel("") // we use custom canvas objects
|
|
.nodeThreeObject((node: GraphNode) => this.createNodeObject(node))
|
|
.nodeThreeObjectExtend(false)
|
|
.linkSource("source")
|
|
.linkTarget("target")
|
|
.linkColor((link: GraphEdge) => {
|
|
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
|
|
return style.color;
|
|
})
|
|
.linkWidth((link: GraphEdge) => {
|
|
if (link.type === "delegates_to") {
|
|
return 1 + (link.weight || 0.5) * 3;
|
|
}
|
|
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
|
|
return style.width;
|
|
})
|
|
.linkOpacity(0.6)
|
|
.linkDirectionalArrowLength((link: GraphEdge) =>
|
|
link.type === "delegates_to" ? 4 : 0
|
|
)
|
|
.linkDirectionalArrowRelPos(1)
|
|
.linkLineDash((link: GraphEdge) => {
|
|
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
|
|
return style.dashed ? [4, 2] : null;
|
|
})
|
|
.onNodeClick((node: GraphNode) => {
|
|
if (this.selectedNode?.id === node.id) {
|
|
this.selectedNode = null;
|
|
} else {
|
|
this.selectedNode = node;
|
|
}
|
|
this.updateDetailPanel();
|
|
this.updateGraphData(); // refresh highlight
|
|
})
|
|
.d3AlphaDecay(0.03)
|
|
.d3VelocityDecay(0.4)
|
|
.warmupTicks(80)
|
|
.cooldownTicks(200);
|
|
|
|
this.graph = graph;
|
|
|
|
// Remap controls: LEFT=PAN, RIGHT=ROTATE, MIDDLE=DOLLY
|
|
// THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2
|
|
const controls = graph.controls();
|
|
if (controls) {
|
|
controls.mouseButtons = { LEFT: 2, MIDDLE: 1, RIGHT: 0 };
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.12;
|
|
}
|
|
|
|
// ResizeObserver for responsive canvas
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
const rect = container.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
graph.width(rect.width);
|
|
graph.height(rect.height);
|
|
}
|
|
});
|
|
this.resizeObserver.observe(container);
|
|
|
|
// Initial size
|
|
requestAnimationFrame(() => {
|
|
const rect = container.getBoundingClientRect();
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
graph.width(rect.width);
|
|
graph.height(rect.height);
|
|
}
|
|
});
|
|
|
|
} catch (e) {
|
|
console.error("[folk-graph-viewer] Failed to load 3d-force-graph:", e);
|
|
container.innerHTML = `<div style="text-align:center;padding:40px;color:var(--rs-text-muted)">
|
|
<p style="font-size:48px">🕸️</p>
|
|
<p>Failed to load 3D graph renderer</p>
|
|
<p style="font-size:12px">${(e as Error).message || ""}</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
private createNodeObject(node: GraphNode): any {
|
|
// Import THREE from the global importmap
|
|
const THREE = (window as any).__THREE_CACHE__ || null;
|
|
if (!THREE) {
|
|
// Lazy-load THREE reference from import
|
|
return this.createNodeObjectAsync(node);
|
|
}
|
|
return this.buildNodeMesh(THREE, node);
|
|
}
|
|
|
|
private _threeModule: any = null;
|
|
private _pendingNodeRebuilds: GraphNode[] = [];
|
|
|
|
private async createNodeObjectAsync(node: GraphNode): Promise<any> {
|
|
if (!this._threeModule) {
|
|
this._threeModule = await import("three");
|
|
(window as any).__THREE_CACHE__ = this._threeModule;
|
|
}
|
|
return this.buildNodeMesh(this._threeModule, node);
|
|
}
|
|
|
|
private buildNodeMesh(THREE: any, node: GraphNode): any {
|
|
const radius = this.getNodeRadius(node) / 10; // scale down for 3D world
|
|
const color = this.getNodeColor(node);
|
|
const isSelected = this.selectedNode?.id === node.id;
|
|
|
|
// Create a group to hold sphere + label
|
|
const group = new THREE.Group();
|
|
|
|
// Sphere geometry
|
|
const geometry = new THREE.SphereGeometry(radius, 16, 12);
|
|
const material = new THREE.MeshLambertMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: node.type === "company" ? 0.9 : 0.75,
|
|
});
|
|
const sphere = new THREE.Mesh(geometry, material);
|
|
group.add(sphere);
|
|
|
|
// Selection ring
|
|
if (isSelected) {
|
|
const ringGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.5, 32);
|
|
const ringMat = new THREE.MeshBasicMaterial({
|
|
color,
|
|
transparent: true,
|
|
opacity: 0.6,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const ring = new THREE.Mesh(ringGeo, ringMat);
|
|
group.add(ring);
|
|
}
|
|
|
|
// Text label as sprite
|
|
const label = this.createTextSprite(THREE, node);
|
|
if (label) {
|
|
label.position.set(0, -(radius + 1.2), 0);
|
|
group.add(label);
|
|
}
|
|
|
|
// Trust badge sprite
|
|
if (node.type !== "company") {
|
|
const trust = this.getTrustScore(node.id);
|
|
if (trust >= 0) {
|
|
const badge = this.createBadgeSprite(THREE, String(trust));
|
|
if (badge) {
|
|
badge.position.set(radius - 0.2, radius - 0.2, 0);
|
|
group.add(badge);
|
|
}
|
|
}
|
|
}
|
|
|
|
return group;
|
|
}
|
|
|
|
private createTextSprite(THREE: any, node: GraphNode): any {
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return null;
|
|
|
|
const text = node.name;
|
|
const fontSize = node.type === "company" ? 28 : 24;
|
|
canvas.width = 256;
|
|
canvas.height = 64;
|
|
|
|
ctx.font = `${node.type === "company" ? "600" : "400"} ${fontSize}px system-ui, sans-serif`;
|
|
ctx.fillStyle = "#e2e8f0";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.shadowColor = "rgba(0,0,0,0.8)";
|
|
ctx.shadowBlur = 4;
|
|
ctx.fillText(text.length > 20 ? text.slice(0, 18) + "\u2026" : text, 128, 32);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: false,
|
|
});
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
sprite.scale.set(8, 2, 1);
|
|
return sprite;
|
|
}
|
|
|
|
private createBadgeSprite(THREE: any, text: string): any {
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return null;
|
|
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(32, 32, 28, 0, Math.PI * 2);
|
|
ctx.fillStyle = "#7c3aed";
|
|
ctx.fill();
|
|
|
|
ctx.font = "bold 24px system-ui, sans-serif";
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(text, 32, 33);
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.needsUpdate = true;
|
|
const spriteMaterial = new THREE.SpriteMaterial({
|
|
map: texture,
|
|
transparent: true,
|
|
depthTest: false,
|
|
});
|
|
const sprite = new THREE.Sprite(spriteMaterial);
|
|
sprite.scale.set(1.5, 1.5, 1);
|
|
return sprite;
|
|
}
|
|
|
|
// ── Data update (no DOM rebuild) ──
|
|
|
|
private updateGraphData() {
|
|
if (!this.graph) return;
|
|
|
|
const filtered = this.getFilteredNodes();
|
|
const filteredIds = new Set(filtered.map(n => n.id));
|
|
|
|
// Filter edges to only include those between visible nodes
|
|
const filteredEdges = this.edges.filter(e => {
|
|
const sid = typeof e.source === "string" ? e.source : e.source.id;
|
|
const tid = typeof e.target === "string" ? e.target : e.target.id;
|
|
return filteredIds.has(sid) && filteredIds.has(tid);
|
|
});
|
|
|
|
this.graph.graphData({
|
|
nodes: filtered,
|
|
links: filteredEdges,
|
|
});
|
|
|
|
// Update legend visibility for trust mode
|
|
const membersLegend = this.shadow.getElementById("legend-members");
|
|
const delegatesLegend = this.shadow.getElementById("legend-delegates");
|
|
if (membersLegend) membersLegend.style.display = this.trustMode ? "" : "none";
|
|
if (delegatesLegend) delegatesLegend.style.display = this.trustMode ? "" : "none";
|
|
|
|
// Fit view after data settles
|
|
setTimeout(() => {
|
|
if (this.graph) this.graph.zoomToFit(400, 40);
|
|
}, 500);
|
|
}
|
|
|
|
// ── Incremental UI updates ──
|
|
|
|
private updateStatsBar() {
|
|
const bar = this.shadow.getElementById("stats-bar");
|
|
if (!bar || !this.info) return;
|
|
const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length;
|
|
bar.innerHTML = `
|
|
<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">${crossOrg}</div><div class="stat-label">Cross-org Links</div></div>
|
|
`;
|
|
}
|
|
|
|
private updateAuthorityBar() {
|
|
const bar = this.shadow.getElementById("authority-bar");
|
|
if (!bar) return;
|
|
bar.classList.toggle("visible", this.trustMode);
|
|
bar.querySelectorAll("[data-authority]").forEach(el => {
|
|
const a = (el as HTMLElement).dataset.authority;
|
|
el.classList.toggle("active", a === this.authority);
|
|
});
|
|
}
|
|
|
|
private updateDetailPanel() {
|
|
const panel = this.shadow.getElementById("detail-panel");
|
|
if (!panel) return;
|
|
|
|
if (!this.selectedNode) {
|
|
panel.classList.remove("visible");
|
|
panel.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const n = this.selectedNode;
|
|
const connected = this.getConnectedNodes(n.id);
|
|
const trust = n.type !== "company" ? this.getTrustScore(n.id) : -1;
|
|
|
|
panel.classList.add("visible");
|
|
panel.innerHTML = `
|
|
<div class="detail-header">
|
|
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : "\u{1F464}"}</span>
|
|
<div class="detail-info">
|
|
<div class="detail-name">${this.esc(n.name)}</div>
|
|
<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
|
|
</div>
|
|
<button class="detail-close" id="close-detail">\u2715</button>
|
|
</div>
|
|
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
|
|
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
|
|
${connected.length > 0 ? `
|
|
<div class="detail-section">Connected (${connected.length})</div>
|
|
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
|
|
` : ""}
|
|
`;
|
|
}
|
|
|
|
private updateWorkspaceList() {
|
|
const section = this.shadow.getElementById("workspace-section");
|
|
if (!section) return;
|
|
if (this.workspaces.length === 0) {
|
|
section.innerHTML = "";
|
|
return;
|
|
}
|
|
section.innerHTML = `
|
|
<div style="margin-top:20px;font-size:14px;font-weight:600;color:var(--rs-text-secondary)">${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>
|
|
`;
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-graph-viewer", FolkGraphViewer);
|