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

1383 lines
50 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 WeightAccounting {
delegatedAway: Record<string, number>; // per authority: total weight delegated out
receivedWeight: Record<string, number>; // per authority: total weight received from others
effectiveWeight: Record<string, number>; // per authority: retained + received
}
interface GraphNode {
id: string;
name: string;
type: "person" | "company" | "opportunity" | "rspace_user" | "space";
workspace: string;
role?: string;
location?: string;
description?: string;
trustScore?: number;
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
weightAccounting?: WeightAccounting;
// 3d-force-graph internal properties
x?: number;
y?: number;
z?: number;
fx?: number;
fy?: number;
fz?: number;
}
interface GraphEdge {
source: string | GraphNode;
target: string | GraphNode;
type: string;
label?: string;
weight?: number;
authority?: string;
}
const DELEGATION_AUTHORITIES = ["gov-ops", "fin-ops", "dev-ops"] as const;
type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number];
type AuthoritySelection = "all" | DelegationAuthority;
// Authority display labels + colors (DB values unchanged)
const AUTHORITY_DISPLAY: Record<string, { label: string; color: string }> = {
"gov-ops": { label: "Gov", color: "#a78bfa" }, // purple — governance
"fin-ops": { label: "Econ", color: "#10b981" }, // green — economics
"dev-ops": { label: "Tech", color: "#3b82f6" }, // blue — technology
};
// Per-authority edge colors for "all" overlay mode
const AUTHORITY_COLORS: Record<string, string> = {
"gov-ops": "#a78bfa", // purple
"fin-ops": "#10b981", // green
"dev-ops": "#3b82f6", // blue
};
// Node colors by type
const NODE_COLORS: Record<string, number> = {
person: 0x3b82f6,
company: 0x22c55e,
opportunity: 0xf59e0b,
rspace_user: 0xa78bfa,
space: 0x64748b,
};
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 },
member_of: { color: "#64748b", width: 0.4, opacity: 0.2 },
member_is: { color: "#38bdf8", width: 1.5, opacity: 0.5 },
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" | "space" = "all";
private searchQuery = "";
private error = "";
private selectedNode: GraphNode | null = null;
private trustMode = false;
private authority: AuthoritySelection = "gov-ops";
private layoutMode: "force" | "rings" = "force";
private ringGuides: any[] = [];
private delegationTarget: GraphNode | null = null;
private delegationTotal = 50;
private delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 };
private demoDelegations: GraphEdge[] = [];
// 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=${encodeURIComponent(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: AuthoritySelection) {
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,
authority: e.authority,
} as GraphEdge));
// Weight accounting: per-authority delegated/received/effective
const authorities = ["gov-ops", "fin-ops", "dev-ops"];
const acctMap = new Map<string, WeightAccounting>();
for (const node of this.nodes) {
acctMap.set(node.id, {
delegatedAway: Object.fromEntries(authorities.map(a => [a, 0])),
receivedWeight: Object.fromEntries(authorities.map(a => [a, 0])),
effectiveWeight: Object.fromEntries(authorities.map(a => [a, 0])),
});
}
for (const e of this.edges) {
if (e.type !== "delegates_to" || !e.authority) continue;
const sid = typeof e.source === "string" ? e.source : e.source.id;
const tid = typeof e.target === "string" ? e.target : e.target.id;
const w = e.weight || 0.5;
const sAcct = acctMap.get(sid);
const tAcct = acctMap.get(tid);
if (sAcct) sAcct.delegatedAway[e.authority] = (sAcct.delegatedAway[e.authority] || 0) + w;
if (tAcct) tAcct.receivedWeight[e.authority] = (tAcct.receivedWeight[e.authority] || 0) + w;
}
acctMap.forEach((acct) => {
for (const a of authorities) {
acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]);
}
});
// Compute normalized delegatedWeight (for backwards compat) and attach accounting
let maxEW = 0;
acctMap.forEach((acct) => {
for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]);
});
if (maxEW === 0) maxEW = 1;
for (const node of this.nodes) {
const acct = acctMap.get(node.id);
if (acct) {
node.weightAccounting = acct;
const avgEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0) / authorities.length;
node.delegatedWeight = avgEW / maxEW;
}
}
// 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,
rspace_member_count: this.nodes.filter(n => n.type === "rspace_user").length,
};
}
private getFilteredNodes(): GraphNode[] {
let filtered = this.nodes;
if (this.filter !== "all") {
if (this.filter === "rspace_user") {
// Include space hub nodes alongside members
filtered = filtered.filter(n => n.type === "rspace_user" || n.type === "space");
} else {
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.type === "space") return 16;
if (this.trustMode && node.weightAccounting) {
const acct = node.weightAccounting;
let ew: number;
if (this.authority !== "all") {
ew = acct.effectiveWeight[this.authority] || 0;
} else {
const vals = Object.values(acct.effectiveWeight);
ew = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
}
// Normalize against max in current filtered view
const maxEW = this._currentMaxEffectiveWeight || 1;
return 4 + (ew / maxEW) * 26;
}
if (node.type === "rspace_user") return 10;
return 12;
}
private _currentMaxEffectiveWeight = 1;
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: 4px; 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; flex-wrap: wrap; }
.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; }
.btn-delegate {
padding: 6px 14px; border: 1px solid #a78bfa; border-radius: 8px;
background: rgba(167,139,250,0.1); color: #a78bfa; cursor: pointer;
font-size: 12px; font-weight: 600; margin-top: 8px;
}
.btn-delegate:hover { background: rgba(167,139,250,0.2); }
.deleg-popup {
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; padding: 16px; margin-top: 12px; position: relative;
}
.deleg-popup.visible { display: block; }
.deleg-popup-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.deleg-popup-close {
position: absolute; top: 10px; right: 12px;
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px;
}
.deleg-slider-row {
display: flex; align-items: center; gap: 10px; margin: 8px 0;
}
.deleg-slider-label { font-size: 12px; font-weight: 500; min-width: 50px; }
.deleg-slider { flex: 1; accent-color: #a78bfa; }
.deleg-slider-val { font-size: 12px; font-weight: 700; min-width: 36px; text-align: right; }
.deleg-confirm {
padding: 6px 16px; border: none; border-radius: 8px;
background: #a78bfa; color: #fff; cursor: pointer;
font-size: 13px; font-weight: 600; margin-top: 10px;
}
.deleg-confirm:hover { background: #8b5cf6; }
.deleg-step-label { font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
@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="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>
<button class="filter-btn" id="rings-toggle" title="Toggle concentric ring layout">Rings</button>
</div>
<div class="authority-bar" id="authority-bar">
<button class="authority-btn" data-authority="all">All</button>
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn" data-authority="${a}">${AUTHORITY_DISPLAY[a]?.label || 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">&minus;</button>
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</button>
</div>
</div>
<div class="detail-panel" id="detail-panel"></div>
<div class="deleg-popup" id="deleg-popup"></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" id="legend-space" style="display:none"><span class="legend-dot" style="background:#64748b"></span> Space</div>
<div class="legend-item" id="legend-member-is" style="display:none"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#38bdf8" stroke-width="2"></line></svg> Matched identity</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>
<span id="legend-authority-colors" style="display:none">
${DELEGATION_AUTHORITIES.map(a => `<div class="legend-item"><span class="legend-dot" style="background:${AUTHORITY_DISPLAY[a]?.color || '#a78bfa'}"></span> ${AUTHORITY_DISPLAY[a]?.label || a}</div>`).join("")}
</span>
</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);
// Auto-enable rings when trust mode is turned on
if (this.trustMode && this.layoutMode !== "rings") {
this.layoutMode = "rings";
const ringsBtn = this.shadow.getElementById("rings-toggle");
if (ringsBtn) ringsBtn.classList.add("active");
}
this.updateAuthorityBar();
this.loadData();
});
// Rings toggle
this.shadow.getElementById("rings-toggle")?.addEventListener("click", () => {
this.layoutMode = this.layoutMode === "rings" ? "force" : "rings";
const btn = this.shadow.getElementById("rings-toggle");
if (btn) btn.classList.toggle("active", this.layoutMode === "rings");
if (this.layoutMode === "rings") {
this.applyRingLayout();
} else {
this.clearFixedPositions();
this.removeRingGuides();
}
});
// Authority buttons
this.shadow.querySelectorAll("[data-authority]").forEach(el => {
el.addEventListener("click", () => {
const authority = (el as HTMLElement).dataset.authority as AuthoritySelection;
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 {
let ForceGraph3D = (window as any).ForceGraph3D;
if (!ForceGraph3D) {
// CDN script tag may have failed (503 etc) — try loading dynamically with fallback
const cdns = [
"https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js",
"https://cdn.jsdelivr.net/npm/3d-force-graph@1.73.4/dist/3d-force-graph.min.js",
];
for (const url of cdns) {
try {
await new Promise<void>((resolve, reject) => {
const s = document.createElement("script");
s.src = url;
s.onload = () => resolve();
s.onerror = () => reject();
document.head.appendChild(s);
});
ForceGraph3D = (window as any).ForceGraph3D;
if (ForceGraph3D) break;
} catch { /* try next CDN */ }
}
if (!ForceGraph3D) throw new Error("ForceGraph3D failed to load from all CDN sources");
}
// Pre-load THREE so nodeThreeObject callback is synchronous.
// Import from the same module the UMD build uses internally
// to avoid "not an instance of THREE.Object3D" errors.
const THREE = await import("three");
this._threeModule = THREE;
(window as any).__THREE_CACHE__ = THREE;
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) => {
if (link.type === "delegates_to") {
if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || EDGE_STYLES.delegates_to.color;
}
return EDGE_STYLES.delegates_to.color;
}
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) * 8;
}
const style = EDGE_STYLES[link.type] || EDGE_STYLES.default;
return style.width;
})
.linkCurvature((link: GraphEdge) =>
link.type === "delegates_to" ? 0.15 : 0
)
.linkCurveRotation("rotation")
.linkOpacity(0.6)
.linkDirectionalArrowLength((link: GraphEdge) =>
link.type === "delegates_to" ? 4 : 0
)
.linkDirectionalArrowRelPos(1)
.linkDirectionalParticles((link: GraphEdge) =>
link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0
)
.linkDirectionalParticleSpeed(0.004)
.linkDirectionalParticleWidth((link: GraphEdge) =>
link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0
)
.linkDirectionalParticleColor((link: GraphEdge) => {
if (link.type !== "delegates_to") return null;
if (this.authority === "all" && link.authority) {
return AUTHORITY_COLORS[link.authority] || "#c4b5fd";
}
return "#c4b5fd";
})
.onNodeClick((node: GraphNode) => {
if (this.selectedNode?.id === node.id) {
this.selectedNode = null;
} else {
this.selectedNode = node;
}
this.updateDetailPanel();
this.updateGraphData(); // refresh highlight
})
.d3AlphaDecay(0.02)
.d3VelocityDecay(0.3)
.warmupTicks(120)
.cooldownTicks(300);
this.graph = graph;
// Custom d3 forces for better clustering and readability
// Stronger repulsion — hub nodes push harder
const chargeForce = graph.d3Force('charge');
if (chargeForce) {
chargeForce.strength((node: GraphNode) => {
if (node.type === 'company' || node.type === 'space') return -300;
return -120;
});
}
// Type-specific link distances
const linkForce = graph.d3Force('link');
if (linkForce) {
linkForce.distance((link: any) => {
const type = link.type || '';
switch (type) {
case 'work_at': return 40;
case 'member_of': return 60;
case 'member_is': return 20;
case 'delegates_to': return 50;
case 'collaborates': return 80;
case 'point_of_contact': return 70;
default: return 60;
}
});
}
// 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">&#x1F578;&#xFE0F;</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 — show per-authority effective weight in trust mode
if (node.type !== "company" && node.type !== "space") {
let badgeText = "";
let badgeColor = "#7c3aed";
if (this.trustMode && node.weightAccounting && this.authority !== "all") {
const ew = node.weightAccounting.effectiveWeight[this.authority] || 0;
badgeText = ew.toFixed(1);
badgeColor = AUTHORITY_COLORS[this.authority] || "#7c3aed";
} else {
const trust = this.getTrustScore(node.id);
if (trust >= 0) badgeText = String(trust);
}
if (badgeText) {
const badge = this.createBadgeSprite(THREE, badgeText, badgeColor);
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, color = "#7c3aed"): 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 = color;
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));
// Compute max effective weight for filtered nodes (used by getNodeRadius)
if (this.trustMode) {
let maxEW = 0;
for (const n of filtered) {
if (!n.weightAccounting) continue;
if (this.authority !== "all") {
maxEW = Math.max(maxEW, n.weightAccounting.effectiveWeight[this.authority] || 0);
} else {
const vals = Object.values(n.weightAccounting.effectiveWeight);
const avg = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
maxEW = Math.max(maxEW, avg);
}
}
this._currentMaxEffectiveWeight = maxEW || 1;
}
// Filter edges to only include those between visible nodes
let 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);
});
// Authority-filtered edge view: in trust mode with specific authority, only show that authority's delegation edges
if (this.trustMode && this.authority !== "all") {
filteredEdges = filteredEdges.filter(e => {
if (e.type !== "delegates_to") return true;
return e.authority === this.authority;
});
}
this.graph.graphData({
nodes: filtered,
links: filteredEdges,
});
// Update legend visibility — members always visible when present, delegations only in trust mode
const hasMembers = this.nodes.some(n => n.type === "rspace_user");
const membersLegend = this.shadow.getElementById("legend-members");
const delegatesLegend = this.shadow.getElementById("legend-delegates");
const authorityColors = this.shadow.getElementById("legend-authority-colors");
const spaceLegend = this.shadow.getElementById("legend-space");
const memberIsLegend = this.shadow.getElementById("legend-member-is");
if (membersLegend) membersLegend.style.display = hasMembers ? "" : "none";
if (spaceLegend) spaceLegend.style.display = hasMembers ? "" : "none";
if (memberIsLegend) memberIsLegend.style.display = (hasMembers && this.edges.some(e => e.type === "member_is")) ? "" : "none";
if (delegatesLegend) delegatesLegend.style.display = (this.trustMode && this.authority !== "all") ? "" : "none";
if (authorityColors) authorityColors.style.display = (this.trustMode && this.authority === "all") ? "" : "none";
// Apply ring layout if active, otherwise fit view
if (this.layoutMode === "rings") {
this.applyRingLayout();
}
setTimeout(() => {
if (this.graph) this.graph.zoomToFit(400, 40);
}, 500);
}
// ── Ring layout ──
private applyRingLayout() {
if (!this.graph) return;
const data = this.graph.graphData();
const nodes = data.nodes as GraphNode[];
// Group by role
const adminNodes = nodes.filter(n => n.role === "admin");
const memberNodes = nodes.filter(n => n.role === "member");
const viewerNodes = nodes.filter(n => n.role === "viewer");
const otherNodes = nodes.filter(n => n.type === "space");
// Place space hub at center
for (const n of otherNodes) {
n.fx = 0; n.fy = 0; n.fz = 0;
}
this.placeRing(adminNodes, 30);
this.placeRing(memberNodes, 80);
this.placeRing(viewerNodes, 160);
this.graph.graphData(data);
this.graph.d3ReheatSimulation();
// Add ring guides
this.removeRingGuides();
this.addRingGuides();
}
private placeRing(nodes: GraphNode[], radius: number) {
const count = nodes.length;
if (count === 0) return;
for (let i = 0; i < count; i++) {
const angle = (2 * Math.PI * i) / count;
nodes[i].fx = Math.cos(angle) * radius;
nodes[i].fy = 0;
nodes[i].fz = Math.sin(angle) * radius;
}
}
private clearFixedPositions() {
if (!this.graph) return;
const data = this.graph.graphData();
for (const n of data.nodes as GraphNode[]) {
delete n.fx;
delete n.fy;
delete n.fz;
}
this.graph.graphData(data);
this.graph.d3ReheatSimulation();
}
private addRingGuides() {
const THREE = this._threeModule;
if (!THREE || !this.graph) return;
const scene = this.graph.scene();
if (!scene) return;
const ringRadii = [
{ r: 30, color: 0xa78bfa, label: "Admin" },
{ r: 80, color: 0x10b981, label: "Member" },
{ r: 160, color: 0x3b82f6, label: "Viewer" },
];
for (const { r, color } of ringRadii) {
const geometry = new THREE.RingGeometry(r - 0.5, r + 0.5, 128);
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2; // flat on XZ plane
mesh.position.y = 0;
scene.add(mesh);
this.ringGuides.push(mesh);
}
}
private removeRingGuides() {
const scene = this.graph?.scene();
if (!scene) return;
for (const mesh of this.ringGuides) {
scene.remove(mesh);
mesh.geometry?.dispose();
mesh.material?.dispose();
}
this.ringGuides = [];
}
// ── 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;
const memberCount = this.info.rspace_member_count || 0;
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>
${memberCount > 0 ? `<div class="stat"><div class="stat-value">${memberCount}</div><div class="stat-label">Members</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" && n.type !== "space") ? this.getTrustScore(n.id) : -1;
// Per-authority effective weight display
let weightHtml = "";
if (n.weightAccounting && (n.type === "rspace_user" || n.type === "person")) {
const acct = n.weightAccounting;
weightHtml = DELEGATION_AUTHORITIES.map(a => {
const ew = Math.round((acct.effectiveWeight[a] || 0) * 100) / 100;
const disp = AUTHORITY_DISPLAY[a];
return `<div class="detail-trust"><span class="trust-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span><span class="trust-bar"><span class="trust-fill" style="width:${Math.min(ew * 50, 100)}%;background:${disp?.color || '#a78bfa'}"></span></span><span class="trust-val" style="color:${disp?.color || '#a78bfa'}">${ew.toFixed(2)}</span></div>`;
}).join("");
}
const canDelegate = n.type === "rspace_user" || n.type === "person";
panel.classList.add("visible");
panel.innerHTML = `
<div class="detail-header">
<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\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.type === "space" ? "Space" : n.type === "rspace_user" ? (n.role || "Member") : 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>` : ""}
${weightHtml}
${canDelegate ? `<button class="btn-delegate" id="btn-delegate">Delegate to ${this.esc(n.name.split(" ")[0])}</button>` : ""}
${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("")}
` : ""}
`;
// Delegate button listener
this.shadow.getElementById("btn-delegate")?.addEventListener("click", () => {
this.delegationTarget = n;
this.delegationTotal = 50;
this.delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 };
this.showDelegationPopup();
});
}
private showDelegationPopup() {
const popup = this.shadow.getElementById("deleg-popup");
if (!popup || !this.delegationTarget) return;
const target = this.delegationTarget;
const total = this.delegationTotal;
const split = this.delegationSplit;
popup.classList.add("visible");
popup.innerHTML = `
<button class="deleg-popup-close" id="deleg-close">\u2715</button>
<div class="deleg-popup-title">Delegate to ${this.esc(target.name)}</div>
<div class="deleg-step-label">Step 1: Total weight</div>
<div class="deleg-slider-row">
<span class="deleg-slider-label">Total</span>
<input type="range" class="deleg-slider" id="deleg-total" min="0" max="100" value="${total}">
<span class="deleg-slider-val" id="deleg-total-val">${total}%</span>
</div>
<div class="deleg-step-label">Step 2: Domain split</div>
${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const pct = split[a] || 0;
return `<div class="deleg-slider-row">
<span class="deleg-slider-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span>
<input type="range" class="deleg-slider" data-deleg-auth="${a}" min="0" max="100" value="${pct}" style="accent-color:${disp?.color || '#a78bfa'}">
<span class="deleg-slider-val" data-deleg-val="${a}">${pct}%</span>
</div>`;
}).join("")}
<button class="deleg-confirm" id="deleg-confirm">Confirm Delegation</button>
`;
// Close
this.shadow.getElementById("deleg-close")?.addEventListener("click", () => {
this.delegationTarget = null;
popup.classList.remove("visible");
});
// Total slider
this.shadow.getElementById("deleg-total")?.addEventListener("input", (e) => {
this.delegationTotal = parseInt((e.target as HTMLInputElement).value);
const valEl = this.shadow.getElementById("deleg-total-val");
if (valEl) valEl.textContent = this.delegationTotal + "%";
});
// Domain sliders — adjust others proportionally
popup.querySelectorAll("[data-deleg-auth]").forEach(el => {
el.addEventListener("input", (e) => {
const auth = (el as HTMLElement).dataset.delegAuth!;
const newVal = parseInt((e.target as HTMLInputElement).value);
const others = DELEGATION_AUTHORITIES.filter(a => a !== auth);
const oldOtherSum = others.reduce((s, a) => s + (this.delegationSplit[a] || 0), 0);
this.delegationSplit[auth] = newVal;
// Redistribute remaining to others proportionally
const remaining = 100 - newVal;
if (oldOtherSum > 0) {
for (const o of others) {
this.delegationSplit[o] = Math.round((this.delegationSplit[o] / oldOtherSum) * remaining);
}
} else {
const each = Math.round(remaining / others.length);
for (const o of others) this.delegationSplit[o] = each;
}
// Update UI
for (const a of DELEGATION_AUTHORITIES) {
const slider = popup.querySelector(`[data-deleg-auth="${a}"]`) as HTMLInputElement;
const val = popup.querySelector(`[data-deleg-val="${a}"]`);
if (slider) slider.value = String(this.delegationSplit[a]);
if (val) val.textContent = this.delegationSplit[a] + "%";
}
});
});
// Confirm
this.shadow.getElementById("deleg-confirm")?.addEventListener("click", () => {
this.confirmDelegation();
});
}
private confirmDelegation() {
if (!this.delegationTarget) return;
const target = this.delegationTarget;
const totalWeight = this.delegationTotal / 100;
// Create delegation edges for each authority
for (const a of DELEGATION_AUTHORITIES) {
const pct = this.delegationSplit[a] || 0;
const weight = Math.round(totalWeight * (pct / 100) * 100) / 100;
if (weight <= 0) continue;
const edge: GraphEdge = {
source: "me-demo",
target: target.id,
type: "delegates_to",
weight,
authority: a,
};
this.edges.push(edge);
this.demoDelegations.push(edge);
}
// Ensure "me-demo" node exists
if (!this.nodes.find(n => n.id === "me-demo")) {
this.nodes.push({
id: "me-demo",
name: "You",
type: "rspace_user",
workspace: "",
role: "member",
});
}
// Recompute weight accounting
this.recomputeWeightAccounting();
// Close popup and refresh
this.delegationTarget = null;
const popup = this.shadow.getElementById("deleg-popup");
if (popup) popup.classList.remove("visible");
this.updateGraphData();
}
private recomputeWeightAccounting() {
const authorities = ["gov-ops", "fin-ops", "dev-ops"];
const acctMap = new Map<string, WeightAccounting>();
for (const node of this.nodes) {
acctMap.set(node.id, {
delegatedAway: Object.fromEntries(authorities.map(a => [a, 0])),
receivedWeight: Object.fromEntries(authorities.map(a => [a, 0])),
effectiveWeight: Object.fromEntries(authorities.map(a => [a, 0])),
});
}
for (const e of this.edges) {
if (e.type !== "delegates_to" || !e.authority) continue;
const sid = typeof e.source === "string" ? e.source : e.source.id;
const tid = typeof e.target === "string" ? e.target : e.target.id;
const w = e.weight || 0.5;
const sAcct = acctMap.get(sid);
const tAcct = acctMap.get(tid);
if (sAcct) sAcct.delegatedAway[e.authority] = (sAcct.delegatedAway[e.authority] || 0) + w;
if (tAcct) tAcct.receivedWeight[e.authority] = (tAcct.receivedWeight[e.authority] || 0) + w;
}
acctMap.forEach((acct) => {
for (const a of authorities) {
acct.effectiveWeight[a] = acct.receivedWeight[a] + Math.max(0, 1 - acct.delegatedAway[a]);
}
});
let maxEW = 0;
acctMap.forEach((acct) => {
for (const a of authorities) maxEW = Math.max(maxEW, acct.effectiveWeight[a]);
});
if (maxEW === 0) maxEW = 1;
for (const node of this.nodes) {
const acct = acctMap.get(node.id);
if (acct) {
node.weightAccounting = acct;
const avgEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0) / authorities.length;
node.delegatedWeight = avgEW / maxEW;
}
}
}
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 &middot; ${ws.edgeCount || 0} edges</div>
</div>
`).join("")}
</div>
`;
}
}
customElements.define("folk-graph-viewer", FolkGraphViewer);