/**
 * <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.
 */

import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { NetworkLocalFirstClient } from '../local-first-client';
import type { DocumentId } from "../../../shared/local-first/document";
import { networkSchema, networkDocId } from "../schemas";

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;
	private powerMode = false;
	private powerData: { results: any[]; giniCoefficient: number; herfindahlIndex: number; totalWeight: number; playerCount: number } | null = null;
	private _powerFetchController: AbortController | null = null;

	// 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();

	// Sprite caches — avoid recreating Canvas+Texture per node per frame
	private _textSpriteCache = new Map<string, any>();
	private _badgeSpriteCache = new Map<string, any>();
	private _stopPresence: (() => void) | null = null;
	private _subscribedDocIds: string[] = [];

	// Local-first client for layer config persistence
	private _lfClient: NetworkLocalFirstClient | null = null;
	private _lfUnsubChange: (() => void) | null = null;
	private _pulseRings: Array<{ mesh: any; baseOpacity: number }> = [];
	private _tickHandler: (() => void) | null = null;

	// Hover glow state
	private _hoverGlowNode: GraphNode | null = null;
	private _hoverGlowMesh: any = null;
	private _hoverDebounce: any = null;

	// Path tracing state (Phase 3)
	private _tracedPathNodes: Set<string> | null = null;

	// Keyboard handler ref for cleanup
	private _keyHandler: ((e: KeyboardEvent) => void) | null = null;

	constructor() {
		super();
		this.shadow = this.attachShadow({ mode: "open" });
	}

	private _delegationsHandler: ((e: Event) => void) | null = null;

	private _onTabChange = (e: Event) => {
		const tab = (e as CustomEvent).detail?.tab;
		if (!tab) return;
		this.applyTab(tab);
	};

	private applyTab(tab: string) {
		const wasTrust = this.trustMode;
		const wasPower = this.powerMode;

		switch (tab) {
			case "members":
				this.filter = "all";
				this.trustMode = false;
				this.powerMode = false;
				break;
			case "trust":
				this.filter = "all";
				this.trustMode = true;
				this.powerMode = false;
				if (this.layoutMode !== "rings") {
					this.layoutMode = "rings";
					const ringsBtn = this.shadow.getElementById("rings-toggle");
					if (ringsBtn) ringsBtn.classList.add("active");
				}
				break;
			case "power":
				this.filter = "all";
				this.trustMode = true; // power mode builds on trust data
				this.powerMode = true;
				if (this.layoutMode !== "rings") {
					this.layoutMode = "rings";
					const ringsBtn = this.shadow.getElementById("rings-toggle");
					if (ringsBtn) ringsBtn.classList.add("active");
				}
				this.fetchPowerData();
				break;
		}
		this.updateAuthorityBar();
		if (this.powerMode !== wasPower) this.updatePowerPanel();
		if (this.trustMode !== wasTrust) {
			this.loadData();
		} else {
			this.updateGraphData();
		}
	}

	connectedCallback() {
		this.space = this.getAttribute("space") || "demo";

		// Read initial tab from attribute or URL
		const attrTab = this.getAttribute("active-tab");
		if (attrTab) this.applyTab(attrTab);

		this.renderDOM();
		this.loadData();
		this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rnetwork', context: 'Network Graph' }));

		// Listen for shell tab bar changes
		document.addEventListener("rapp-tab-change", this._onTabChange);

		// Listen for cross-component delegation updates
		this._delegationsHandler = () => {
			this._textSpriteCache.clear();
			this._badgeSpriteCache.clear();
			this.loadData();
		};
		document.addEventListener("delegations-updated", this._delegationsHandler);

		// Init local-first client for layer config persistence
		this._lfClient = new NetworkLocalFirstClient(this.space);
		this._lfClient.init().then(() => this._lfClient!.subscribe()).then(() => {
			this.restoreLayerConfig();
		}).catch(() => { /* offline is fine */ });
		this.subscribeCollabOverlay();
	}

	disconnectedCallback() {
		this._stopPresence?.();
		document.removeEventListener("rapp-tab-change", this._onTabChange);
		if (this._keyHandler) {
			document.removeEventListener("keydown", this._keyHandler);
			this._keyHandler = null;
		}
		if (this._delegationsHandler) {
			document.removeEventListener("delegations-updated", this._delegationsHandler);
			this._delegationsHandler = null;
		}
		if (this.resizeObserver) {
			this.resizeObserver.disconnect();
			this.resizeObserver = null;
		}
		if (this._lfUnsubChange) { this._lfUnsubChange(); this._lfUnsubChange = null; }
		this._lfClient?.disconnect();
		this._lfClient = null;
		if (this.graph) {
			this.graph._destructor?.();
			this.graph = null;
		}
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (runtime) {
			for (const id of this._subscribedDocIds) runtime.unsubscribe(id);
		}
		this._subscribedDocIds = [];
	}

	private async subscribeCollabOverlay() {
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime?.isInitialized) return;
		try {
			const docId = networkDocId(this.space) as DocumentId;
			await runtime.subscribe(docId, networkSchema);
			this._subscribedDocIds.push(docId);
		} catch { /* runtime unavailable */ }
	}

	private getApiBase(): string {
		const path = window.location.pathname;
		const match = path.match(/^(\/[^/]+)?\/rnetwork/);
		return match ? match[0] : "";
	}

	private async loadData() {
		const base = this.getApiBase();
		// Show loading spinner
		const loader = this.shadow.getElementById("graph-loading");
		if (loader) loader.classList.remove("hidden");
		try {
			const trustParam = this.trustMode ? `?trust=true&authority=${encodeURIComponent(this.authority)}` : "";
			const graphCacheKey = `rnetwork:graph:${this.space}:${this.trustMode}:${this.authority}`;

			// Check sessionStorage cache (60s TTL, matches server cache)
			let graphData: any = null;
			try {
				const cached = sessionStorage.getItem(graphCacheKey);
				if (cached) {
					const { data, ts } = JSON.parse(cached);
					if (Date.now() - ts < 60_000) graphData = data;
				}
			} catch { /* storage unavailable */ }

			const token = localStorage.getItem("encryptid-token");
			const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
			const [wsRes, infoRes, graphRes] = await Promise.all([
				fetch(`${base}/api/workspaces`, { headers }),
				fetch(`${base}/api/info`, { headers }),
				graphData ? Promise.resolve(null) : fetch(`${base}/api/graph${trustParam}`, { headers }),
			]);
			if (wsRes.ok) this.workspaces = await wsRes.json();
			if (infoRes.ok) this.info = await infoRes.json();

			if (graphData) {
				this.importGraph(graphData);
			} else if (graphRes && graphRes.ok) {
				graphData = await graphRes.json();
				this.importGraph(graphData);
				try { sessionStorage.setItem(graphCacheKey, JSON.stringify({ data: graphData, ts: Date.now() })); } catch { /* quota */ }
			}
		} catch { /* offline */ }
		// Hide loading spinner
		if (loader) loader.classList.add("hidden");
		this.updateStatsBar();
		this.updateAuthorityBar();
		this.updateWorkspaceList();
		this.updateGraphData();
	}

	private async reloadWithAuthority(authority: AuthoritySelection) {
		this.authority = authority;
		this.trustMode = true;
		this._textSpriteCache.clear();
		this._badgeSpriteCache.clear();
		await this.loadData();
		if (this.powerMode) this.fetchPowerData();
	}

	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;
		// Power mode: size by Banzhaf index
		if (this.powerMode && this.powerData?.results) {
			const pr = this.powerData.results.find((r: any) => r.did === node.id);
			if (pr) {
				const maxB = Math.max(...this.powerData.results.map((r: any) => r.banzhaf), 0.01);
				return 6 + (pr.banzhaf / maxB) * 55;
			}
		}
		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; }

			/* Loading spinner overlay */
			.graph-loading {
				position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
				background: rgba(0,0,0,0.4); z-index: 10; border-radius: 12px;
			}
			.graph-loading.hidden { display: none; }
			.spinner {
				width: 36px; height: 36px; border: 3px solid rgba(167,139,250,0.2);
				border-top-color: #a78bfa; border-radius: 50%;
				animation: spin 0.8s linear infinite;
			}
			@keyframes spin { to { transform: rotate(360deg); } }

			/* Empty state */
			.graph-empty {
				position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
				color: var(--rs-text-muted); font-size: 13px; z-index: 5; pointer-events: none;
			}
			.graph-empty.hidden { display: none; }

			/* Toast notification */
			.graph-toast {
				position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
				padding: 10px 20px; border-radius: 8px; background: #1e1b4b; border: 1px solid #a78bfa;
				color: #e2e8f0; font-size: 13px; z-index: 1000; opacity: 0;
				transition: opacity 0.3s; pointer-events: none;
			}
			.graph-toast.visible { opacity: 1; }

			/* Metrics sidebar (Phase 3) */
			.metrics-panel {
				display: none; position: absolute; top: 12px; left: 12px;
				width: 200px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
				border-radius: 10px; padding: 12px; z-index: 6; font-size: 12px;
				max-height: calc(100% - 24px); overflow-y: auto;
			}
			.metrics-panel.visible { display: block; }
			.metrics-title { font-size: 11px; font-weight: 700; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
			.metrics-row { display: flex; justify-content: space-between; align-items: center; padding: 3px 0; }
			.metrics-value { font-weight: 700; color: #a78bfa; }
			.metrics-section { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); margin: 8px 0 4px; text-transform: uppercase; }
			.metrics-influencer { display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; border-radius: 4px; }
			.metrics-influencer:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
			.metrics-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
			.metrics-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
			.metrics-score { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); }
			.metrics-toggle {
				background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
				font-size: 12px; padding: 2px; position: absolute; top: 8px; right: 8px;
			}

			/* Power analysis panel */
			.power-panel {
				display: none; position: absolute; top: 12px; right: 12px;
				width: 280px; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
				border-radius: 10px; padding: 14px; z-index: 7; font-size: 12px;
				max-height: calc(100% - 24px); overflow-y: auto;
				backdrop-filter: blur(8px);
			}
			.power-panel.visible { display: block; }
			.power-header { font-size: 11px; font-weight: 700; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
			.power-close { background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px; position: absolute; top: 8px; right: 10px; }
			.power-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 12px; }
			.power-metric { background: rgba(255,255,255,0.03); border-radius: 6px; padding: 8px; text-align: center; }
			.power-metric-val { font-size: 18px; font-weight: 700; }
			.power-metric-lbl { font-size: 9px; color: var(--rs-text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 2px; }
			.power-gauge { height: 3px; background: rgba(255,255,255,0.08); border-radius: 2px; margin-top: 4px; overflow: hidden; }
			.power-gauge-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease-out; }
			.power-bars { display: flex; flex-direction: column; gap: 4px; }
			.power-bar-row { display: flex; align-items: center; gap: 6px; cursor: pointer; padding: 2px 0; border-radius: 4px; }
			.power-bar-row:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.04)); }
			.power-bar-name { width: 72px; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--rs-text-secondary, #cbd5e1); text-align: right; flex-shrink: 0; }
			.power-bar-tracks { flex: 1; display: flex; flex-direction: column; gap: 1px; }
			.power-bar-track { height: 10px; background: rgba(255,255,255,0.03); border-radius: 2px; overflow: hidden; }
			.power-bar-fill { height: 100%; border-radius: 2px; transition: width 0.5s ease-out; min-width: 2px; font-size: 8px; line-height: 10px; padding-left: 3px; color: rgba(255,255,255,0.7); }
			.power-bar-fill.weight { background: rgba(148,163,184,0.35); }
			.power-bar-fill.banzhaf { background: var(--power-color, #a78bfa); opacity: 0.85; }
			.power-bar-fill.shapley { background: var(--power-color, #a78bfa); opacity: 0.4; }
			.power-legend { display: flex; gap: 10px; margin-bottom: 8px; }
			.power-legend-item { display: flex; align-items: center; gap: 3px; font-size: 9px; color: var(--rs-text-muted); }
			.power-legend-sw { width: 8px; height: 8px; border-radius: 2px; }
			.power-section { font-size: 10px; font-weight: 600; color: var(--rs-text-muted); margin: 8px 0 6px; text-transform: uppercase; }
			.power-loading { text-align: center; padding: 20px; color: var(--rs-text-muted); }

			.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; }
			}

			.wiring-status {
				position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
				background: rgba(167,139,250,0.9); color: #fff; padding: 6px 16px;
				border-radius: 20px; font-size: 12px; font-weight: 600;
				pointer-events: none; z-index: 50; white-space: nowrap;
				animation: feed-pulse 1.5s ease-in-out infinite;
			}
			.wiring-status.hidden { display: none; }

			@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%; }
				.zoom-btn { width: 40px; height: 40px; font-size: 20px; }
				.filter-btn { min-height: 40px; }
				.metrics-panel { width: 160px; font-size: 11px; }
			}
		</style>

		<div class="toolbar">
			<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="">
			<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>
		</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="graph-loading" id="graph-loading"><div class="spinner"></div></div>
				<div class="graph-empty hidden" id="graph-empty">No nodes match your current filter.</div>
				<div class="metrics-panel" id="metrics-panel"></div>
				<div class="power-panel" id="power-panel"></div>
				<div class="zoom-controls">
					<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
					<span class="zoom-level" id="zoom-level">100%</span>
					<button class="zoom-btn" id="zoom-out" title="Zoom out">&minus;</button>
					<button class="zoom-btn zoom-btn--fit" id="zoom-fit" title="Fit to view">&#x2922;</button>
				</div>
			</div>
			<div class="member-list-panel" id="member-list-panel"></div>
		</div>

		<div class="detail-panel" id="detail-panel"></div>
		<div class="deleg-panel" id="deleg-panel"></div>

		<div class="layers-panel" id="layers-panel"></div>
		<div id="wiring-status" class="wiring-status hidden"></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>
		<div class="graph-toast" id="graph-toast"></div>
		`;
		this.attachListeners();
		this.initGraph3D();
	}

	private attachListeners() {
		// 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);
		});

		// 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);
				}
			});
		});

		// Keyboard shortcuts
		if (this._keyHandler) document.removeEventListener("keydown", this._keyHandler);
		this._keyHandler = (e: KeyboardEvent) => {
			// Don't intercept when typing in inputs
			const tag = (e.target as HTMLElement)?.tagName;
			if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;

			switch (e.key) {
				case "Escape":
					if (this.selectedNode) {
						this.selectedNode = null;
						this._tracedPathNodes = null;
						this.updateDetailPanel();
						this.updateGraphData();
					} else if (this.showMemberList) {
						this.showMemberList = false;
						const listBtn = this.shadow.getElementById("list-toggle");
						if (listBtn) listBtn.classList.remove("active");
						this.updateMemberList();
					} else if (this.layersPanelOpen) {
						this.layersPanelOpen = false;
						this.renderLayersPanel();
					}
					break;
				case "f":
				case "F":
					if (this.graph) this.graph.zoomToFit(300, 20);
					break;
				case "l":
				case "L":
					this.shadow.getElementById("list-toggle")?.click();
					break;
			}
		};
		document.addEventListener("keydown", this._keyHandler);
	}

	/** Show a brief toast notification */
	private showToast(message: string, durationMs = 2500) {
		const toast = this.shadow.getElementById("graph-toast");
		if (!toast) return;
		toast.textContent = message;
		toast.classList.add("visible");
		setTimeout(() => toast.classList.remove("visible"), durationMs);
	}

	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") {
						// Log-scale: 5% weight = ~2px, 50% = ~4px, 100% = ~5px
						const w = link.weight || 0.05;
						return 1.5 + Math.log10(1 + w * 9) * 3;
					}
					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")
				.linkVisibility((link: any) => {
					// Hide links whose endpoints haven't been positioned yet
					// (prevents TubeGeometry NaN errors during force warmup)
					const s = link.source, t = link.target;
					if (!s || !t) return false;
					const sx = typeof s === "object" ? s.x : undefined;
					const tx = typeof t === "object" ? t.x : undefined;
					return sx != null && !isNaN(sx) && tx != null && !isNaN(tx);
				})
				.linkOpacity((link: GraphEdge) => {
					if (!this._tracedPathNodes) return 0.6;
					const sid = typeof link.source === "string" ? link.source : (link.source as any)?.id;
					const tid = typeof link.target === "string" ? link.target : (link.target as any)?.id;
					const onPath = this._tracedPathNodes.has(sid) && this._tracedPathNodes.has(tid);
					return onPath ? 1.0 : 0.1;
				})
				.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.min(2, Math.ceil((link.strength || 0.5) * 3));
					return link.type === "delegates_to" ? Math.min(2, 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";
				})
				.onNodeHover((node: GraphNode | null) => {
					clearTimeout(this._hoverDebounce);
					this._hoverDebounce = setTimeout(() => {
						this.handleNodeHover(node);
					}, 30);
				})
				.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;
						this._tracedPathNodes = null;
					} else {
						this.selectedNode = node;
						// Path tracing in trust mode
						if (this.trustMode) {
							this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
						} else {
							this._tracedPathNodes = null;
						}
					}
					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();
					// Update metrics when selection changes
					if (this.trustMode) this.updateMetricsPanel();
				})
				.d3AlphaDecay(0.02)
				.d3VelocityDecay(0.3)
				.warmupTicks(100)
				.cooldownTicks(150);

			this.graph = graph;

			// Pulse animation for compatible feed target rings during wiring
			this._tickHandler = () => {
				if (!this.flowWiringSource || this._pulseRings.length === 0) return;
				const t = performance.now() / 1000;
				const pulse = 0.3 + 0.4 * (0.5 + 0.5 * Math.sin(t * 4));
				for (const pr of this._pulseRings) {
					if (pr.mesh?.material) pr.mesh.material.opacity = pulse;
				}
			};
			graph.onEngineTick(this._tickHandler);

			// 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=ROTATE, RIGHT=PAN, MIDDLE=DOLLY
			// THREE.MOUSE: ROTATE=0, DOLLY=1, PAN=2
			const controls = graph.controls();
			if (controls) {
				controls.mouseButtons = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
				controls.enableDamping = true;
				controls.dampingFactor = 0.12;
				controls.zoomSpeed = 2.5; // faster scroll wheel zoom
			}

			// ResizeObserver for responsive canvas
			this.resizeObserver = new ResizeObserver(() => {
				const rect = container.getBoundingClientRect();
				if (rect.width > 0 && rect.height > 0) {
					graph.width(rect.width);
					graph.height(rect.height);
				}
			});
			this.resizeObserver.observe(container);

			// Initial size
			requestAnimationFrame(() => {
				const rect = container.getBoundingClientRect();
				if (rect.width > 0 && rect.height > 0) {
					graph.width(rect.width);
					graph.height(rect.height);
				}
			});

		} catch (e) {
			console.error("[folk-graph-viewer] Failed to load 3d-force-graph:", e);
			container.innerHTML = `<div style="text-align:center;padding:40px;color:var(--rs-text-muted)">
				<p style="font-size:48px">&#x1F578;&#xFE0F;</p>
				<p>Failed to load 3D graph renderer</p>
				<p style="font-size:12px">${(e as Error).message || ""}</p>
			</div>`;
		}
	}

	private handleNodeHover(node: GraphNode | null) {
		const THREE = this._threeModule;
		const scene = this.graph?.scene();
		if (!THREE || !scene) return;

		// Remove previous glow
		if (this._hoverGlowMesh) {
			scene.remove(this._hoverGlowMesh);
			this._hoverGlowMesh.geometry?.dispose();
			this._hoverGlowMesh.material?.dispose();
			this._hoverGlowMesh = null;
		}
		this._hoverGlowNode = null;

		if (!node || node.x == null || node.y == null || node.z == null) return;

		const radius = this.getNodeRadius(node) / 10;
		const color = this.getNodeColor(node);

		const glowGeo = new THREE.RingGeometry(radius + 0.3, radius + 0.8, 32);
		const glowMat = new THREE.MeshBasicMaterial({
			color, transparent: true, opacity: 0.4, side: THREE.DoubleSide,
		});
		const glow = new THREE.Mesh(glowGeo, glowMat);
		glow.position.set(node.x, node.y, node.z);
		// Billboard: face camera
		glow.lookAt(this.graph.camera().position);
		glow.raycast = () => {};
		scene.add(glow);

		this._hoverGlowMesh = glow;
		this._hoverGlowNode = node;
	}

	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;

		// Path dimming: if tracing and node is not on path, dim to 20%
		const isOnPath = !this._tracedPathNodes || this._tracedPathNodes.has(node.id);
		const pathDimFactor = isOnPath ? 1.0 : 0.2;

		// Create a group to hold sphere + label
		const group = new THREE.Group();

		// Sphere geometry
		const segments = (node.type === "module" || node.type === "feed") ? 24 : 12;
		const geometry = new THREE.SphereGeometry(radius, segments, Math.round(segments * 3 / 4));
		const baseOpacity = node.type === "module" ? 0.95 : node.type === "feed" ? 0.85 : node.type === "company" ? 0.9 : 0.75;
		const opacity = baseOpacity * pathDimFactor;
		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 (pulse animated via onEngineTick)
		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 — animated via _pulseRings
						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);
						this._pulseRings.push({ mesh: pulseRing, baseOpacity: 0.5 });
					}
				}
			}
		}

		// Transitive chain indicator: outer torus ring when >30% of effective weight is from received delegations
		if (this.trustMode && node.weightAccounting && (node.type === "rspace_user" || node.type === "person") && isOnPath) {
			const acct = node.weightAccounting;
			const authorities = ["gov-ops", "fin-ops", "dev-ops"];
			const totalEW = authorities.reduce((s, a) => s + (acct.effectiveWeight[a] || 0), 0);
			const totalRecv = authorities.reduce((s, a) => s + (acct.receivedWeight[a] || 0), 0);
			if (totalEW > 0 && totalRecv / totalEW > 0.3) {
				const torusGeo = new THREE.TorusGeometry(radius + 0.5, 0.12, 8, 32);
				const torusMat = new THREE.MeshBasicMaterial({
					color: 0xc4b5fd, transparent: true, opacity: 0.5,
				});
				const torus = new THREE.Mesh(torusGeo, torusMat);
				group.add(torus);
			}
		}

		// 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 isSelected = this.selectedNode?.id === node.id;
		const cacheKey = `${node.id}:${isSelected}:${this.trustMode}:${this.authority}`;
		const cached = this._textSpriteCache.get(cacheKey);
		if (cached) return cached.clone();

		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 = 256;
		canvas.height = 48;

		ctx.font = `${(node.type === "company" || node.type === "module") ? "600" : "500"} ${Math.round(fontSize / 2)}px system-ui, sans-serif`;
		ctx.fillStyle = "#e2e8f0";
		ctx.textAlign = "center";
		ctx.textBaseline = "middle";
		ctx.shadowColor = "rgba(0,0,0,0.9)";
		ctx.shadowBlur = 3;
		ctx.fillText(text.length > 24 ? text.slice(0, 22) + "\u2026" : text, 128, 24);

		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);
		this._textSpriteCache.set(cacheKey, sprite);
		return sprite.clone();
	}

	private createBadgeSprite(THREE: any, text: string, color = "#7c3aed"): any {
		const cacheKey = `${text}:${color}`;
		const cached = this._badgeSpriteCache.get(cacheKey);
		if (cached) return cached.clone();

		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);
		this._badgeSpriteCache.set(cacheKey, sprite);
		return sprite.clone();
	}

	// ── Data update (no DOM rebuild) ──

	private updateGraphData() {
		if (!this.graph) return;

		const filtered = this.getFilteredNodes();

		// Show/hide empty state
		const emptyEl = this.shadow.getElementById("graph-empty");
		if (emptyEl) emptyEl.classList.toggle("hidden", filtered.length > 0 || this.nodes.length === 0);
		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;
			});
		}

		// Seed initial positions to prevent NaN geometry warnings during sim warmup
		for (const n of filtered) {
			if (n.x == null || isNaN(n.x)) n.x = (Math.random() - 0.5) * 100;
			if (n.y == null || isNaN(n.y)) n.y = (Math.random() - 0.5) * 100;
			if (n.z == null || isNaN(n.z)) n.z = (Math.random() - 0.5) * 100;
		}

		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();

		// Show/update metrics panel in trust mode
		if (this.trustMode) this.updateMetricsPanel();
	}

	// ── Ring layout ──

	private applyRingLayout() {
		if (!this.graph) return;
		const data = this.graph.graphData();
		const nodes = data.nodes as GraphNode[];

		// Group by role
		const adminNodes = nodes.filter(n => n.role === "admin");
		const memberNodes = nodes.filter(n => n.role === "member");
		const viewerNodes = nodes.filter(n => n.role === "viewer");
		const otherNodes = nodes.filter(n => n.type === "space");

		// Place space hub at center
		for (const n of otherNodes) {
			n.fx = 0; n.fy = 0; n.fz = 0;
		}

		this.placeSphere(adminNodes, 30);
		this.placeSphere(memberNodes, 80);
		this.placeSphere(viewerNodes, 160);

		this.graph.graphData(data);
		this.graph.d3ReheatSimulation();

		// Add ring guides
		this.removeRingGuides();
		this.addRingGuides();
	}

	private placeSphere(nodes: GraphNode[], radius: number) {
		const count = nodes.length;
		if (count === 0) return;
		// Fibonacci sphere — even distribution on a sphere surface
		const goldenAngle = Math.PI * (3 - Math.sqrt(5));
		for (let i = 0; i < count; i++) {
			const y = 1 - (2 * i) / (count - 1 || 1); // -1 to 1
			const r = Math.sqrt(1 - y * y);
			const theta = goldenAngle * i;
			nodes[i].fx = Math.cos(theta) * r * radius;
			nodes[i].fy = y * radius;
			nodes[i].fz = Math.sin(theta) * r * radius;
		}
	}

	private clearFixedPositions() {
		if (!this.graph) return;
		const data = this.graph.graphData();
		for (const n of data.nodes as GraphNode[]) {
			delete n.fx;
			delete n.fy;
			delete n.fz;
		}
		this.graph.graphData(data);
		this.graph.d3ReheatSimulation();
	}

	private addRingGuides() {
		const THREE = this._threeModule;
		if (!THREE || !this.graph) return;
		const scene = this.graph.scene();
		if (!scene) return;

		const sphereRadii = [
			{ r: 30, color: 0xa78bfa, label: "Admin" },
			{ r: 80, color: 0x10b981, label: "Member" },
			{ r: 160, color: 0x3b82f6, label: "Viewer" },
		];

		for (const { r, color } of sphereRadii) {
			// Wireframe sphere guide
			const geometry = new THREE.SphereGeometry(r, 24, 16);
			const material = new THREE.MeshBasicMaterial({
				color,
				transparent: true,
				opacity: 0.06,
				wireframe: true,
			});
			const mesh = new THREE.Mesh(geometry, material);
			scene.add(mesh);
			this.ringGuides.push(mesh);
		}
	}

	private removeRingGuides() {
		const scene = this.graph?.scene();
		if (!scene) return;
		for (const mesh of this.ringGuides) {
			scene.remove(mesh);
			mesh.geometry?.dispose();
			mesh.material?.dispose();
		}
		this.ringGuides = [];
	}

	// ── Incremental UI updates ──

	private updateStatsBar() {
		const bar = this.shadow.getElementById("stats-bar");
		if (!bar || !this.info) return;
		const crossOrg = this.edges.filter(e => e.type === "point_of_contact").length;
		const memberCount = this.info.rspace_member_count || 0;
		bar.innerHTML = `
			<div class="stat"><div class="stat-value">${this.info.member_count || 0}</div><div class="stat-label">People</div></div>
			<div class="stat"><div class="stat-value">${this.info.company_count || 0}</div><div class="stat-label">Organizations</div></div>
			${memberCount > 0 ? `<div class="stat"><div class="stat-value">${memberCount}</div><div class="stat-label">Members</div></div>` : ""}
			<div class="stat"><div class="stat-value">${crossOrg}</div><div class="stat-label">Cross-org Links</div></div>
		`;
	}

	private updateAuthorityBar() {
		const bar = this.shadow.getElementById("authority-bar");
		if (!bar) return;
		bar.classList.toggle("visible", this.trustMode);
		bar.querySelectorAll("[data-authority]").forEach(el => {
			const a = (el as HTMLElement).dataset.authority;
			el.classList.toggle("active", a === this.authority);
		});
	}

	private updateDetailPanel() {
		const panel = this.shadow.getElementById("detail-panel");
		if (!panel) return;

		if (!this.selectedNode) {
			panel.classList.remove("visible");
			panel.innerHTML = "";
			return;
		}

		const n = this.selectedNode;
		const connected = this.getConnectedNodes(n.id);
		const trust = (n.type !== "company" && n.type !== "space") ? this.getTrustScore(n.id) : -1;

		// Per-authority effective weight display
		let weightHtml = "";
		if (n.weightAccounting && (n.type === "rspace_user" || n.type === "person")) {
			const acct = n.weightAccounting;
			// Find max effective weight across all nodes for bar scaling
			let detailMaxEW = 1;
			for (const nd of this.nodes) {
				if (!nd.weightAccounting) continue;
				for (const a of DELEGATION_AUTHORITIES) {
					detailMaxEW = Math.max(detailMaxEW, nd.weightAccounting.effectiveWeight[a] || 0);
				}
			}
			weightHtml = DELEGATION_AUTHORITIES.map(a => {
				const ew = Math.round(acct.effectiveWeight[a] || 0);
				const base = Math.round((n.baseWeights?.[a] || 0) * 100);
				const recv = Math.round(acct.receivedWeight[a] || 0);
				const away = Math.round(acct.delegatedAway[a] || 0);
				const disp = AUTHORITY_DISPLAY[a];
				const barPct = Math.min((ew / detailMaxEW) * 100, 100);
				return `<div class="detail-trust"><span class="trust-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span><span class="trust-bar"><span class="trust-fill" style="width:${barPct}%;background:${disp?.color || '#a78bfa'}"></span></span><span class="trust-val" style="color:${disp?.color || '#a78bfa'}">${ew}</span></div>
				<div style="font-size:10px;color:var(--rs-text-muted);margin:-4px 0 6px 76px">base ${base} &minus; ${away} delegated + ${recv} received</div>`;
			}).join("");
		}

		panel.classList.add("visible");
		panel.innerHTML = `
			<div class="detail-header">
				<span class="detail-icon">${n.type === "company" ? "\u{1F3E2}" : n.type === "space" ? "\u{1F310}" : "\u{1F464}"}</span>
				<div class="detail-info">
					<div class="detail-name">${this.esc(n.name)}</div>
					<div class="detail-type">${this.esc(n.type === "company" ? "Organization" : n.type === "space" ? "Space" : n.type === "rspace_user" ? (n.role || "Member") : n.role || "Person")}${n.location ? ` \u00b7 ${this.esc(n.location)}` : ""}</div>
				</div>
				<button class="detail-close" id="close-detail">\u2715</button>
			</div>
			${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
			${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
			${weightHtml}
			${connected.length > 0 ? `
				<div class="detail-section">Connected (${connected.length})</div>
				${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
			` : ""}
		`;
	}

	// ── Delegation panel (multi-select + fuzzy search) ──

	private renderDelegationPanel() {
		const panel = this.shadow.getElementById("deleg-panel");
		if (!panel) return;

		if (this.selectedDelegates.size === 0) {
			panel.classList.remove("visible");
			panel.innerHTML = "";
			return;
		}

		// Compute remaining weight per authority
		const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
		this.selectedDelegates.forEach(({ weights }) => {
			for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
		});

		panel.classList.add("visible");
		const rows: string[] = [];
		this.selectedDelegates.forEach(({ node, weights }, id) => {
			rows.push(`
				<div class="deleg-row" data-deleg-id="${id}">
					<span class="deleg-row-name" title="${this.esc(node.name)}">${this.esc(node.name)}</span>
					<div class="deleg-row-sliders">
						${DELEGATION_AUTHORITIES.map(a => {
							const disp = AUTHORITY_DISPLAY[a];
							const w = weights[a] || 0;
							return `<span class="deleg-auth-label" style="color:${disp?.color}">${disp?.label}</span>
								<input type="range" class="deleg-mini-slider" data-did="${id}" data-auth="${a}" min="0" max="100" value="${w}" style="accent-color:${disp?.color}">
								<span class="deleg-mini-val" data-did-val="${id}-${a}" style="color:${disp?.color}">${w}%</span>`;
						}).join("")}
					</div>
					<button class="deleg-row-remove" data-remove="${id}" title="Remove">\u2715</button>
				</div>
			`);
		});

		panel.innerHTML = `
			<div class="deleg-panel-header">
				<span class="deleg-panel-title">Delegate Weight (${this.selectedDelegates.size} selected)</span>
				<button class="deleg-panel-close" id="deleg-panel-close">\u2715</button>
			</div>

			<div class="deleg-search-wrap">
				<input class="deleg-search" type="text" placeholder="Search members to add..." id="deleg-search" value="${this.esc(this.delegateSearchQuery)}">
				<div class="deleg-results" id="deleg-results" style="display:none"></div>
			</div>

			${rows.join("")}

			<div class="deleg-remaining">${DELEGATION_AUTHORITIES.map(a => {
				const disp = AUTHORITY_DISPLAY[a];
				return `<span style="color:${disp?.color}">${disp?.label}: ${Math.max(0, 100 - spent[a])}% left</span>`;
			}).join(" &middot; ")}</div>

			<button class="deleg-confirm-all" id="deleg-confirm-all">Confirm All Delegations</button>
		`;

		this.attachDelegationListeners(panel);
	}

	private attachDelegationListeners(panel: HTMLElement) {
		// Close
		this.shadow.getElementById("deleg-panel-close")?.addEventListener("click", () => {
			this.selectedDelegates.clear();
			this.delegateSearchQuery = "";
			this.renderDelegationPanel();
		});

		// Remove buttons
		panel.querySelectorAll("[data-remove]").forEach(el => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.remove!;
				this.selectedDelegates.delete(id);
				this.renderDelegationPanel();
			});
		});

		// Per-delegate per-authority sliders
		panel.querySelectorAll("[data-did][data-auth]").forEach(el => {
			el.addEventListener("input", (e) => {
				const id = (el as HTMLElement).dataset.did!;
				const auth = (el as HTMLElement).dataset.auth!;
				const val = parseInt((e.target as HTMLInputElement).value);
				const entry = this.selectedDelegates.get(id);
				if (entry) {
					entry.weights[auth] = val;
					const valEl = panel.querySelector(`[data-did-val="${id}-${auth}"]`);
					if (valEl) valEl.textContent = val + "%";
					// Update remaining display
					this.updateRemainingDisplay(panel);
				}
			});
		});

		// Fuzzy search
		const searchInput = this.shadow.getElementById("deleg-search") as HTMLInputElement | null;
		const resultsDiv = this.shadow.getElementById("deleg-results");
		let searchTimeout: any;

		searchInput?.addEventListener("input", () => {
			this.delegateSearchQuery = searchInput.value;
			clearTimeout(searchTimeout);
			searchTimeout = setTimeout(() => {
				this.updateSearchResults(searchInput.value, resultsDiv);
			}, 150);
		});

		searchInput?.addEventListener("focus", () => {
			if (searchInput.value.trim()) {
				this.updateSearchResults(searchInput.value, resultsDiv);
			}
		});

		// Confirm all
		this.shadow.getElementById("deleg-confirm-all")?.addEventListener("click", () => {
			this.confirmAllDelegations();
		});
	}

	private updateRemainingDisplay(panel: HTMLElement) {
		const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
		this.selectedDelegates.forEach(({ weights }) => {
			for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
		});
		const remainEl = panel.querySelector(".deleg-remaining");
		if (remainEl) {
			remainEl.innerHTML = DELEGATION_AUTHORITIES.map(a => {
				const disp = AUTHORITY_DISPLAY[a];
				const left = Math.max(0, 100 - spent[a]);
				return `<span style="color:${disp?.color}${left === 0 ? ';opacity:0.5' : ''}">${disp?.label}: ${left}% left</span>`;
			}).join(" &middot; ");
		}
	}

	private fuzzyMatch(query: string, name: string): boolean {
		const q = query.toLowerCase();
		const n = name.toLowerCase();
		// Substring match
		if (n.includes(q)) return true;
		// Fuzzy: all query chars appear in order
		let qi = 0;
		for (let i = 0; i < n.length && qi < q.length; i++) {
			if (n[i] === q[qi]) qi++;
		}
		return qi === q.length;
	}

	private updateSearchResults(query: string, resultsDiv: HTMLElement | null) {
		if (!resultsDiv) return;
		const q = query.trim();
		if (!q) {
			resultsDiv.style.display = "none";
			return;
		}

		const matches = this.nodes.filter(n =>
			(n.type === "rspace_user" || n.type === "person") &&
			!this.selectedDelegates.has(n.id) &&
			this.fuzzyMatch(q, n.name)
		).slice(0, 8);

		if (matches.length === 0) {
			resultsDiv.style.display = "none";
			return;
		}

		resultsDiv.style.display = "block";
		resultsDiv.innerHTML = matches.map(n =>
			`<div class="deleg-result-item" data-add-id="${n.id}">
				<span>${this.esc(n.name)}</span>
				<span class="deleg-result-role">${n.role || n.type}</span>
			</div>`
		).join("");

		resultsDiv.querySelectorAll("[data-add-id]").forEach(el => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.addId!;
				const node = this.nodes.find(n => n.id === id);
				if (node) {
					this.selectedDelegates.set(id, {
						node,
						weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 },
					});
					this.delegateSearchQuery = "";
					this.renderDelegationPanel();
				}
			});
		});
	}

	private confirmAllDelegations() {
		if (this.selectedDelegates.size === 0) return;

		// Ensure "me-demo" node exists
		if (!this.nodes.find(n => n.id === "me-demo")) {
			this.nodes.push({
				id: "me-demo",
				name: "You",
				type: "rspace_user",
				workspace: "",
				role: "member",
			});
		}

		// Create delegation edges for each selected delegate
		this.selectedDelegates.forEach(({ node, weights }) => {
			for (const a of DELEGATION_AUTHORITIES) {
				const weight = Math.round((weights[a] || 0) / 100 * 100) / 100;
				if (weight <= 0) continue;
				const edge: GraphEdge = {
					source: "me-demo",
					target: node.id,
					type: "delegates_to",
					weight,
					authority: a,
				};
				this.edges.push(edge);
				this.demoDelegations.push(edge);
			}
		});

		// Recompute weight accounting + refresh
		this.recomputeWeightAccounting();
		const delegateCount = this.selectedDelegates.size;
		this.selectedDelegates.clear();
		this.delegateSearchQuery = "";
		this.renderDelegationPanel();
		this.updateGraphData();
		this.showToast(`Delegated to ${delegateCount} member${delegateCount > 1 ? "s" : ""}`);
	}

	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);
			}
		}
	}

	/** BFS from a node along delegation edges (inbound + outbound), return all reachable node IDs within maxDepth */
	private computeDelegationPaths(nodeId: string, maxDepth: number): Set<string> {
		const visited = new Set<string>([nodeId]);
		let frontier = [nodeId];

		for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
			const next: string[] = [];
			for (const nid of frontier) {
				for (const e of this.edges) {
					if (e.type !== "delegates_to") continue;
					const sid = typeof e.source === "string" ? e.source : e.source.id;
					const tid = typeof e.target === "string" ? e.target : e.target.id;
					if (sid === nid && !visited.has(tid)) { visited.add(tid); next.push(tid); }
					if (tid === nid && !visited.has(sid)) { visited.add(sid); next.push(sid); }
				}
			}
			frontier = next;
		}
		return visited;
	}

	/** Update the network metrics sidebar (Phase 3) */
	private updateMetricsPanel() {
		const panel = this.shadow.getElementById("metrics-panel");
		if (!panel) return;

		if (!this.trustMode) {
			panel.classList.remove("visible");
			return;
		}
		panel.classList.add("visible");

		// Compute metrics
		const delegEdges = this.edges.filter(e => e.type === "delegates_to");
		const totalDelegations = delegEdges.length;

		// In-degree map
		const inDegree = new Map<string, number>();
		for (const e of delegEdges) {
			const tid = typeof e.target === "string" ? e.target : e.target.id;
			inDegree.set(tid, (inDegree.get(tid) || 0) + 1);
		}

		// Top 5 influencers by in-degree
		const influencers = [...inDegree.entries()]
			.sort((a, b) => b[1] - a[1])
			.slice(0, 5)
			.map(([id, count]) => {
				const node = this.nodes.find(n => n.id === id);
				return { id, name: node?.name || id.slice(0, 12), count };
			});

		// Gini concentration index on effective weights
		const weights = this.nodes
			.filter(n => n.weightAccounting)
			.map(n => {
				const acct = n.weightAccounting!;
				return Object.values(acct.effectiveWeight).reduce((s, v) => s + v, 0);
			})
			.sort((a, b) => a - b);
		let gini = 0;
		if (weights.length > 1) {
			const n = weights.length;
			const mean = weights.reduce((s, v) => s + v, 0) / n;
			if (mean > 0) {
				let sumDiff = 0;
				for (let i = 0; i < n; i++) {
					for (let j = i + 1; j < n; j++) {
						sumDiff += Math.abs(weights[i] - weights[j]);
					}
				}
				gini = sumDiff / (n * n * mean);
			}
		}

		panel.innerHTML = `
			<div class="metrics-title">Network Metrics</div>
			<button class="metrics-toggle" id="metrics-close">&times;</button>
			<div class="metrics-row"><span>Delegations</span><span class="metrics-value">${totalDelegations}</span></div>
			<div class="metrics-row"><span>Gini Index</span><span class="metrics-value">${gini.toFixed(2)}</span></div>
			<div class="metrics-row"><span>Participants</span><span class="metrics-value">${inDegree.size}</span></div>
			${influencers.length > 0 ? `
				<div class="metrics-section">Top Influencers</div>
				${influencers.map(inf => `
					<div class="metrics-influencer" data-focus-id="${inf.id}">
						<span class="metrics-dot" style="background:#a78bfa"></span>
						<span class="metrics-name">${this.esc(inf.name)}</span>
						<span class="metrics-score">${inf.count} in</span>
					</div>
				`).join("")}
			` : ""}
		`;

		// Click handlers
		panel.querySelector("#metrics-close")?.addEventListener("click", () => {
			panel.classList.remove("visible");
		});
		panel.querySelectorAll("[data-focus-id]").forEach(el => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.focusId!;
				const node = this.nodes.find(n => n.id === id);
				if (node && this.graph && node.x != null && node.y != null && node.z != null) {
					this.selectedNode = node;
					this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
					this.updateDetailPanel();
					this.updateGraphData();
					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 async fetchPowerData() {
		this._powerFetchController?.abort();
		this._powerFetchController = new AbortController();
		const auth = this.authority === "all" ? "gov-ops" : this.authority;
		try {
			const res = await fetch(
				`/rnetwork/api/power-indices?space=${encodeURIComponent(this.space)}&authority=${encodeURIComponent(auth)}`,
				{ signal: this._powerFetchController.signal },
			);
			if (!res.ok) throw new Error(`HTTP ${res.status}`);
			this.powerData = await res.json();
		} catch (e) {
			if ((e as Error).name !== "AbortError") {
				console.error("[power] fetch error:", e);
				this.powerData = null;
			}
		}
		this.updatePowerPanel();
		// Re-render graph with power-based node sizes
		if (this.graph) this.updateGraphData();
	}

	private updatePowerPanel() {
		const panel = this.shadow.getElementById("power-panel");
		if (!panel) return;

		if (!this.powerMode) {
			panel.classList.remove("visible");
			return;
		}
		panel.classList.add("visible");

		const d = this.powerData;
		if (!d || !d.results || d.results.length === 0) {
			panel.innerHTML = `
				<div class="power-header">Power Analysis</div>
				<button class="power-close" id="power-close">&times;</button>
				<div class="power-loading">${d ? 'No delegation data for analysis.' : 'Loading power indices...'}</div>
			`;
			panel.querySelector("#power-close")?.addEventListener("click", () => panel.classList.remove("visible"));
			return;
		}

		const auth = this.authority === "all" ? "gov-ops" : this.authority;
		const authColor = AUTHORITY_COLORS[auth] || "#a78bfa";
		const effectiveN = d.herfindahlIndex > 0 ? (1 / d.herfindahlIndex).toFixed(1) : "—";
		const giniPct = (d.giniCoefficient * 100).toFixed(0);
		const hhiPct = (d.herfindahlIndex * 100).toFixed(0);
		const giniColor = d.giniCoefficient > 0.6 ? "#f87171" : d.giniCoefficient > 0.3 ? "#fbbf24" : "#34d399";
		const hhiColor = d.herfindahlIndex > 0.25 ? "#f87171" : d.herfindahlIndex > 0.15 ? "#fbbf24" : "#34d399";

		const maxVal = Math.max(...d.results.map((r: any) => Math.max(r.weight / d.totalWeight, r.banzhaf, r.shapleyShubik)), 0.01);
		const top = d.results.slice(0, 15);

		panel.innerHTML = `
			<div class="power-header">Power Analysis</div>
			<button class="power-close" id="power-close">&times;</button>
			<div class="power-metrics">
				<div class="power-metric">
					<div class="power-metric-val">${d.playerCount}</div>
					<div class="power-metric-lbl">Voters</div>
				</div>
				<div class="power-metric">
					<div class="power-metric-val">${effectiveN}</div>
					<div class="power-metric-lbl">Effective</div>
					<div class="power-gauge"><div class="power-gauge-fill" style="width:${Math.min(100, (1 / d.herfindahlIndex / d.playerCount) * 100)}%;background:${hhiColor}"></div></div>
				</div>
				<div class="power-metric">
					<div class="power-metric-val">${giniPct}%</div>
					<div class="power-metric-lbl">Gini</div>
					<div class="power-gauge"><div class="power-gauge-fill" style="width:${d.giniCoefficient * 100}%;background:${giniColor}"></div></div>
				</div>
				<div class="power-metric">
					<div class="power-metric-val">${hhiPct}%</div>
					<div class="power-metric-lbl">HHI</div>
					<div class="power-gauge"><div class="power-gauge-fill" style="width:${d.herfindahlIndex * 100}%;background:${hhiColor}"></div></div>
				</div>
			</div>
			<div class="power-legend">
				<div class="power-legend-item"><div class="power-legend-sw" style="background:rgba(148,163,184,0.35)"></div>Weight</div>
				<div class="power-legend-item"><div class="power-legend-sw" style="background:${authColor}"></div>Banzhaf</div>
				<div class="power-legend-item"><div class="power-legend-sw" style="background:${authColor}66"></div>Shapley</div>
			</div>
			<div class="power-section">Top ${top.length} by Banzhaf Power</div>
			<div class="power-bars" style="--power-color:${authColor}">${top.map((r: any) => {
				const node = this.nodes.find(n => n.id === r.did);
				const name = node?.name || r.did.slice(0, 12);
				const wPct = ((r.weight / d.totalWeight) / maxVal * 100).toFixed(1);
				const bPct = (r.banzhaf / maxVal * 100).toFixed(1);
				const sPct = (r.shapleyShubik / maxVal * 100).toFixed(1);
				return `<div class="power-bar-row" data-focus-did="${r.did}">
					<div class="power-bar-name" title="${this.esc(name)}">${this.esc(name)}</div>
					<div class="power-bar-tracks">
						<div class="power-bar-track"><div class="power-bar-fill weight" style="width:${wPct}%">${(r.weight / d.totalWeight * 100).toFixed(0)}%</div></div>
						<div class="power-bar-track"><div class="power-bar-fill banzhaf" style="width:${bPct}%">${(r.banzhaf * 100).toFixed(1)}%</div></div>
						<div class="power-bar-track"><div class="power-bar-fill shapley" style="width:${sPct}%">${(r.shapleyShubik * 100).toFixed(1)}%</div></div>
					</div>
				</div>`;
			}).join("")}</div>
		`;

		// Delay bar fill widths by 1 frame so CSS transitions animate
		requestAnimationFrame(() => {
			panel.querySelectorAll<HTMLElement>(".power-bar-fill").forEach(el => {
				const w = el.style.width;
				el.style.width = "0%";
				requestAnimationFrame(() => { el.style.width = w; });
			});
		});

		// Click handlers
		panel.querySelector("#power-close")?.addEventListener("click", () => panel.classList.remove("visible"));
		panel.querySelectorAll("[data-focus-did]").forEach(el => {
			el.addEventListener("click", () => {
				const did = (el as HTMLElement).dataset.focusDid!;
				const node = this.nodes.find(n => n.id === did);
				if (node && this.graph && node.x != null && node.y != null && node.z != null) {
					this.selectedNode = node;
					this._tracedPathNodes = this.computeDelegationPaths(node.id, 3);
					this.updateDetailPanel();
					this.updateGraphData();
					this.graph.cameraPosition(
						{ x: node.x + 60, y: node.y + 18, z: node.z + 60 },
						{ x: node.x, y: node.y, z: node.z },
						400
					);
				}
			});
		});
	}

	private updateMemberList() {
		const panel = this.shadow.getElementById("member-list-panel");
		if (!panel) return;

		if (!this.showMemberList) {
			panel.classList.remove("visible");
			return;
		}
		panel.classList.add("visible");

		const groups: { label: string; color: string; role: string; nodes: GraphNode[] }[] = [
			{ label: "Admins", color: "#a78bfa", role: "admin", nodes: [] },
			{ label: "Members", color: "#10b981", role: "member", nodes: [] },
			{ label: "Viewers", color: "#3b82f6", role: "viewer", nodes: [] },
		];

		const filtered = this.getFilteredNodes().filter(n => n.type === "rspace_user" || n.type === "person");
		for (const n of filtered) {
			const g = groups.find(g => g.role === n.role) || groups[2];
			g.nodes.push(n);
		}

		// Sort each group by effective weight (descending)
		for (const g of groups) {
			g.nodes.sort((a, b) => {
				const aw = a.weightAccounting ? Object.values(a.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
				const bw = b.weightAccounting ? Object.values(b.weightAccounting.effectiveWeight).reduce((s, v) => s + v, 0) : 0;
				return bw - aw;
			});
		}

		const authHeaders = DELEGATION_AUTHORITIES.map(a => {
			const d = AUTHORITY_DISPLAY[a];
			return `<span style="color:${d?.color};font-size:9px;font-weight:700">${d?.label?.[0]}</span>`;
		}).join(`<span style="opacity:0.3">/</span>`);

		panel.innerHTML = groups.filter(g => g.nodes.length > 0).map(g => `
			<div class="member-group">
				<div class="member-group-header" style="color:${g.color}">
					${g.label}
					<span style="display:flex;align-items:center;gap:2px"><span class="member-group-count">${g.nodes.length}</span><span class="member-weight" style="font-size:9px">${authHeaders}</span></span>
				</div>
				${g.nodes.map(n => {
					const wa = n.weightAccounting;
					const weights = wa ? DELEGATION_AUTHORITIES.map(a => {
						const disp = AUTHORITY_DISPLAY[a];
						return `<span style="color:${disp?.color}">${Math.round(wa.effectiveWeight[a] || 0)}</span>`;
					}).join(`<span style="color:var(--rs-text-muted);opacity:0.4">/</span>`) : "";
					return `<div class="member-item" data-member-id="${n.id}">
						<span class="member-dot" style="background:${g.color}"></span>
						<span class="member-name" title="${this.esc(n.name)}">${this.esc(n.name)}</span>
						${weights ? `<span class="member-weight">${weights}</span>` : ""}
					</div>`;
				}).join("")}
			</div>
		`).join("");

		// Click to select/focus node in graph
		panel.querySelectorAll("[data-member-id]").forEach(el => {
			el.addEventListener("click", () => {
				const id = (el as HTMLElement).dataset.memberId!;
				const node = this.nodes.find(n => n.id === id);
				if (node && this.graph) {
					this.selectedNode = node;
					this.updateDetailPanel();
					this.updateGraphData();
					// Fly camera to node
					if (node.x != null && node.y != null && node.z != null) {
						const dist = 60;
						this.graph.cameraPosition(
							{ x: node.x + dist, y: node.y + dist * 0.3, z: node.z + dist },
							{ x: node.x, y: node.y, z: node.z },
							400
						);
					}
				}
			});
		});
	}

	private updateWorkspaceList() {
		const section = this.shadow.getElementById("workspace-section");
		if (!section) return;
		if (this.workspaces.length === 0) {
			section.innerHTML = "";
			return;
		}
		section.innerHTML = `
			<div style="margin-top:20px;font-size:14px;font-weight:600;color:var(--rs-text-secondary)">${this.space === "demo" ? "Organizations" : "Workspaces"}</div>
			<div class="workspace-list">
				${this.workspaces.map(ws => `
					<div class="ws-card">
						<div class="ws-name">${this.esc(ws.name || ws.slug)}</div>
						<div class="ws-meta">${ws.nodeCount || 0} nodes &middot; ${ws.edgeCount || 0} edges</div>
					</div>
				`).join("")}
			</div>
		`;
	}

	// ══════════════════════════════════════════════════════════════
	// ═══ 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;
		this._pulseRings = [];
		this.updateWiringStatus();

		// 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 persistLayerConfig() {
		if (!this._lfClient) return;
		const layers = this.layerInstances.map(l => ({
			moduleId: l.moduleId,
			moduleName: l.moduleName,
			moduleIcon: l.moduleIcon,
			axis: l.axis,
		}));
		const flows = this.crossLayerFlows.map(f => ({
			id: f.id,
			sourceLayerIdx: f.sourceLayerIdx,
			sourceFeedId: f.sourceFeedId,
			targetLayerIdx: f.targetLayerIdx,
			targetFeedId: f.targetFeedId,
			kind: f.kind,
			strength: f.strength,
		}));
		this._lfClient.saveLayerConfig(layers, flows);
	}

	private restoreLayerConfig() {
		if (!this._lfClient) return;
		const saved = this._lfClient.getLayerConfig();
		if (!saved || saved.layers.length === 0) return;

		// Only restore if not already in layers mode
		if (this.layersMode && this.layerInstances.length > 0) return;

		const available = this.getAvailableModules();
		const restored: LayerInstance[] = [];

		for (const lc of saved.layers) {
			const mod = available.find(m => m.id === lc.moduleId);
			if (!mod) continue;
			restored.push({
				moduleId: lc.moduleId,
				moduleName: lc.moduleName,
				moduleIcon: lc.moduleIcon,
				moduleColor: MODULE_PALETTE[restored.length % MODULE_PALETTE.length],
				axis: (lc.axis as AxisPlane) || "xy",
				feeds: mod.feeds,
				acceptsFeeds: mod.acceptsFeeds,
			});
		}

		if (restored.length === 0) return;

		this.layerInstances = restored;
		this.crossLayerFlows = saved.flows.filter(f =>
			f.sourceLayerIdx < restored.length && f.targetLayerIdx < restored.length
		);

		// Enter layers mode
		if (!this.layersMode) {
			this.layersMode = true;
			this.savedGraphState = { nodes: [...this.nodes], edges: [...this.edges] };
		}
		this.rebuildLayerGraph();
		this.renderLayersPanel();
	}

	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;

		// Clear pulse ring references (will be re-populated by createNodeObject)
		this._pulseRings = [];

		// Persist layer config to CRDT doc
		this.persistLayerConfig();

		// 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 updateWiringStatus() {
		const el = this.shadow.getElementById("wiring-status");
		if (!el) return;
		if (this.flowWiringSource) {
			const layer = this.layerInstances[this.flowWiringSource.layerIdx];
			el.textContent = `Wiring: click a compatible target from ${layer?.moduleIcon || ''} ${layer?.moduleName || ''}...`;
			el.classList.remove("hidden");
		} else {
			el.classList.add("hidden");
			this._pulseRings = [];
		}
	}

	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();
			this.updateWiringStatus();
		} 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();
				this.updateWiringStatus();
				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();
				this.updateWiringStatus();
				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();
			this.updateWiringStatus();
		});

		// 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.updateWiringStatus();
			this.renderLayersPanel();
			this.rebuildLayerGraph();
		});
	}

}

customElements.define("folk-graph-viewer", FolkGraphViewer);
