2496 lines
91 KiB
TypeScript
2496 lines
91 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" | "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 === "module") return 30;
|
||
if (node.type === "feed") return 15;
|
||
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 === "module") return node.moduleColor || 0x6366f1;
|
||
if (node.type === "feed") {
|
||
const flowColor = LAYER_FLOW_COLORS[node.feedKind || "custom"];
|
||
if (flowColor) return parseInt(flowColor.replace("#", ""), 16);
|
||
return 0x94a3b8;
|
||
}
|
||
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">−</button>
|
||
<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">⤢</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 === "cross_layer_flow" && link.flowKind) {
|
||
return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8";
|
||
}
|
||
if (link.type === "layer_internal") return "rgba(255,255,255,0.15)";
|
||
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 === "cross_layer_flow") return 1.5 + (link.strength || 0.5) * 3;
|
||
if (link.type === "layer_internal") return 0.4;
|
||
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) => {
|
||
if (link.type === "cross_layer_flow") return 0.2;
|
||
return link.type === "delegates_to" ? 0.15 : 0;
|
||
})
|
||
.linkCurveRotation("rotation")
|
||
.linkOpacity(0.6)
|
||
.linkDirectionalArrowLength((link: GraphEdge) => {
|
||
if (link.type === "cross_layer_flow") return 3;
|
||
return link.type === "delegates_to" ? 4 : 0;
|
||
})
|
||
.linkDirectionalArrowRelPos(1)
|
||
.linkDirectionalParticles((link: GraphEdge) => {
|
||
if (link.type === "cross_layer_flow") return Math.ceil((link.strength || 0.5) * 3);
|
||
return link.type === "delegates_to" ? Math.ceil((link.weight || 0.5) * 4) : 0;
|
||
})
|
||
.linkDirectionalParticleSpeed((link: GraphEdge) =>
|
||
link.type === "cross_layer_flow" ? 0.006 : 0.004
|
||
)
|
||
.linkDirectionalParticleWidth((link: GraphEdge) => {
|
||
if (link.type === "cross_layer_flow") return 1.5;
|
||
return link.type === "delegates_to" ? 1 + (link.weight || 0.5) * 2 : 0;
|
||
})
|
||
.linkDirectionalParticleColor((link: GraphEdge) => {
|
||
if (link.type === "cross_layer_flow" && link.flowKind) {
|
||
return LAYER_FLOW_COLORS[link.flowKind] || "#94a3b8";
|
||
}
|
||
if (link.type !== "delegates_to") return null;
|
||
if (this.authority === "all" && link.authority) {
|
||
return AUTHORITY_COLORS[link.authority] || "#c4b5fd";
|
||
}
|
||
return "#c4b5fd";
|
||
})
|
||
.onNodeClick((node: GraphNode) => {
|
||
// Layers mode: handle feed wiring
|
||
if (this.layersMode && node.type === "feed") {
|
||
this.handleLayerNodeClick(node);
|
||
return;
|
||
}
|
||
|
||
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">🕸️</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 segments = (node.type === "module" || node.type === "feed") ? 24 : 16;
|
||
const geometry = new THREE.SphereGeometry(radius, segments, segments * 3 / 4);
|
||
const opacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75;
|
||
const material = new THREE.MeshLambertMaterial({
|
||
color,
|
||
transparent: true,
|
||
opacity,
|
||
});
|
||
const sphere = new THREE.Mesh(geometry, material);
|
||
group.add(sphere);
|
||
|
||
// Glow ring for module hub nodes
|
||
if (node.type === "module") {
|
||
const glowGeo = new THREE.RingGeometry(radius + 0.2, radius + 0.6, 32);
|
||
const glowMat = new THREE.MeshBasicMaterial({
|
||
color, transparent: true, opacity: 0.3, side: THREE.DoubleSide,
|
||
});
|
||
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||
group.add(glow);
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// Wiring highlight for compatible feed targets
|
||
if (this.layersMode && this.flowWiringSource && node.type === "feed" && node.layerId) {
|
||
const nodeLayerIdx = parseInt(node.layerId);
|
||
if (nodeLayerIdx !== this.flowWiringSource.layerIdx) {
|
||
const srcLayer = this.layerInstances[this.flowWiringSource.layerIdx];
|
||
const tgtLayer = this.layerInstances[nodeLayerIdx];
|
||
if (srcLayer && tgtLayer) {
|
||
const compat = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
|
||
if (compat.size > 0) {
|
||
// Pulse ring for compatible target
|
||
const pulseGeo = new THREE.RingGeometry(radius + 0.4, radius + 0.8, 32);
|
||
const pulseMat = new THREE.MeshBasicMaterial({
|
||
color: 0x4ade80, transparent: true, opacity: 0.5, side: THREE.DoubleSide,
|
||
});
|
||
const pulseRing = new THREE.Mesh(pulseGeo, pulseMat);
|
||
group.add(pulseRing);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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" && node.type !== "module" && node.type !== "feed") {
|
||
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 === "module" ? 44 : node.type === "company" ? 42 : node.type === "feed" ? 30 : 36;
|
||
canvas.width = 512;
|
||
canvas.height = 96;
|
||
|
||
ctx.font = `${(node.type === "company" || node.type === "module") ? "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} − ${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(" · ")}</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(" · ");
|
||
}
|
||
}
|
||
|
||
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 · ${ws.edgeCount || 0} edges</div>
|
||
</div>
|
||
`).join("")}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════
|
||
// ═══ LAYERS MODE ═══════════════════════════════════════════
|
||
// ══════════════════════════════════════════════════════════════
|
||
|
||
private getAvailableModules(): Array<{ id: string; name: string; icon: string; feeds: any[]; acceptsFeeds: string[] }> {
|
||
const modList = (window as any).__rspaceModuleList as any[] || [];
|
||
return modList
|
||
.filter((m: any) => !m.hidden && (m.feeds?.length > 0 || m.acceptsFeeds?.length > 0))
|
||
.map((m: any) => ({
|
||
id: m.id,
|
||
name: m.name || m.id,
|
||
icon: m.icon || "\u{1F4E6}",
|
||
feeds: m.feeds || [],
|
||
acceptsFeeds: m.acceptsFeeds || [],
|
||
}));
|
||
}
|
||
|
||
private getModuleOutputKinds(moduleId: string): Set<string> {
|
||
const mods = (window as any).__rspaceModuleList as any[] || [];
|
||
const mod = mods.find((m: any) => m.id === moduleId);
|
||
if (!mod?.feeds) return new Set();
|
||
return new Set(mod.feeds.map((f: any) => f.kind));
|
||
}
|
||
|
||
private getModuleInputKinds(moduleId: string): Set<string> {
|
||
const mods = (window as any).__rspaceModuleList as any[] || [];
|
||
const mod = mods.find((m: any) => m.id === moduleId);
|
||
if (!mod?.acceptsFeeds) return new Set();
|
||
return new Set(mod.acceptsFeeds);
|
||
}
|
||
|
||
private getCompatibleFlowKinds(srcModuleId: string, tgtModuleId: string): Set<string> {
|
||
const srcOutputs = this.getModuleOutputKinds(srcModuleId);
|
||
const tgtInputs = this.getModuleInputKinds(tgtModuleId);
|
||
if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set();
|
||
const compatible = new Set<string>();
|
||
Array.from(srcOutputs).forEach(kind => {
|
||
if (tgtInputs.has(kind)) compatible.add(kind);
|
||
});
|
||
return compatible;
|
||
}
|
||
|
||
private enterLayersMode() {
|
||
if (this.layersMode) return;
|
||
this.layersMode = true;
|
||
this.layersPanelOpen = true;
|
||
|
||
// Save current graph state
|
||
this.savedGraphState = {
|
||
nodes: [...this.nodes],
|
||
edges: [...this.edges],
|
||
};
|
||
|
||
// Save camera constraints
|
||
const controls = this.graph?.controls();
|
||
if (controls) {
|
||
this.savedCameraControls = {
|
||
minPolar: controls.minPolarAngle,
|
||
maxPolar: controls.maxPolarAngle,
|
||
minDist: controls.minDistance,
|
||
maxDist: controls.maxDistance,
|
||
};
|
||
// Free camera
|
||
controls.minPolarAngle = 0;
|
||
controls.maxPolarAngle = Math.PI;
|
||
controls.minDistance = 20;
|
||
controls.maxDistance = 2000;
|
||
}
|
||
|
||
// Clear existing ring guides
|
||
this.removeRingGuides();
|
||
|
||
// Show layers panel
|
||
this.renderLayersPanel();
|
||
this.rebuildLayerGraph();
|
||
}
|
||
|
||
private exitLayersMode() {
|
||
if (!this.layersMode) return;
|
||
this.layersMode = false;
|
||
this.layersPanelOpen = false;
|
||
this.flowWiringSource = null;
|
||
|
||
// Remove layer plane objects
|
||
this.removeLayerPlanes();
|
||
|
||
// Restore camera constraints
|
||
const controls = this.graph?.controls();
|
||
if (controls && this.savedCameraControls) {
|
||
controls.minPolarAngle = this.savedCameraControls.minPolar;
|
||
controls.maxPolarAngle = this.savedCameraControls.maxPolar;
|
||
controls.minDistance = this.savedCameraControls.minDist;
|
||
controls.maxDistance = this.savedCameraControls.maxDist;
|
||
}
|
||
this.savedCameraControls = null;
|
||
|
||
// Restore original graph data
|
||
if (this.savedGraphState) {
|
||
this.nodes = this.savedGraphState.nodes;
|
||
this.edges = this.savedGraphState.edges;
|
||
this.savedGraphState = null;
|
||
}
|
||
|
||
// Clear layer state
|
||
this.layerInstances = [];
|
||
this.crossLayerFlows = [];
|
||
|
||
// Hide panels
|
||
const panel = this.shadow.getElementById("layers-panel");
|
||
if (panel) panel.classList.remove("visible");
|
||
const flowOverlay = this.shadow.getElementById("flow-dialog-overlay");
|
||
if (flowOverlay) flowOverlay.style.display = "none";
|
||
|
||
this.updateGraphData();
|
||
}
|
||
|
||
private renderLayersPanel() {
|
||
const panel = this.shadow.getElementById("layers-panel");
|
||
if (!panel) return;
|
||
|
||
if (!this.layersPanelOpen) {
|
||
panel.classList.remove("visible");
|
||
return;
|
||
}
|
||
panel.classList.add("visible");
|
||
|
||
const available = this.getAvailableModules();
|
||
const selectedIds = new Set(this.layerInstances.map(l => l.moduleId));
|
||
|
||
const moduleButtons = available.map(m => {
|
||
const isSelected = selectedIds.has(m.id);
|
||
const atMax = this.layerInstances.length >= 3 && !isSelected;
|
||
return `<button class="module-pick-btn ${isSelected ? 'selected' : ''} ${atMax ? 'disabled' : ''}" data-mod-id="${m.id}" ${atMax ? 'disabled' : ''}>${m.icon} ${this.esc(m.name)}</button>`;
|
||
}).join("");
|
||
|
||
const layerRows = this.layerInstances.map((layer, idx) => {
|
||
const feedList = layer.feeds.map(f =>
|
||
`<span style="color:${LAYER_FLOW_COLORS[f.kind] || '#94a3b8'}">${f.name}</span>`
|
||
).join(", ");
|
||
return `
|
||
<div class="layer-row">
|
||
<span class="layer-row-icon">${layer.moduleIcon}</span>
|
||
<span class="layer-row-name">${this.esc(layer.moduleName)}</span>
|
||
<select class="axis-select" data-layer-idx="${idx}">
|
||
<option value="xy" ${layer.axis === "xy" ? "selected" : ""}>XY (front)</option>
|
||
<option value="xz" ${layer.axis === "xz" ? "selected" : ""}>XZ (floor)</option>
|
||
<option value="yz" ${layer.axis === "yz" ? "selected" : ""}>YZ (side)</option>
|
||
</select>
|
||
<button class="layer-row-remove" data-remove-idx="${idx}" title="Remove layer">\u2715</button>
|
||
</div>
|
||
${feedList ? `<div class="layer-feeds-hint">Feeds: ${feedList}</div>` : ""}
|
||
`;
|
||
}).join("");
|
||
|
||
const flowRows = this.crossLayerFlows.map((flow, idx) => {
|
||
const srcLayer = this.layerInstances[flow.sourceLayerIdx];
|
||
const tgtLayer = this.layerInstances[flow.targetLayerIdx];
|
||
if (!srcLayer || !tgtLayer) return "";
|
||
return `<div class="layer-row">
|
||
<span style="color:${LAYER_FLOW_COLORS[flow.kind] || '#94a3b8'}">\u25CF</span>
|
||
<span class="layer-row-name" style="font-size:11px">${srcLayer.moduleIcon} \u2192 ${tgtLayer.moduleIcon} (${LAYER_FLOW_LABELS[flow.kind] || flow.kind})</span>
|
||
<span style="font-size:10px;color:var(--rs-text-muted)">${Math.round(flow.strength * 100)}%</span>
|
||
<button class="layer-row-remove" data-remove-flow="${idx}" title="Remove flow">\u2715</button>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
panel.innerHTML = `
|
||
<div class="layers-panel-header">
|
||
<span class="layers-panel-title">Layer Visualization</span>
|
||
<button class="layers-panel-close" id="layers-panel-close">\u2715</button>
|
||
</div>
|
||
<div class="module-picker">${moduleButtons}</div>
|
||
${layerRows}
|
||
${this.crossLayerFlows.length > 0 ? `<div style="font-size:11px;font-weight:600;color:var(--rs-text-muted);margin:8px 0 4px;text-transform:uppercase;letter-spacing:0.05em">Flows</div>${flowRows}` : ""}
|
||
${this.layerInstances.length >= 2 ? '<div class="layer-feeds-hint" style="margin-top:8px">Click a feed node, then click a compatible target to create a flow.</div>' : ""}
|
||
`;
|
||
|
||
this.attachLayersPanelListeners(panel);
|
||
}
|
||
|
||
private attachLayersPanelListeners(panel: HTMLElement) {
|
||
// Close
|
||
this.shadow.getElementById("layers-panel-close")?.addEventListener("click", () => {
|
||
this.layersPanelOpen = false;
|
||
this.renderLayersPanel();
|
||
});
|
||
|
||
// Module pick buttons
|
||
panel.querySelectorAll("[data-mod-id]").forEach(el => {
|
||
el.addEventListener("click", () => {
|
||
const modId = (el as HTMLElement).dataset.modId!;
|
||
const existIdx = this.layerInstances.findIndex(l => l.moduleId === modId);
|
||
if (existIdx >= 0) {
|
||
// Deselect
|
||
this.layerInstances.splice(existIdx, 1);
|
||
// Remove any flows referencing this layer
|
||
this.crossLayerFlows = this.crossLayerFlows.filter(f =>
|
||
f.sourceLayerIdx !== existIdx && f.targetLayerIdx !== existIdx
|
||
).map(f => ({
|
||
...f,
|
||
sourceLayerIdx: f.sourceLayerIdx > existIdx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx,
|
||
targetLayerIdx: f.targetLayerIdx > existIdx ? f.targetLayerIdx - 1 : f.targetLayerIdx,
|
||
}));
|
||
} else if (this.layerInstances.length < 3) {
|
||
const mods = this.getAvailableModules();
|
||
const mod = mods.find(m => m.id === modId);
|
||
if (mod) {
|
||
const axes: AxisPlane[] = ["xy", "xz", "yz"];
|
||
const usedAxes = new Set(this.layerInstances.map(l => l.axis));
|
||
const axis = axes.find(a => !usedAxes.has(a)) || "xy";
|
||
this.layerInstances.push({
|
||
moduleId: mod.id,
|
||
moduleName: mod.name,
|
||
moduleIcon: mod.icon,
|
||
moduleColor: MODULE_PALETTE[this.layerInstances.length % MODULE_PALETTE.length],
|
||
axis,
|
||
feeds: mod.feeds.map((f: any) => ({ id: f.id, name: f.name, kind: f.kind })),
|
||
acceptsFeeds: mod.acceptsFeeds,
|
||
});
|
||
}
|
||
}
|
||
this.renderLayersPanel();
|
||
this.rebuildLayerGraph();
|
||
});
|
||
});
|
||
|
||
// Axis select
|
||
panel.querySelectorAll("[data-layer-idx]").forEach(el => {
|
||
el.addEventListener("change", () => {
|
||
const idx = parseInt((el as HTMLElement).dataset.layerIdx!);
|
||
const val = (el as HTMLSelectElement).value as AxisPlane;
|
||
if (this.layerInstances[idx]) {
|
||
this.layerInstances[idx].axis = val;
|
||
this.rebuildLayerGraph();
|
||
}
|
||
});
|
||
});
|
||
|
||
// Remove layer
|
||
panel.querySelectorAll("[data-remove-idx]").forEach(el => {
|
||
el.addEventListener("click", () => {
|
||
const idx = parseInt((el as HTMLElement).dataset.removeIdx!);
|
||
this.layerInstances.splice(idx, 1);
|
||
this.crossLayerFlows = this.crossLayerFlows.filter(f =>
|
||
f.sourceLayerIdx !== idx && f.targetLayerIdx !== idx
|
||
).map(f => ({
|
||
...f,
|
||
sourceLayerIdx: f.sourceLayerIdx > idx ? f.sourceLayerIdx - 1 : f.sourceLayerIdx,
|
||
targetLayerIdx: f.targetLayerIdx > idx ? f.targetLayerIdx - 1 : f.targetLayerIdx,
|
||
}));
|
||
this.renderLayersPanel();
|
||
this.rebuildLayerGraph();
|
||
});
|
||
});
|
||
|
||
// Remove flow
|
||
panel.querySelectorAll("[data-remove-flow]").forEach(el => {
|
||
el.addEventListener("click", () => {
|
||
const idx = parseInt((el as HTMLElement).dataset.removeFlow!);
|
||
this.crossLayerFlows.splice(idx, 1);
|
||
this.renderLayersPanel();
|
||
this.rebuildLayerGraph();
|
||
});
|
||
});
|
||
}
|
||
|
||
private getPlaneOffset(axis: AxisPlane, layerIdx: number): { x: number; y: number; z: number } {
|
||
const spacing = 100;
|
||
const offset = (layerIdx - (this.layerInstances.length - 1) / 2) * spacing;
|
||
switch (axis) {
|
||
case "xy": return { x: 0, y: 0, z: offset };
|
||
case "xz": return { x: 0, y: offset, z: 0 };
|
||
case "yz": return { x: offset, y: 0, z: 0 };
|
||
}
|
||
}
|
||
|
||
private rebuildLayerGraph() {
|
||
if (!this.graph) return;
|
||
|
||
// Remove old planes
|
||
this.removeLayerPlanes();
|
||
|
||
if (this.layerInstances.length === 0) {
|
||
this.graph.graphData({ nodes: [], links: [] });
|
||
return;
|
||
}
|
||
|
||
const nodes: GraphNode[] = [];
|
||
const edges: GraphEdge[] = [];
|
||
|
||
// Build nodes/edges for each layer
|
||
for (let i = 0; i < this.layerInstances.length; i++) {
|
||
const layer = this.layerInstances[i];
|
||
const offset = this.getPlaneOffset(layer.axis, i);
|
||
|
||
// Hub node (module center)
|
||
const hubId = `layer-${i}-hub`;
|
||
nodes.push({
|
||
id: hubId,
|
||
name: layer.moduleName,
|
||
type: "module",
|
||
workspace: "",
|
||
layerId: String(i),
|
||
moduleColor: layer.moduleColor,
|
||
fx: offset.x,
|
||
fy: offset.y,
|
||
fz: offset.z,
|
||
});
|
||
|
||
// Feed nodes arranged in circle around hub
|
||
const feedCount = layer.feeds.length;
|
||
const feedRadius = 25;
|
||
for (let j = 0; j < feedCount; j++) {
|
||
const feed = layer.feeds[j];
|
||
const angle = (2 * Math.PI * j) / feedCount;
|
||
|
||
// Calculate feed position on the plane
|
||
let fx: number, fy: number, fz: number;
|
||
switch (layer.axis) {
|
||
case "xy":
|
||
fx = offset.x + Math.cos(angle) * feedRadius;
|
||
fy = offset.y + Math.sin(angle) * feedRadius;
|
||
fz = offset.z;
|
||
break;
|
||
case "xz":
|
||
fx = offset.x + Math.cos(angle) * feedRadius;
|
||
fy = offset.y;
|
||
fz = offset.z + Math.sin(angle) * feedRadius;
|
||
break;
|
||
case "yz":
|
||
fx = offset.x;
|
||
fy = offset.y + Math.cos(angle) * feedRadius;
|
||
fz = offset.z + Math.sin(angle) * feedRadius;
|
||
break;
|
||
}
|
||
|
||
const feedNodeId = `layer-${i}-feed-${feed.id}`;
|
||
nodes.push({
|
||
id: feedNodeId,
|
||
name: feed.name,
|
||
type: "feed",
|
||
workspace: "",
|
||
layerId: String(i),
|
||
feedId: feed.id,
|
||
feedKind: feed.kind,
|
||
moduleColor: layer.moduleColor,
|
||
fx, fy, fz,
|
||
});
|
||
|
||
// Internal edge: hub → feed
|
||
edges.push({
|
||
source: hubId,
|
||
target: feedNodeId,
|
||
type: "layer_internal",
|
||
});
|
||
}
|
||
|
||
// Add plane visual
|
||
this.addLayerPlane(layer, i, offset);
|
||
}
|
||
|
||
// Cross-layer flow edges
|
||
for (const flow of this.crossLayerFlows) {
|
||
const srcFeedId = `layer-${flow.sourceLayerIdx}-feed-${flow.sourceFeedId}`;
|
||
const tgtFeedId = `layer-${flow.targetLayerIdx}-feed-${flow.targetFeedId}`;
|
||
edges.push({
|
||
source: srcFeedId,
|
||
target: tgtFeedId,
|
||
type: "cross_layer_flow",
|
||
flowKind: flow.kind,
|
||
strength: flow.strength,
|
||
});
|
||
}
|
||
|
||
this.graph.graphData({ nodes, links: edges });
|
||
|
||
// Fly camera to overview
|
||
setTimeout(() => {
|
||
if (this.graph) this.graph.zoomToFit(500, 40);
|
||
}, 300);
|
||
}
|
||
|
||
private addLayerPlane(layer: LayerInstance, idx: number, offset: { x: number; y: number; z: number }) {
|
||
const THREE = this._threeModule;
|
||
if (!THREE || !this.graph) return;
|
||
const scene = this.graph.scene();
|
||
if (!scene) return;
|
||
|
||
const planeSize = 80;
|
||
const geometry = new THREE.PlaneGeometry(planeSize, planeSize);
|
||
const colorHex = layer.moduleColor;
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: colorHex,
|
||
transparent: true,
|
||
opacity: 0.06,
|
||
side: THREE.DoubleSide,
|
||
});
|
||
const plane = new THREE.Mesh(geometry, material);
|
||
plane.position.set(offset.x, offset.y, offset.z);
|
||
|
||
// Rotate based on axis
|
||
switch (layer.axis) {
|
||
case "xy": break; // default orientation faces Z
|
||
case "xz": plane.rotation.x = Math.PI / 2; break; // floor
|
||
case "yz": plane.rotation.y = Math.PI / 2; break; // side wall
|
||
}
|
||
|
||
// Disable raycasting so clicks pass through
|
||
plane.raycast = () => {};
|
||
|
||
scene.add(plane);
|
||
this.layerPlaneObjects.push(plane);
|
||
|
||
// Grid overlay
|
||
const gridHelper = new THREE.GridHelper(planeSize, 8, colorHex, colorHex);
|
||
gridHelper.material.transparent = true;
|
||
gridHelper.material.opacity = 0.08;
|
||
gridHelper.position.set(offset.x, offset.y, offset.z);
|
||
|
||
switch (layer.axis) {
|
||
case "xy": gridHelper.rotation.x = Math.PI / 2; break;
|
||
case "xz": break; // GridHelper is already on XZ
|
||
case "yz": gridHelper.rotation.z = Math.PI / 2; break;
|
||
}
|
||
gridHelper.raycast = () => {};
|
||
scene.add(gridHelper);
|
||
this.layerPlaneObjects.push(gridHelper);
|
||
|
||
// Module name label sprite above plane
|
||
const labelCanvas = document.createElement("canvas");
|
||
const ctx = labelCanvas.getContext("2d");
|
||
if (ctx) {
|
||
labelCanvas.width = 512;
|
||
labelCanvas.height = 96;
|
||
ctx.font = "bold 36px 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(`${layer.moduleIcon} ${layer.moduleName}`, 256, 48);
|
||
|
||
const texture = new THREE.CanvasTexture(labelCanvas);
|
||
texture.needsUpdate = true;
|
||
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
|
||
const sprite = new THREE.Sprite(spriteMat);
|
||
sprite.scale.set(20, 5, 1);
|
||
|
||
// Position label above plane
|
||
switch (layer.axis) {
|
||
case "xy": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break;
|
||
case "xz": sprite.position.set(offset.x, offset.y + 5, offset.z - planeSize / 2 - 5); break;
|
||
case "yz": sprite.position.set(offset.x, offset.y + planeSize / 2 + 5, offset.z); break;
|
||
}
|
||
sprite.raycast = () => {};
|
||
scene.add(sprite);
|
||
this.layerPlaneObjects.push(sprite);
|
||
}
|
||
}
|
||
|
||
private removeLayerPlanes() {
|
||
const scene = this.graph?.scene();
|
||
if (!scene) return;
|
||
for (const obj of this.layerPlaneObjects) {
|
||
scene.remove(obj);
|
||
if (obj.geometry) obj.geometry.dispose();
|
||
if (obj.material) {
|
||
if (obj.material.map) obj.material.map.dispose();
|
||
obj.material.dispose();
|
||
}
|
||
}
|
||
this.layerPlaneObjects = [];
|
||
}
|
||
|
||
private handleLayerNodeClick(node: GraphNode) {
|
||
if (!node.layerId || !node.feedId || !node.feedKind) return;
|
||
const layerIdx = parseInt(node.layerId);
|
||
const layer = this.layerInstances[layerIdx];
|
||
if (!layer) return;
|
||
|
||
if (!this.flowWiringSource) {
|
||
// Start wiring — set source
|
||
this.flowWiringSource = {
|
||
layerIdx,
|
||
feedId: node.feedId,
|
||
feedKind: node.feedKind,
|
||
};
|
||
// Highlight compatible targets by refreshing graph
|
||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||
this.graph?.refresh();
|
||
} else {
|
||
// Check compatibility
|
||
const src = this.flowWiringSource;
|
||
if (src.layerIdx === layerIdx) {
|
||
// Same layer — cancel wiring
|
||
this.flowWiringSource = null;
|
||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||
this.graph?.refresh();
|
||
return;
|
||
}
|
||
|
||
const srcLayer = this.layerInstances[src.layerIdx];
|
||
const tgtLayer = this.layerInstances[layerIdx];
|
||
if (!srcLayer || !tgtLayer) return;
|
||
|
||
const compatible = this.getCompatibleFlowKinds(srcLayer.moduleId, tgtLayer.moduleId);
|
||
if (compatible.size === 0) {
|
||
// No compatible flows — cancel
|
||
this.flowWiringSource = null;
|
||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||
this.graph?.refresh();
|
||
return;
|
||
}
|
||
|
||
// Show flow creation dialog
|
||
this.showFlowDialog(src, { layerIdx, feedId: node.feedId, feedKind: node.feedKind }, compatible);
|
||
}
|
||
}
|
||
|
||
private showFlowDialog(
|
||
src: { layerIdx: number; feedId: string; feedKind: string },
|
||
tgt: { layerIdx: number; feedId: string; feedKind: string },
|
||
compatibleKinds: Set<string>,
|
||
) {
|
||
const overlay = this.shadow.getElementById("flow-dialog-overlay");
|
||
const dialog = this.shadow.getElementById("flow-dialog");
|
||
if (!overlay || !dialog) return;
|
||
|
||
const srcLayer = this.layerInstances[src.layerIdx];
|
||
const tgtLayer = this.layerInstances[tgt.layerIdx];
|
||
const kindsArr = Array.from(compatibleKinds);
|
||
|
||
dialog.innerHTML = `
|
||
<div class="flow-dialog-title">Create Flow</div>
|
||
<div class="flow-dialog-pair">${srcLayer.moduleIcon} ${this.esc(srcLayer.moduleName)} \u2192 ${tgtLayer.moduleIcon} ${this.esc(tgtLayer.moduleName)}</div>
|
||
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:8px">Select flow type:</div>
|
||
${kindsArr.map((kind, i) => `
|
||
<div class="flow-kind-option ${i === 0 ? 'selected' : ''}" data-flow-kind="${kind}">
|
||
<span class="flow-kind-dot" style="background:${LAYER_FLOW_COLORS[kind] || '#94a3b8'}"></span>
|
||
<span class="flow-kind-label">${LAYER_FLOW_LABELS[kind] || kind}</span>
|
||
</div>
|
||
`).join("")}
|
||
<div class="flow-strength-row">
|
||
<span class="flow-strength-label">Strength</span>
|
||
<input type="range" class="flow-strength-slider" id="flow-strength" min="10" max="100" value="50">
|
||
<span class="flow-strength-val" id="flow-strength-val">50%</span>
|
||
</div>
|
||
<div class="flow-dialog-actions">
|
||
<button class="flow-dialog-btn flow-dialog-cancel" id="flow-cancel">Cancel</button>
|
||
<button class="flow-dialog-btn flow-dialog-create" id="flow-create">Create</button>
|
||
</div>
|
||
`;
|
||
overlay.style.display = "flex";
|
||
|
||
let selectedKind = kindsArr[0];
|
||
|
||
// Kind selection
|
||
dialog.querySelectorAll("[data-flow-kind]").forEach(el => {
|
||
el.addEventListener("click", () => {
|
||
dialog.querySelectorAll("[data-flow-kind]").forEach(e => e.classList.remove("selected"));
|
||
el.classList.add("selected");
|
||
selectedKind = (el as HTMLElement).dataset.flowKind!;
|
||
});
|
||
});
|
||
|
||
// Strength slider
|
||
const slider = this.shadow.getElementById("flow-strength") as HTMLInputElement;
|
||
const valEl = this.shadow.getElementById("flow-strength-val");
|
||
slider?.addEventListener("input", () => {
|
||
if (valEl) valEl.textContent = slider.value + "%";
|
||
});
|
||
|
||
// Cancel
|
||
this.shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
|
||
overlay.style.display = "none";
|
||
this.flowWiringSource = null;
|
||
this.graph?.nodeThreeObject((n: GraphNode) => this.createNodeObject(n));
|
||
this.graph?.refresh();
|
||
});
|
||
|
||
// Create
|
||
this.shadow.getElementById("flow-create")?.addEventListener("click", () => {
|
||
const strength = parseInt(slider?.value || "50") / 100;
|
||
this.crossLayerFlows.push({
|
||
id: `flow-${Date.now()}`,
|
||
sourceLayerIdx: src.layerIdx,
|
||
sourceFeedId: src.feedId,
|
||
targetLayerIdx: tgt.layerIdx,
|
||
targetFeedId: tgt.feedId,
|
||
kind: selectedKind,
|
||
strength,
|
||
});
|
||
overlay.style.display = "none";
|
||
this.flowWiringSource = null;
|
||
this.renderLayersPanel();
|
||
this.rebuildLayerGraph();
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
customElements.define("folk-graph-viewer", FolkGraphViewer);
|