// Lazy-loaded weathermap viewer (Cytoscape.js with utilization-based // edge coloring). Pulls cytoscape into a shared chunk with NetworkMap. import cytoscape from "../../vendor/cytoscape" export const WeathermapViewer = { cy: null as any, _topologyData: null as any, _zoomInHandler: null as ((e: Event) => void) | null, _zoomOutHandler: null as ((e: Event) => void) | null, _fitHandler: null as ((e: Event) => void) | null, mounted(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this._topologyData = topologyData this.initializeCytoscape(topologyData) this.handleEvent("update_weathermap", (payload: any) => { if (this.cy) { this._topologyData = payload.topology this.updateTopology(payload.topology) } }) this.handleEvent("apply_filter", (payload: any) => { if (!this.cy) return this._applyFilter(payload.filter) }) this.handleEvent("search_nodes", (payload: any) => { if (!this.cy) return this._searchNodes(payload.query) }) this.handleEvent("change_layout", (payload: any) => { if (!this.cy || !this._topologyData) return this._changeLayout(payload.mode, this._topologyData) }) }, updated(this: any) { const topologyData = JSON.parse(this.el.dataset.topology || '{}') this.updateTopology(topologyData) }, destroyed(this: any) { const zoomIn = document.getElementById('cy-zoom-in') const zoomOut = document.getElementById('cy-zoom-out') const fit = document.getElementById('cy-fit') if (zoomIn && this._zoomInHandler) zoomIn.removeEventListener('click', this._zoomInHandler) if (zoomOut && this._zoomOutHandler) zoomOut.removeEventListener('click', this._zoomOutHandler) if (fit && this._fitHandler) fit.removeEventListener('click', this._fitHandler) this._zoomInHandler = null this._zoomOutHandler = null this._fitHandler = null if (this.cy) { try { this.cy.stop() } catch (_e) { /* ignore */ } this.cy.destroy() this.cy = null } }, initializeCytoscape(this: any, topology: any) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' const roleColors: Record = { router: '#3B82F6', switch: '#10B981', wireless: '#8B5CF6', firewall: '#EF4444', server: '#F59E0B', unknown: '#9CA3AF' } const roleShapes: Record = { router: 'round-rectangle', switch: 'diamond', wireless: 'triangle', firewall: 'hexagon', server: 'rectangle', unknown: 'ellipse' } const utilizationColors: Record = { low: '#10B981', medium: '#F59E0B', high: '#F97316', critical: '#EF4444' } const getWeathermapEdgeStyle = (edge: any) => { const utilizationLevel = edge.utilization_level const confidence = edge.confidence || 0.5 if (utilizationLevel && utilizationColors[utilizationLevel]) { return { color: utilizationColors[utilizationLevel], style: 'solid' as const, dashPattern: [] } } if (confidence > 0.9) return { color: '#9CA3AF', style: 'solid' as const, dashPattern: [] } if (confidence >= 0.5) return { color: '#9CA3AF', style: 'dashed' as const, dashPattern: [6, 3] } return { color: '#D1D5DB', style: 'dashed' as const, dashPattern: [2, 3] } } const computeSimplePositions = (nodes: any[]): Record => { const positions: Record = {} const SPACING = 120 const cols = Math.ceil(Math.sqrt(nodes.filter(n => n.type !== 'site').length)) let x = 0, y = 0, col = 0 nodes.forEach((node) => { if (node.type === 'site') return positions[node.id] = { x: x * SPACING, y: y * SPACING } col++ if (col >= cols) { col = 0; x = 0; y++ } else { x++ } }) return positions } const presetPositions = computeSimplePositions(topology.nodes || []) const elements: any[] = [] topology.nodes?.forEach((node: any) => { if (node.type === 'site') return const data: any = { id: node.id, label: node.label, type: node.type, status: node.status, discovered: node.discovered, ip_address: node.ip_address, site_name: node.site_name, manufacturer: node.manufacturer, client_count: node.client_count || 0, signal_health: node.signal_health, has_alerts: node.has_alerts || false, device_role: node.device_role } elements.push({ data, classes: node.discovered ? 'discovered' : node.status }) }) topology.edges?.forEach((edge: any) => { let label = '' if (edge.utilization_text) { label = edge.utilization_text } else if (edge.source_interface && edge.target_interface) { label = `${edge.source_interface} ↔ ${edge.target_interface}` } elements.push({ data: { id: edge.id, source: edge.source, target: edge.target, source_interface: edge.source_interface, target_interface: edge.target_interface, link_type: edge.link_type, confidence: edge.confidence ?? 0.5, if_speed: edge.if_speed, utilization_level: edge.utilization_level, utilization_pct: edge.utilization_pct, utilization_text: edge.utilization_text, throughput_bps: edge.throughput_bps, capacity_bps: edge.capacity_bps, signal_health: edge.signal_health, snr: edge.snr, label } }) }) const getEdgeWidth = (speed: number | null | undefined): number => { if (!speed) return 2 if (speed >= 10_000_000_000) return 6 if (speed >= 1_000_000_000) return 4 if (speed >= 100_000_000) return 3 return 2 } this.cy = cytoscape({ container: this.el, elements, style: [ { selector: 'node', style: { 'background-color': function(ele: any) { return roleColors[ele.data('type')] || roleColors.unknown }, 'shape': function(ele: any) { return roleShapes[ele.data('type')] || roleShapes.unknown }, 'label': 'data(label)', 'color': isDark ? '#fff' : '#000', 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': 6, 'font-size': '13px', 'font-weight': '500', 'text-wrap': 'wrap', 'text-max-width': '140px', 'width': 44, 'height': 44, 'border-width': 2, 'border-color': function(ele: any) { const status = ele.data('status') if (status === 'down') return '#dc2626' if (status === 'unknown') return '#ca8a04' const type = ele.data('type') const darkerColors: Record = { router: '#2563EB', switch: '#059669', wireless: '#7C3AED', firewall: '#DC2626', server: '#D97706', unknown: '#6B7280' } return darkerColors[type] || darkerColors.unknown } } as any }, { selector: 'node[status = "down"]', style: { 'border-width': 3, 'border-color': '#dc2626', 'background-blacken': -0.3 } }, { selector: 'node.discovered', style: { 'border-style': 'dashed', 'border-width': 2, 'border-color': '#9ca3af', 'opacity': 0.6, 'width': 34, 'height': 34, 'font-size': '11px' } }, { selector: 'edge', style: { 'width': function(ele: any) { return getEdgeWidth(ele.data('if_speed')) }, 'line-color': function(ele: any) { return getWeathermapEdgeStyle(ele.data()).color }, 'line-style': function(ele: any) { return getWeathermapEdgeStyle(ele.data()).style }, 'line-dash-pattern': function(ele: any) { const pattern = getWeathermapEdgeStyle(ele.data()).dashPattern return pattern.length > 0 ? pattern : [1, 0] }, 'curve-style': 'bezier', 'label': 'data(label)', 'font-size': '9px', 'color': isDark ? '#d1d5db' : '#4b5563', 'text-background-color': isDark ? '#1f2937' : '#ffffff', 'text-background-opacity': 0.9, 'text-background-padding': '3px', 'text-rotation': 'autorotate', 'text-margin-y': -10 } as any } ], layout: { name: 'preset', positions: (node: any) => presetPositions[node.id()] || { x: 0, y: 0 }, fit: true, padding: 50 }, minZoom: 0.2, maxZoom: 3, wheelSensitivity: 1 }) this.cy.on('tap', 'node', (evt: any) => { const node = evt.target const nodeData = node.data() this.pushEvent("node_clicked", { node_id: nodeData.id, node_type: nodeData.discovered ? 'discovered' : 'managed' }) }) this.handleEvent("highlight_node", (payload: any) => { if (!this.cy) return this.cy.nodes().removeClass('highlighted') const node = this.cy.getElementById(payload.node_id) if (node.length > 0) node.addClass('highlighted') }) this.handleEvent("clear_highlight", () => { if (!this.cy) return this.cy.nodes().removeClass('highlighted') }) this.cy.on('mouseover', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() const srcNode = this.cy.getElementById(data.source) const tgtNode = this.cy.getElementById(data.target) const srcName = srcNode.data('label') || data.source const tgtName = tgtNode.data('label') || data.target let tooltip = `${srcName} ↔ ${tgtName}` if (data.utilization_text) { tooltip += `\n🚦 ${data.utilization_text}` if (data.utilization_pct != null) tooltip += ` (${data.utilization_pct}%)` } edge.style({ 'line-color': '#fbbf24', 'width': Math.max(getEdgeWidth(data.if_speed), 4), 'label': tooltip }) }) this.cy.on('mouseout', 'edge', (evt: any) => { const edge = evt.target const data = edge.data() const edgeStyle = getWeathermapEdgeStyle(data) edge.style({ 'line-color': edgeStyle.color, 'width': getEdgeWidth(data.if_speed), 'label': data.label || '' }) }) this._initControls() }, _initControls(this: any) { const ZOOM_STEP = 0.25 const zoomIn = document.getElementById('cy-zoom-in') const zoomOut = document.getElementById('cy-zoom-out') const fit = document.getElementById('cy-fit') if (zoomIn) { this._zoomInHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.min(this.cy.zoom() + ZOOM_STEP, this.cy.maxZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) } zoomIn.addEventListener('click', this._zoomInHandler) } if (zoomOut) { this._zoomOutHandler = () => { if (!this.cy) return this.cy.zoom({ level: Math.max(this.cy.zoom() - ZOOM_STEP, this.cy.minZoom()), renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 } }) } zoomOut.addEventListener('click', this._zoomOutHandler) } if (fit) { this._fitHandler = () => { if (!this.cy) return this.cy.fit(undefined, 50) } fit.addEventListener('click', this._fitHandler) } }, updateTopology(this: any, topology: any) { if (!this.cy) return this.cy.destroy() this.initializeCytoscape(topology) }, _applyFilter(this: any, filter: string) { if (!this.cy) return this.cy.elements().style('display', 'element') if (filter === 'high_util') { const matchEdges = this.cy.edges().filter((e: any) => { const level = e.data('utilization_level') return level === 'high' || level === 'critical' }) const connectedNodes = matchEdges.connectedNodes() const visible = matchEdges.union(connectedNodes) this.cy.elements().not(visible).style('display', 'none') } else if (filter === 'critical') { const matchEdges = this.cy.edges().filter((e: any) => { const level = e.data('utilization_level') return level === 'critical' }) const connectedNodes = matchEdges.connectedNodes() const visible = matchEdges.union(connectedNodes) this.cy.elements().not(visible).style('display', 'none') } }, _searchNodes(this: any, query: string) { if (!this.cy) return this.cy.nodes().removeClass('search-match') if (!query || query.trim() === '') return const q = query.toLowerCase() const matches = this.cy.nodes().filter((n: any) => { const d = n.data() return (d.label && d.label.toLowerCase().includes(q)) || (d.ip_address && d.ip_address.toLowerCase().includes(q)) || (d.site_name && d.site_name.toLowerCase().includes(q)) || (d.manufacturer && d.manufacturer.toLowerCase().includes(q)) }) matches.addClass('search-match') if (matches.length > 0) this.cy.fit(matches, 80) }, _changeLayout(this: any, _mode: string, _topology: any) { if (!this.cy) return this.cy.layout({ name: 'circle', animate: true, animationDuration: 500 }).run() } }