/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import type { MatterNode } from "@matter-server/ws-client"; import { LitElement, css } from "lit"; import { property, state } from "lit/decorators.js"; import { DataSet, Network } from "vis-network/standalone"; import { getCssVar } from "../../util/shared-styles.js"; import { ThemeService } from "../../util/theme-service.js"; import type { NetworkGraphEdge, NetworkGraphNode } from "./network-types.js"; /** * Base class for network graph components (Thread and WiFi). * Provides shared vis.js network initialization, highlighting, and theme support. */ export abstract class BaseNetworkGraph extends LitElement { @property({ type: Object }) public nodes: Record = {}; @state() protected _selectedNodeId: number | string | null = null; @state() protected _physicsEnabled = true; protected _network?: Network; protected _nodesDataSet?: DataSet; protected _edgesDataSet?: DataSet; protected _container?: HTMLDivElement; protected _resizeObserver?: ResizeObserver; protected _themeUnsubscribe?: () => void; protected _updateDebounceTimer?: ReturnType; protected _autoFreezeTimer?: ReturnType; /** Whether auto-freeze has already been applied (to avoid re-freezing after user unfreezes) */ private _autoFreezeApplied = false; /** Whether initial fit has been done (to preserve user's zoom/pan after first load) */ private _initialFitDone = false; /** Store original edge colors for restoration after highlighting */ private _originalEdgeColors: Map = new Map(); protected _getFontColor(): string { return getCssVar("--graph-font-color", ThemeService.effectiveTheme === "dark" ? "#e0e0e0" : "#333333"); } protected _getDimmedEdgeColor(): string { return getCssVar("--graph-edge-dimmed", ThemeService.effectiveTheme === "dark" ? "#555555" : "#cccccc"); } /** * Returns physics options for the network. Override in subclasses for different behavior. */ protected _getPhysicsOptions(): any { return { enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { gravitationalConstant: -70, centralGravity: 0.005, springLength: 130, springConstant: 0.08, damping: 0.4, avoidOverlap: 0.6, }, stabilization: { enabled: true, iterations: 250, updateInterval: 25, }, }; } override updated(changedProperties: Map): void { super.updated(changedProperties); // If container wasn't found in firstUpdated (empty state was rendered), // try to find and observe it now that it might have appeared if (!this._container && !this._resizeObserver) { this._tryAttachContainer(); } if (changedProperties.has("nodes")) { this._debouncedUpdateGraph(); } } /** * Try to find and attach the graph container if it wasn't available before. * This handles the case where empty state was rendered initially. */ private _tryAttachContainer(): void { const container = this.shadowRoot?.querySelector(".graph-container") as HTMLDivElement; if (container) { this._container = container; this._resizeObserver = new ResizeObserver(entries => { const entry = entries[0]; if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) { if (!this._network) { this._initializeNetwork(); } else { this._network.setSize(`${entry.contentRect.width}px`, `${entry.contentRect.height}px`); this._network.redraw(); } } }); this._resizeObserver.observe(this._container); } } /** Debounced graph update to avoid excessive redraws */ protected _debouncedUpdateGraph(): void { if (this._updateDebounceTimer) { clearTimeout(this._updateDebounceTimer); } this._updateDebounceTimer = setTimeout(() => { this._updateGraph(); }, 100); } override firstUpdated(): void { this._container = this.shadowRoot?.querySelector(".graph-container") as HTMLDivElement; if (this._container) { // Wait for container to have proper dimensions before initializing this._resizeObserver = new ResizeObserver(entries => { const entry = entries[0]; if (entry && entry.contentRect.width > 0 && entry.contentRect.height > 0) { if (!this._network) { this._initializeNetwork(); } else { // Resize existing network this._network.setSize(`${entry.contentRect.width}px`, `${entry.contentRect.height}px`); this._network.redraw(); } } }); this._resizeObserver.observe(this._container); } // Subscribe to theme changes - refresh entire graph to update colors this._themeUnsubscribe = ThemeService.subscribe(() => { if (this._network && this._nodesDataSet) { const fontColor = this._getFontColor(); // Update default font colors for new nodes this._network.setOptions({ nodes: { font: { color: fontColor, }, }, }); // Regenerate node icons and edges with new theme colors this._updateGraph(); // Update font color on all existing nodes in the dataset const allNodes = this._nodesDataSet.get(); const nodeUpdates = allNodes.map((node: NetworkGraphNode) => ({ id: node.id, font: { color: fontColor }, })); this._nodesDataSet.update(nodeUpdates); // Force redraw this._network.redraw(); } }); } override disconnectedCallback(): void { super.disconnectedCallback(); this._resizeObserver?.disconnect(); this._themeUnsubscribe?.(); this._cancelAutoFreezeTimer(); if (this._updateDebounceTimer) { clearTimeout(this._updateDebounceTimer); } this._nodesDataSet?.clear(); this._edgesDataSet?.clear(); this._network?.destroy(); this._network = undefined; this._nodesDataSet = undefined; this._edgesDataSet = undefined; this._originalEdgeColors.clear(); } protected _initializeNetwork(): void { if (!this._container) return; // Get actual dimensions const rect = this._container.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { return; // Wait for proper dimensions } this._nodesDataSet = new DataSet(); this._edgesDataSet = new DataSet(); const options = { width: `${rect.width}px`, height: `${rect.height}px`, autoResize: false, // We handle resize manually nodes: { shape: "image", size: 30, font: { size: 12, color: this._getFontColor(), }, borderWidth: 2, borderWidthSelected: 3, }, edges: { width: 2, smooth: { enabled: true, type: "continuous", roundness: 0.5, }, }, physics: this._getPhysicsOptions(), interaction: { hover: true, tooltipDelay: 200, hideEdgesOnDrag: true, }, }; this._network = new Network( this._container, { nodes: this._nodesDataSet, edges: this._edgesDataSet, }, options, ); // Handle node selection this._network.on("selectNode", (params: { nodes: (number | string)[] }) => { if (params.nodes.length > 0) { this._selectedNodeId = params.nodes[0]; this._highlightConnections(this._selectedNodeId); this._dispatchNodeSelected(this._selectedNodeId); } }); this._network.on("deselectNode", () => { this._selectedNodeId = null; this._clearHighlights(); this._dispatchNodeSelected(null); }); // Auto-fit after initial stabilization completes (only once) this._network.on("stabilizationIterationsDone", () => { if (!this._initialFitDone) { // Fit with padding to keep nodes away from edges this._network?.fit({ animation: { duration: 500, easingFunction: "easeInOutQuad", }, }); this._initialFitDone = true; // Start auto-freeze timer (5 seconds after initial stabilization) this._startAutoFreezeTimer(); } }); this._updateGraph(); } /** * Returns true if the graph is initialized and ready for interaction. */ public isReady(): boolean { return this._network !== undefined && this._nodesDataSet !== undefined; } /** * Fits all nodes into view. */ public fit(): void { this._network?.fit({ animation: { duration: 300, easingFunction: "easeInOutQuad", }, }); } /** * Zooms in by 20%. */ public zoomIn(): void { if (!this._network) return; const scale = this._network.getScale(); this._network.moveTo({ scale: scale * 1.2, animation: { duration: 200, easingFunction: "easeInOutQuad", }, }); } /** * Zooms out by 20%. */ public zoomOut(): void { if (!this._network) return; const scale = this._network.getScale(); this._network.moveTo({ scale: scale / 1.2, animation: { duration: 200, easingFunction: "easeInOutQuad", }, }); } /** * Returns whether physics simulation is currently enabled. */ public get physicsEnabled(): boolean { return this._physicsEnabled; } /** * Enables or disables physics simulation (node movement/settling). * When disabled, nodes freeze in place; when enabled, they resume settling. * @param enabled Whether to enable physics * @param isManual If true, cancels any pending auto-freeze (user manually toggled) */ public setPhysicsEnabled(enabled: boolean, isManual = true): void { // If user manually toggles, cancel auto-freeze to respect their choice if (isManual) { this._cancelAutoFreezeTimer(); this._autoFreezeApplied = true; // Prevent future auto-freeze } this._physicsEnabled = enabled; this._network?.setOptions({ physics: { enabled }, }); } /** * Starts the auto-freeze timer. Physics will be disabled after 5 seconds * unless the user manually toggles physics or the timer is cancelled. */ private _startAutoFreezeTimer(): void { // Don't auto-freeze if already applied or user has manually controlled physics if (this._autoFreezeApplied) { return; } this._cancelAutoFreezeTimer(); this._autoFreezeTimer = setTimeout(() => { if (!this._autoFreezeApplied && this._physicsEnabled) { this.setPhysicsEnabled(false, false); // false = not manual, don't mark as applied this._autoFreezeApplied = true; // Dispatch event so parent can update UI state this.dispatchEvent( new CustomEvent("physics-changed", { detail: { enabled: false }, bubbles: true, composed: true, }), ); } }, 5000); } /** * Cancels any pending auto-freeze timer. */ private _cancelAutoFreezeTimer(): void { if (this._autoFreezeTimer) { clearTimeout(this._autoFreezeTimer); this._autoFreezeTimer = undefined; } } /** * Selects a node by ID and focuses on it. */ public selectNode(nodeId: number | string): void { if (!this._network) return; this._selectedNodeId = nodeId; this._network.selectNodes([nodeId]); this._highlightConnections(nodeId); this._dispatchNodeSelected(nodeId); // Focus on the selected node this._network.focus(nodeId, { scale: 1.2, animation: { duration: 500, easingFunction: "easeInOutQuad", }, }); } /** * Deselects all nodes, clears highlights, and restores default styling. */ public deselectAll(): void { if (!this._network) return; this._selectedNodeId = null; this._network.unselectAll(); this._clearHighlights(); } protected _dispatchNodeSelected(nodeId: number | string | null): void { this.dispatchEvent( new CustomEvent("node-selected", { detail: { nodeId }, bubbles: true, composed: true, }), ); } /** * Highlights edges connected to the selected node and makes neighbor nodes more prominent. */ protected _highlightConnections(nodeId: number | string): void { if (!this._edgesDataSet || !this._nodesDataSet) return; const allEdges = this._edgesDataSet.get(); // First pass: Store original colors ONLY if not already stored. // We must do this BEFORE any highlighting modifies edge colors. // If edges were already highlighted (switching between nodes), the colors // in the DataSet might be dimmed, so we rely on previously stored values. for (const edge of allEdges) { const edgeId = String(edge.id); if (!this._originalEdgeColors.has(edgeId)) { const colorObj = edge.color as { color: string; highlight: string } | undefined; // Extract colors, with fallbacks const color = colorObj?.color ?? getCssVar("--graph-node-fallback", "#999999"); const highlight = colorObj?.highlight ?? color; this._originalEdgeColors.set(edgeId, { color, highlight }); } } // Find all edges connected to this node const connectedEdges = this._edgesDataSet.get({ filter: (edge: NetworkGraphEdge) => edge.from === nodeId || edge.to === nodeId, }); // Get neighbor node IDs const neighborIds = new Set(); for (const edge of connectedEdges) { if (edge.from === nodeId) { neighborIds.add(edge.to); } else { neighborIds.add(edge.from); } } // Update edges - make connected ones thicker, dim non-connected ones const dimmedColor = this._getDimmedEdgeColor(); const edgeUpdates = allEdges.map((edge: NetworkGraphEdge) => { const isConnected = edge.from === nodeId || edge.to === nodeId; const originalColor = this._originalEdgeColors.get(String(edge.id)); // Use stored original color for connected edges, fallback to a default if somehow missing const connectedColor = originalColor ?? { color: getCssVar("--graph-node-fallback", "#999999"), highlight: getCssVar("--graph-node-fallback", "#999999"), }; return { id: edge.id, width: isConnected ? 3 : 1, color: isConnected ? { color: connectedColor.color, highlight: connectedColor.highlight } : { color: dimmedColor, highlight: dimmedColor }, }; }); this._edgesDataSet.update(edgeUpdates); // Update nodes - make neighbors more prominent const allNodes = this._nodesDataSet.get(); const nodeUpdates = allNodes.map((node: NetworkGraphNode) => { const isNeighbor = neighborIds.has(node.id); const isSelected = node.id === nodeId; return { id: node.id, size: isSelected ? 40 : isNeighbor ? 35 : 25, font: { size: isSelected ? 14 : isNeighbor ? 13 : 11, color: this._getFontColor(), bold: isSelected || isNeighbor ? { color: this._getFontColor() } : undefined, }, opacity: isSelected || isNeighbor ? 1 : 0.5, }; }); this._nodesDataSet.update(nodeUpdates); } /** * Clears all highlights and restores default styling. */ protected _clearHighlights(): void { if (!this._edgesDataSet || !this._nodesDataSet) return; // Restore edge widths and original colors const allEdges = this._edgesDataSet.get(); const edgeUpdates = allEdges.map((edge: NetworkGraphEdge) => { const originalColor = this._originalEdgeColors.get(String(edge.id)); return { id: edge.id, width: 2, color: originalColor ?? { color: getCssVar("--graph-node-fallback", "#999999"), highlight: getCssVar("--graph-node-fallback", "#999999"), }, }; }); this._edgesDataSet.update(edgeUpdates); // Restore node sizes const allNodes = this._nodesDataSet.get(); const nodeUpdates = allNodes.map((node: NetworkGraphNode) => ({ id: node.id, size: 30, font: { size: 12, color: this._getFontColor(), bold: undefined, }, opacity: 1, })); this._nodesDataSet.update(nodeUpdates); } /** * Clear stored edge colors when graph is updated (edges are recreated). */ protected _clearOriginalEdgeColors(): void { this._originalEdgeColors.clear(); } /** * Abstract method to be implemented by subclasses for graph-specific updates. */ protected abstract _updateGraph(): void; static override styles = css` :host { display: block; width: 100%; height: 100%; min-height: 0; } .graph-container { width: 100%; height: 100%; background-color: var(--md-sys-color-surface, #fff); border-radius: 8px; border: 1px solid var(--md-sys-color-outline-variant, #ccc); } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--md-sys-color-on-surface-variant, #666); text-align: center; padding: 24px; box-sizing: border-box; background-color: var(--md-sys-color-surface, #fff); border-radius: 8px; border: 1px solid var(--md-sys-color-outline-variant, #ccc); } .empty-state p { margin: 8px 0; } .empty-state .hint { font-size: 0.875rem; opacity: 0.7; } `; }