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

1834 lines
68 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* <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" | "module" | "feed";
workspace: string;
role?: string;
location?: string;
description?: string;
trustScore?: number;
delegatedWeight?: number; // 0-1 normalized, computed from delegation edges
baseWeights?: Record<string, number>; // per-authority base weights (0-1 scale from server)
weightAccounting?: WeightAccounting;
// Layers mode fields
layerId?: string;
feedId?: string;
feedKind?: string; // FlowKind
moduleColor?: number;
// 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;
flowKind?: string; // FlowKind, for cross_layer_flow edges
strength?: number; // 0-1, for cross_layer_flow edges
}
type AxisPlane = "xy" | "xz" | "yz";
interface LayerInstance {
moduleId: string;
moduleName: string;
moduleIcon: string;
moduleColor: number;
axis: AxisPlane;
feeds: Array<{ id: string; name: string; kind: string }>;
acceptsFeeds: string[];
}
interface CrossLayerFlow {
id: string;
sourceLayerIdx: number;
sourceFeedId: string;
targetLayerIdx: number;
targetFeedId: string;
kind: string; // FlowKind
strength: number;
}
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];
// Layer/Flow constants (mirrored from lib/layer-types.ts for browser use)
const LAYER_FLOW_COLORS: Record<string, string> = {
economic: "#4ade80", trust: "#c4b5fd", data: "#60a5fa",
attention: "#fcd34d", governance: "#a78bfa", resource: "#6ee7b7", custom: "#94a3b8",
};
const LAYER_FLOW_LABELS: Record<string, string> = {
economic: "Economic", trust: "Trust", data: "Data",
attention: "Attention", governance: "Delegation", resource: "Resource", custom: "Custom",
};
const MODULE_PALETTE = [0x6366f1, 0x10b981, 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 demoDelegations: GraphEdge[] = [];
private showMemberList = false;
// Layers mode state
private layersMode = false;
private layerInstances: LayerInstance[] = [];
private crossLayerFlows: CrossLayerFlow[] = [];
private flowWiringSource: { layerIdx: number; feedId: string; feedKind: string } | null = null;
private layersPanelOpen = false;
private layerPlaneObjects: any[] = [];
private savedGraphState: { nodes: GraphNode[]; edges: GraphEdge[] } | null = null;
private savedCameraControls: { minPolar: number; maxPolar: number; minDist: number; maxDist: number } | null = null;
// Multi-select delegation state
private selectedDelegates: Map<string, { node: GraphNode; weights: Record<string, number> }> = new Map();
private delegateSearchQuery = "";
// 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,
baseWeights: n.data?.trustScores || {},
} 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;
}
// Effective weight = retained base + received delegations (absolute tokens, ×100)
const nodeById = new Map(this.nodes.map(n => [n.id, n]));
acctMap.forEach((acct, nodeId) => {
const node = nodeById.get(nodeId);
for (const a of authorities) {
const base = (node?.baseWeights?.[a] || 0) * 100;
const away = acct.delegatedAway[a] * 100;
const recv = acct.receivedWeight[a] * 100;
acct.delegatedAway[a] = away;
acct.receivedWeight[a] = recv;
acct.effectiveWeight[a] = Math.max(0, base - away) + recv;
}
});
// Attach accounting to nodes
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 totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0);
node.delegatedWeight = totalEW / (maxEW * 3);
}
}
// 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 — dramatic sizing
const maxEW = this._currentMaxEffectiveWeight || 1;
return 6 + (ew / maxEW) * 50;
}
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-row {
display: flex; flex: 1; gap: 0; min-height: 400px;
}
.graph-canvas {
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; }
.member-list-panel {
display: none; width: 260px; flex-shrink: 0;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 0 12px 12px 0; overflow-y: auto;
font-size: 12px; margin-left: -1px;
}
.member-list-panel.visible { display: block; }
.member-group { padding: 8px 12px; }
.member-group-header {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; padding: 6px 0 4px; border-bottom: 1px solid var(--rs-border);
display: flex; justify-content: space-between; align-items: center;
}
.member-group-count {
font-size: 10px; font-weight: 400; color: var(--rs-text-muted);
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06));
padding: 1px 6px; border-radius: 8px;
}
.member-item {
padding: 4px 0; display: flex; align-items: center; gap: 6px;
cursor: pointer; border-radius: 4px;
}
.member-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
.member-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.member-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.member-weight { font-size: 10px; font-weight: 600; min-width: 70px; text-align: right; white-space: nowrap; }
.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; }
/* ── Delegation panel (bottom drawer) ── */
.deleg-panel {
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; padding: 12px 16px; margin-top: 8px;
}
.deleg-panel.visible { display: block; }
.deleg-panel-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
}
.deleg-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
.deleg-panel-close {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px;
}
.deleg-search-wrap { position: relative; margin-bottom: 8px; }
.deleg-search {
width: 100%; padding: 6px 10px; border: 1px solid var(--rs-input-border);
border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text);
font-size: 12px; outline: none;
}
.deleg-search:focus { border-color: #a78bfa; }
.deleg-results {
position: absolute; top: 100%; left: 0; right: 0; z-index: 20;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 8px; max-height: 160px; overflow-y: auto; margin-top: 2px;
}
.deleg-result-item {
padding: 6px 10px; cursor: pointer; font-size: 12px;
display: flex; align-items: center; gap: 6px;
}
.deleg-result-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); }
.deleg-result-role { font-size: 10px; color: var(--rs-text-muted); margin-left: auto; }
.deleg-row {
display: flex; align-items: center; gap: 6px; padding: 6px 0;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
}
.deleg-row:last-child { border-bottom: none; }
.deleg-row-name { font-size: 12px; font-weight: 500; min-width: 90px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.deleg-row-sliders { display: flex; gap: 4px; flex: 1; align-items: center; }
.deleg-mini-slider { width: 60px; height: 4px; accent-color: #a78bfa; }
.deleg-mini-val { font-size: 10px; font-weight: 700; min-width: 24px; text-align: right; }
.deleg-row-remove {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
font-size: 12px; padding: 2px 4px; border-radius: 4px;
}
.deleg-row-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.deleg-confirm-all {
padding: 6px 16px; border: none; border-radius: 8px;
background: #a78bfa; color: #fff; cursor: pointer;
font-size: 12px; font-weight: 600; margin-top: 8px; width: 100%;
}
.deleg-confirm-all:hover { background: #8b5cf6; }
.deleg-remaining { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; }
.deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; }
/* ── Layers panel ── */
.layers-panel {
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; padding: 12px 16px; margin-top: 8px;
}
.layers-panel.visible { display: block; }
.layers-panel-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.layers-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
.layers-panel-close {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px;
}
.module-picker {
display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;
}
.module-pick-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; display: flex; align-items: center; gap: 4px;
}
.module-pick-btn:hover { border-color: var(--rs-border-strong); }
.module-pick-btn.selected { border-color: var(--rs-primary-hover); color: var(--rs-primary-hover); }
.module-pick-btn.disabled { opacity: 0.4; cursor: not-allowed; }
.layer-row {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
}
.layer-row:last-child { border-bottom: none; }
.layer-row-icon { font-size: 16px; }
.layer-row-name { font-size: 12px; font-weight: 500; flex: 1; }
.axis-select {
padding: 3px 6px; border-radius: 4px; border: 1px solid var(--rs-input-border);
background: var(--rs-input-bg); color: var(--rs-input-text); font-size: 11px; cursor: pointer;
}
.layer-row-remove {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
font-size: 12px; padding: 2px 4px; border-radius: 4px;
}
.layer-row-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.layer-feeds-hint { font-size: 10px; color: var(--rs-text-muted); margin-top: 2px; }
/* ── Flow dialog overlay ── */
.flow-dialog-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
}
.flow-dialog {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; padding: 20px; min-width: 300px; max-width: 400px;
}
.flow-dialog-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.flow-dialog-pair { font-size: 12px; color: var(--rs-text-muted); margin-bottom: 10px; }
.flow-kind-option {
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
border-radius: 6px; cursor: pointer; margin-bottom: 4px;
border: 1px solid transparent;
}
.flow-kind-option:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
.flow-kind-option.selected { border-color: var(--rs-primary-hover); }
.flow-kind-dot { width: 10px; height: 10px; border-radius: 50%; }
.flow-kind-label { font-size: 12px; flex: 1; }
.flow-strength-row { display: flex; align-items: center; gap: 8px; margin: 12px 0; }
.flow-strength-label { font-size: 11px; color: var(--rs-text-muted); min-width: 60px; }
.flow-strength-slider { flex: 1; accent-color: #a78bfa; }
.flow-strength-val { font-size: 11px; font-weight: 600; min-width: 30px; text-align: right; }
.flow-dialog-actions { display: flex; gap: 8px; margin-top: 14px; }
.flow-dialog-btn {
padding: 6px 16px; border: none; border-radius: 8px; cursor: pointer;
font-size: 12px; font-weight: 600; flex: 1;
}
.flow-dialog-create { background: #a78bfa; color: #fff; }
.flow-dialog-create:hover { background: #8b5cf6; }
.flow-dialog-cancel { background: var(--rs-bg-surface-raised, rgba(255,255,255,0.08)); color: var(--rs-text-primary); }
.flow-dialog-cancel:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.12)); }
/* Wiring pulse animation for compatible feed targets */
@keyframes feed-pulse {
0%, 100% { box-shadow: 0 0 4px 1px currentColor; }
50% { box-shadow: 0 0 12px 4px currentColor; }
}
@media (max-width: 768px) {
.graph-row { flex-direction: column; }
.graph-canvas { min-height: 300px; }
.member-list-panel { width: 100%; max-height: 200px; border-radius: 0 0 12px 12px; margin-left: 0; margin-top: -1px; }
.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>
<button class="filter-btn" id="list-toggle" title="Toggle member list sidebar">List</button>
<button class="filter-btn" id="layers-toggle" title="Toggle multi-layer rApp visualization">Layers</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-row" id="graph-row">
<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="member-list-panel" id="member-list-panel"></div>
</div>
<div class="detail-panel" id="detail-panel"></div>
<div class="deleg-panel" id="deleg-panel"></div>
<div class="layers-panel" id="layers-panel"></div>
<div class="flow-dialog-overlay" id="flow-dialog-overlay" style="display:none">
<div class="flow-dialog" id="flow-dialog"></div>
</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 — aggressive steps for fast navigation
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.5);
});
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
if (!this.graph) return;
const cam = this.graph.camera();
const dist = cam.position.length();
this.animateCameraDistance(dist * 2);
});
this.shadow.getElementById("zoom-fit")?.addEventListener("click", () => {
if (this.graph) this.graph.zoomToFit(300, 20);
});
// Member list toggle
this.shadow.getElementById("list-toggle")?.addEventListener("click", () => {
this.showMemberList = !this.showMemberList;
const btn = this.shadow.getElementById("list-toggle");
if (btn) btn.classList.toggle("active", this.showMemberList);
this.updateMemberList();
// Resize graph when panel toggles
requestAnimationFrame(() => {
if (this.graph && this.graphContainer) {
const rect = this.graphContainer.getBoundingClientRect();
if (rect.width > 0) this.graph.width(rect.width);
}
});
});
// Layers toggle
this.shadow.getElementById("layers-toggle")?.addEventListener("click", () => {
if (this.layersMode) {
this.exitLayersMode();
} else {
this.enterLayersMode();
}
const btn = this.shadow.getElementById("layers-toggle");
if (btn) btn.classList.toggle("active", this.layersMode);
});
}
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,
200
);
}
// ── 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) => {
const canDelegate = node.type === "rspace_user" || node.type === "person";
// Toggle detail panel for inspection
if (this.selectedNode?.id === node.id) {
this.selectedNode = null;
} else {
this.selectedNode = node;
}
this.updateDetailPanel();
// Add to delegation selection if delegatable
if (canDelegate && !this.selectedDelegates.has(node.id)) {
this.selectedDelegates.set(node.id, {
node,
weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 },
});
this.renderDelegationPanel();
}
this.updateGraphData();
})
.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;
controls.zoomSpeed = 2.5; // faster scroll wheel zoom
}
// 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 = String(Math.round(ew));
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" ? 42 : 36;
canvas.width = 512;
canvas.height = 96;
ctx.font = `${node.type === "company" ? "600" : "500"} ${fontSize}px system-ui, sans-serif`;
ctx.fillStyle = "#e2e8f0";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 6;
ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 256, 48);
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(14, 3.5, 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);
// Refresh member list if visible
if (this.showMemberList) this.updateMemberList();
}
// ── 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.placeSphere(adminNodes, 30);
this.placeSphere(memberNodes, 80);
this.placeSphere(viewerNodes, 160);
this.graph.graphData(data);
this.graph.d3ReheatSimulation();
// Add ring guides
this.removeRingGuides();
this.addRingGuides();
}
private placeSphere(nodes: GraphNode[], radius: number) {
const count = nodes.length;
if (count === 0) return;
// Fibonacci sphere — even distribution on a sphere surface
const goldenAngle = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < count; i++) {
const y = 1 - (2 * i) / (count - 1 || 1); // -1 to 1
const r = Math.sqrt(1 - y * y);
const theta = goldenAngle * i;
nodes[i].fx = Math.cos(theta) * r * radius;
nodes[i].fy = y * radius;
nodes[i].fz = Math.sin(theta) * r * 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 sphereRadii = [
{ r: 30, color: 0xa78bfa, label: "Admin" },
{ r: 80, color: 0x10b981, label: "Member" },
{ r: 160, color: 0x3b82f6, label: "Viewer" },
];
for (const { r, color } of sphereRadii) {
// Wireframe sphere guide
const geometry = new THREE.SphereGeometry(r, 24, 16);
const material = new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity: 0.06,
wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material);
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;
// Find max effective weight across all nodes for bar scaling
let detailMaxEW = 1;
for (const nd of this.nodes) {
if (!nd.weightAccounting) continue;
for (const a of DELEGATION_AUTHORITIES) {
detailMaxEW = Math.max(detailMaxEW, nd.weightAccounting.effectiveWeight[a] || 0);
}
}
weightHtml = DELEGATION_AUTHORITIES.map(a => {
const ew = Math.round(acct.effectiveWeight[a] || 0);
const base = Math.round((n.baseWeights?.[a] || 0) * 100);
const recv = Math.round(acct.receivedWeight[a] || 0);
const away = Math.round(acct.delegatedAway[a] || 0);
const disp = AUTHORITY_DISPLAY[a];
const barPct = Math.min((ew / detailMaxEW) * 100, 100);
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:${barPct}%;background:${disp?.color || '#a78bfa'}"></span></span><span class="trust-val" style="color:${disp?.color || '#a78bfa'}">${ew}</span></div>
<div style="font-size:10px;color:var(--rs-text-muted);margin:-4px 0 6px 76px">base ${base} &minus; ${away} delegated + ${recv} received</div>`;
}).join("");
}
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}
${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("")}
` : ""}
`;
}
// ── Delegation panel (multi-select + fuzzy search) ──
private renderDelegationPanel() {
const panel = this.shadow.getElementById("deleg-panel");
if (!panel) return;
if (this.selectedDelegates.size === 0) {
panel.classList.remove("visible");
panel.innerHTML = "";
return;
}
// Compute remaining weight per authority
const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
this.selectedDelegates.forEach(({ weights }) => {
for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
});
panel.classList.add("visible");
const rows: string[] = [];
this.selectedDelegates.forEach(({ node, weights }, id) => {
rows.push(`
<div class="deleg-row" data-deleg-id="${id}">
<span class="deleg-row-name" title="${this.esc(node.name)}">${this.esc(node.name)}</span>
<div class="deleg-row-sliders">
${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const w = weights[a] || 0;
return `<span class="deleg-auth-label" style="color:${disp?.color}">${disp?.label}</span>
<input type="range" class="deleg-mini-slider" data-did="${id}" data-auth="${a}" min="0" max="100" value="${w}" style="accent-color:${disp?.color}">
<span class="deleg-mini-val" data-did-val="${id}-${a}" style="color:${disp?.color}">${w}%</span>`;
}).join("")}
</div>
<button class="deleg-row-remove" data-remove="${id}" title="Remove">\u2715</button>
</div>
`);
});
panel.innerHTML = `
<div class="deleg-panel-header">
<span class="deleg-panel-title">Delegate Weight (${this.selectedDelegates.size} selected)</span>
<button class="deleg-panel-close" id="deleg-panel-close">\u2715</button>
</div>
<div class="deleg-search-wrap">
<input class="deleg-search" type="text" placeholder="Search members to add..." id="deleg-search" value="${this.esc(this.delegateSearchQuery)}">
<div class="deleg-results" id="deleg-results" style="display:none"></div>
</div>
${rows.join("")}
<div class="deleg-remaining">${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
return `<span style="color:${disp?.color}">${disp?.label}: ${Math.max(0, 100 - spent[a])}% left</span>`;
}).join(" &middot; ")}</div>
<button class="deleg-confirm-all" id="deleg-confirm-all">Confirm All Delegations</button>
`;
this.attachDelegationListeners(panel);
}
private attachDelegationListeners(panel: HTMLElement) {
// Close
this.shadow.getElementById("deleg-panel-close")?.addEventListener("click", () => {
this.selectedDelegates.clear();
this.delegateSearchQuery = "";
this.renderDelegationPanel();
});
// Remove buttons
panel.querySelectorAll("[data-remove]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.remove!;
this.selectedDelegates.delete(id);
this.renderDelegationPanel();
});
});
// Per-delegate per-authority sliders
panel.querySelectorAll("[data-did][data-auth]").forEach(el => {
el.addEventListener("input", (e) => {
const id = (el as HTMLElement).dataset.did!;
const auth = (el as HTMLElement).dataset.auth!;
const val = parseInt((e.target as HTMLInputElement).value);
const entry = this.selectedDelegates.get(id);
if (entry) {
entry.weights[auth] = val;
const valEl = panel.querySelector(`[data-did-val="${id}-${auth}"]`);
if (valEl) valEl.textContent = val + "%";
// Update remaining display
this.updateRemainingDisplay(panel);
}
});
});
// Fuzzy search
const searchInput = this.shadow.getElementById("deleg-search") as HTMLInputElement | null;
const resultsDiv = this.shadow.getElementById("deleg-results");
let searchTimeout: any;
searchInput?.addEventListener("input", () => {
this.delegateSearchQuery = searchInput.value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.updateSearchResults(searchInput.value, resultsDiv);
}, 150);
});
searchInput?.addEventListener("focus", () => {
if (searchInput.value.trim()) {
this.updateSearchResults(searchInput.value, resultsDiv);
}
});
// Confirm all
this.shadow.getElementById("deleg-confirm-all")?.addEventListener("click", () => {
this.confirmAllDelegations();
});
}
private updateRemainingDisplay(panel: HTMLElement) {
const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
this.selectedDelegates.forEach(({ weights }) => {
for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
});
const remainEl = panel.querySelector(".deleg-remaining");
if (remainEl) {
remainEl.innerHTML = DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const left = Math.max(0, 100 - spent[a]);
return `<span style="color:${disp?.color}${left === 0 ? ';opacity:0.5' : ''}">${disp?.label}: ${left}% left</span>`;
}).join(" &middot; ");
}
}
private fuzzyMatch(query: string, name: string): boolean {
const q = query.toLowerCase();
const n = name.toLowerCase();
// Substring match
if (n.includes(q)) return true;
// Fuzzy: all query chars appear in order
let qi = 0;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) qi++;
}
return qi === q.length;
}
private updateSearchResults(query: string, resultsDiv: HTMLElement | null) {
if (!resultsDiv) return;
const q = query.trim();
if (!q) {
resultsDiv.style.display = "none";
return;
}
const matches = this.nodes.filter(n =>
(n.type === "rspace_user" || n.type === "person") &&
!this.selectedDelegates.has(n.id) &&
this.fuzzyMatch(q, n.name)
).slice(0, 8);
if (matches.length === 0) {
resultsDiv.style.display = "none";
return;
}
resultsDiv.style.display = "block";
resultsDiv.innerHTML = matches.map(n =>
`<div class="deleg-result-item" data-add-id="${n.id}">
<span>${this.esc(n.name)}</span>
<span class="deleg-result-role">${n.role || n.type}</span>
</div>`
).join("");
resultsDiv.querySelectorAll("[data-add-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.addId!;
const node = this.nodes.find(n => n.id === id);
if (node) {
this.selectedDelegates.set(id, {
node,
weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 },
});
this.delegateSearchQuery = "";
this.renderDelegationPanel();
}
});
});
}
private confirmAllDelegations() {
if (this.selectedDelegates.size === 0) return;
// 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",
});
}
// Create delegation edges for each selected delegate
this.selectedDelegates.forEach(({ node, weights }) => {
for (const a of DELEGATION_AUTHORITIES) {
const weight = Math.round((weights[a] || 0) / 100 * 100) / 100;
if (weight <= 0) continue;
const edge: GraphEdge = {
source: "me-demo",
target: node.id,
type: "delegates_to",
weight,
authority: a,
};
this.edges.push(edge);
this.demoDelegations.push(edge);
}
});
// Recompute weight accounting + refresh
this.recomputeWeightAccounting();
this.selectedDelegates.clear();
this.delegateSearchQuery = "";
this.renderDelegationPanel();
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;
}
// Absolute token weights: base×100 - delegated + received
const nodeById2 = new Map(this.nodes.map(n => [n.id, n]));
acctMap.forEach((acct, nodeId) => {
const node = nodeById2.get(nodeId);
for (const a of authorities) {
const base = (node?.baseWeights?.[a] || 0) * 100;
const away = acct.delegatedAway[a] * 100;
const recv = acct.receivedWeight[a] * 100;
acct.delegatedAway[a] = away;
acct.receivedWeight[a] = recv;
acct.effectiveWeight[a] = Math.max(0, base - away) + recv;
}
});
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 totalEW = authorities.reduce((s, a) => s + acct.effectiveWeight[a], 0);
node.delegatedWeight = totalEW / (maxEW * 3);
}
}
}
private updateMemberList() {
const panel = this.shadow.getElementById("member-list-panel");
if (!panel) return;
if (!this.showMemberList) {
panel.classList.remove("visible");
return;
}
panel.classList.add("visible");
const groups: { label: string; color: string; role: string; nodes: GraphNode[] }[] = [
{ label: "Admins", color: "#a78bfa", role: "admin", nodes: [] },
{ label: "Members", color: "#10b981", role: "member", nodes: [] },
{ label: "Viewers", color: "#3b82f6", role: "viewer", nodes: [] },
];
const filtered = this.getFilteredNodes().filter(n => n.type === "rspace_user" || n.type === "person");
for (const n of filtered) {
const g = groups.find(g => g.role === n.role) || groups[2];
g.nodes.push(n);
}
// Sort each group by effective weight (descending)
for (const g of groups) {
g.nodes.sort((a, b) => {
const aw = a.weightAccounting ? Object.values(a.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
const bw = b.weightAccounting ? Object.values(b.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
return bw - aw;
});
}
const authHeaders = DELEGATION_AUTHORITIES.map(a => {
const d = AUTHORITY_DISPLAY[a];
return `<span style="color:${d?.color};font-size:9px;font-weight:700">${d?.label?.[0]}</span>`;
}).join(`<span style="opacity:0.3">/</span>`);
panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
<div class="member-group">
<div class="member-group-header" style="color:${g.color}">
${g.label}
<span style="display:flex;align-items:center;gap:2px"><span class="member-group-count">${g.nodes.length}</span><span class="member-weight" style="font-size:9px">${authHeaders}</span></span>
</div>
${g.nodes.map(n => {
const wa = n.weightAccounting;
const weights = wa ? DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
return `<span style="color:${disp?.color}">${Math.round(wa.effectiveWeight[a] || 0)}</span>`;
}).join(`<span style="color:var(--rs-text-muted);opacity:0.4">/</span>`) : "";
return `<div class="member-item" data-member-id="${n.id}">
<span class="member-dot" style="background:${g.color}"></span>
<span class="member-name" title="${this.esc(n.name)}">${this.esc(n.name)}</span>
${weights ? `<span class="member-weight">${weights}</span>` : ""}
</div>`;
}).join("")}
</div>
`).join("");
// Click to select/focus node in graph
panel.querySelectorAll("[data-member-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.memberId!;
const node = this.nodes.find(n => n.id === id);
if (node && this.graph) {
this.selectedNode = node;
this.updateDetailPanel();
this.updateGraphData();
// Fly camera to node
if (node.x != null && node.y != null && node.z != null) {
const dist = 60;
this.graph.cameraPosition(
{ x: node.x + dist, y: node.y + dist * 0.3, z: node.z + dist },
{ x: node.x, y: node.y, z: node.z },
400
);
}
}
});
});
}
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);