From c4e391c2643806747fc0718af6cdb0df462e95c7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 19 May 2025 16:37:05 +0300 Subject: [PATCH] Echarts network graph for ZHA (#25457) * Echarts network graph for ZHA * improve layout * better diff * remove vis-network * not bad layout * fix LQI and clean up a bit * Use ha-chart-base and remove header * legend * use color vars * use colorVariables * fix * add physics toggle * tweak lines * remove vis-network * minor tweaks * dynamically load graph chart * type fix * fix height * navigate to device page on label click * PR comments * aria tweak * make extraComponents non reactive * PR comments * quick fix * just make hass non reactive * button tweak * Update src/components/chart/ha-network-graph.ts Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com> --------- Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com> --- package.json | 1 - src/components/chart/ha-chart-base.ts | 131 ++-- src/components/chart/ha-network-graph.ts | 278 +++++++ .../chart/state-history-chart-line.ts | 1 + src/components/chart/statistics-chart.ts | 1 + .../zha/zha-network-visualization-page.ts | 694 +++++++----------- .../hui-energy-devices-detail-graph-card.ts | 4 +- src/resources/echarts.ts | 2 + src/resources/theme/color.globals.ts | 1 + src/translations/en.json | 9 +- src/types/echarts.d.ts | 3 + yarn.lock | 15 - 12 files changed, 641 insertions(+), 499 deletions(-) create mode 100644 src/components/chart/ha-network-graph.ts create mode 100644 src/types/echarts.d.ts diff --git a/package.json b/package.json index a6fc187da5..b851f202fd 100644 --- a/package.json +++ b/package.json @@ -137,7 +137,6 @@ "tinykeys": "3.0.0", "ua-parser-js": "2.0.3", "vis-data": "7.1.9", - "vis-network": "9.1.9", "vue": "2.7.16", "vue2-daterange-picker": "0.6.8", "weekstart": "2.0.0", diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index b03f88847c..0703d66eb9 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -48,7 +48,8 @@ export class HaChartBase extends LitElement { @property({ attribute: "expand-legend", type: Boolean }) public expandLegend?: boolean; - @property({ attribute: false }) public extraComponents?: any[]; + // extraComponents is not reactive and should not trigger updates + public extraComponents?: any[]; @state() @consume({ context: themesContext, subscribe: true }) @@ -106,48 +107,49 @@ export class HaChartBase extends LitElement { }) ); - // Add keyboard event listeners - const handleKeyDown = (ev: KeyboardEvent) => { - if ( - !this._modifierPressed && - ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) - ) { - this._modifierPressed = true; - if (!this.options?.dataZoom) { - this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + if (!this.options?.dataZoom) { + // Add keyboard event listeners + const handleKeyDown = (ev: KeyboardEvent) => { + if ( + !this._modifierPressed && + ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) + ) { + this._modifierPressed = true; + if (!this.options?.dataZoom) { + this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + } + // drag to zoom + this.chart?.dispatchAction({ + type: "takeGlobalCursor", + key: "dataZoomSelect", + dataZoomSelectActive: true, + }); } - // drag to zoom - this.chart?.dispatchAction({ - type: "takeGlobalCursor", - key: "dataZoomSelect", - dataZoomSelectActive: true, - }); - } - }; + }; - const handleKeyUp = (ev: KeyboardEvent) => { - if ( - this._modifierPressed && - ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) - ) { - this._modifierPressed = false; - if (!this.options?.dataZoom) { - this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + const handleKeyUp = (ev: KeyboardEvent) => { + if ( + this._modifierPressed && + ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) + ) { + this._modifierPressed = false; + if (!this.options?.dataZoom) { + this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + } + this.chart?.dispatchAction({ + type: "takeGlobalCursor", + key: "dataZoomSelect", + dataZoomSelectActive: false, + }); } - this.chart?.dispatchAction({ - type: "takeGlobalCursor", - key: "dataZoomSelect", - dataZoomSelectActive: false, - }); - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - this._listeners.push( - () => window.removeEventListener("keydown", handleKeyDown), - () => window.removeEventListener("keyup", handleKeyUp) - ); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + this._listeners.push( + () => window.removeEventListener("keydown", handleKeyDown), + () => window.removeEventListener("keyup", handleKeyUp) + ); + } } protected firstUpdated() { @@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
${this._renderLegend()} - ${this._isZoomed - ? html`` - : nothing} +
+ ${this._isZoomed + ? html`` + : nothing} + +
`; } @@ -210,7 +215,7 @@ export class HaChartBase extends LitElement { return nothing; } const legend = ensureArray(this.options.legend)[0] as LegendComponentOption; - if (!legend.show) { + if (!legend.show || legend.type !== "custom") { return nothing; } const datasets = ensureArray(this.data); @@ -315,7 +320,9 @@ export class HaChartBase extends LitElement { this.chart.on("click", (e: ECElementEvent) => { fireEvent(this, "chart-click", e); }); - this.chart.getZr().on("dblclick", this._handleClickZoom); + if (!this.options?.dataZoom) { + this.chart.getZr().on("dblclick", this._handleClickZoom); + } if (this._isTouchDevice) { this.chart.getZr().on("click", (e: ECElementEvent) => { if (!e.zrByTouch) { @@ -410,6 +417,12 @@ export class HaChartBase extends LitElement { } as XAXisOption; }); } + let legend = this.options?.legend; + if (legend) { + legend = ensureArray(legend).map((l) => + l.type === "custom" ? { show: false } : l + ); + } const options = { animation: !this._reducedMotion, darkMode: this._themes.darkMode ?? false, @@ -424,7 +437,7 @@ export class HaChartBase extends LitElement { iconStyle: { opacity: 0 }, }, ...this.options, - legend: { show: false }, + legend, xAxis, }; @@ -725,16 +738,26 @@ export class HaChartBase extends LitElement { height: 100%; width: 100%; } - .zoom-reset { + .chart-controls { position: absolute; top: 16px; right: 4px; + display: flex; + flex-direction: column; + gap: 4px; + } + .chart-controls ha-icon-button, + .chart-controls ::slotted(ha-icon-button) { background: var(--card-background-color); border-radius: 4px; --mdc-icon-button-size: 32px; color: var(--primary-color); border: 1px solid var(--divider-color); } + .chart-controls ha-icon-button.inactive, + .chart-controls ::slotted(ha-icon-button.inactive) { + color: var(--state-inactive-color); + } .chart-legend { max-height: 60%; overflow-y: auto; diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts new file mode 100644 index 0000000000..db4463ec41 --- /dev/null +++ b/src/components/chart/ha-network-graph.ts @@ -0,0 +1,278 @@ +import type { EChartsType } from "echarts/core"; +import type { GraphSeriesOption } from "echarts/charts"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state, query } from "lit/decorators"; +import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; +import { mdiGoogleCirclesGroup } from "@mdi/js"; +import memoizeOne from "memoize-one"; +import { listenMediaQuery } from "../../common/dom/media_query"; +import type { ECOption } from "../../resources/echarts"; +import "./ha-chart-base"; +import type { HaChartBase } from "./ha-chart-base"; +import type { HomeAssistant } from "../../types"; + +export interface NetworkNode { + id: string; + name?: string; + category?: number; + label?: string; + value?: number; + symbolSize?: number; + symbol?: string; + itemStyle?: { + color?: string; + borderColor?: string; + borderWidth?: number; + }; + fixed?: boolean; + /** + * Distance from the center, where 0 is the center and 1 is the edge + */ + polarDistance?: number; +} + +export interface NetworkLink { + source: string; + target: string; + value?: number; + reverseValue?: number; + lineStyle?: { + width?: number; + color?: string; + type?: "solid" | "dashed" | "dotted"; + }; + symbolSize?: number | number[]; + label?: { + show?: boolean; + formatter?: string; + }; + ignoreForceLayout?: boolean; +} + +export interface NetworkData { + nodes: NetworkNode[]; + links: NetworkLink[]; + categories?: { name: string }[]; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports +let GraphChart: typeof import("echarts/lib/chart/graph/install"); + +@customElement("ha-network-graph") +export class HaNetworkGraph extends LitElement { + public chart?: EChartsType; + + @property({ attribute: false }) public data?: NetworkData; + + @property({ attribute: false }) public tooltipFormatter?: ( + params: TopLevelFormatterParams + ) => string; + + public hass!: HomeAssistant; + + @state() private _reducedMotion = false; + + @state() private _physicsEnabled = true; + + private _listeners: (() => void)[] = []; + + private _nodePositions: Record = {}; + + @query("ha-chart-base") private _baseChart?: HaChartBase; + + constructor() { + super(); + if (!GraphChart) { + import("echarts/lib/chart/graph/install").then((module) => { + GraphChart = module; + this.requestUpdate(); + }); + } + } + + public async connectedCallback() { + super.connectedCallback(); + this._listeners.push( + listenMediaQuery("(prefers-reduced-motion)", (matches) => { + if (this._reducedMotion !== matches) { + this._reducedMotion = matches; + } + }) + ); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + while (this._listeners.length) { + this._listeners.pop()!(); + } + } + + protected render() { + if (!GraphChart) { + return nothing; + } + return html` + + + `; + } + + private _createOptions = memoizeOne( + (categories?: NetworkData["categories"]): ECOption => ({ + tooltip: { + trigger: "item", + confine: true, + formatter: this.tooltipFormatter, + }, + legend: { + show: !!categories?.length, + data: categories, + top: 8, + }, + dataZoom: { + type: "inside", + filterMode: "none", + }, + }) + ); + + private _getSeries = memoizeOne( + (data?: NetworkData, physicsEnabled?: boolean, reducedMotion?: boolean) => { + if (!data) { + return []; + } + + const containerWidth = this.clientWidth; + const containerHeight = this.clientHeight; + return [ + { + id: "network", + type: "graph", + layout: physicsEnabled ? "force" : "none", + draggable: true, + roam: true, + selectedMode: "single", + label: { + show: true, + position: "right", + }, + emphasis: { + focus: "adjacency", + }, + force: { + repulsion: [400, 600], + edgeLength: [200, 300], + gravity: 0.1, + layoutAnimation: !reducedMotion, + }, + edgeSymbol: ["none", "arrow"], + edgeSymbolSize: 10, + data: data.nodes.map((node) => { + const echartsNode: NonNullable[number] = + { + id: node.id, + name: node.name, + category: node.category, + value: node.value, + symbolSize: node.symbolSize || 30, + symbol: node.symbol || "circle", + itemStyle: node.itemStyle || {}, + fixed: node.fixed, + }; + if (this._nodePositions[node.id]) { + echartsNode.x = this._nodePositions[node.id].x; + echartsNode.y = this._nodePositions[node.id].y; + } else if (typeof node.polarDistance === "number") { + // set the position of the node at polarDistance from the center in a random direction + const angle = Math.random() * 2 * Math.PI; + echartsNode.x = + containerWidth / 2 + + ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance; + echartsNode.y = + containerHeight / 2 + + ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance; + this._nodePositions[node.id] = { + x: echartsNode.x, + y: echartsNode.y, + }; + } + return echartsNode; + }), + links: data.links.map((link) => ({ + ...link, + value: link.reverseValue + ? Math.max(link.value ?? 0, link.reverseValue) + : link.value, + // remove arrow for bidirectional links + symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work + })), + categories: data.categories || [], + }, + ] as any; + } + ); + + private _togglePhysics() { + if (this._baseChart?.chart) { + this._baseChart.chart + // @ts-ignore private method but no other way to get the graph positions + .getModel() + .getSeriesByIndex(0) + .getGraph() + .eachNode((node: any) => { + const layout = node.getLayout(); + if (layout) { + this._nodePositions[node.id] = { + x: layout[0], + y: layout[1], + }; + } + }); + } + this._physicsEnabled = !this._physicsEnabled; + } + + static styles = css` + :host { + display: block; + position: relative; + } + ha-chart-base { + height: 100%; + --chart-max-height: 100%; + } + + ha-icon-button, + ::slotted(ha-icon-button) { + margin-right: 12px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-network-graph": HaNetworkGraph; + } + interface HASSDomEvents { + "node-selected": { id: string }; + } +} diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 2ad664e627..5e11621b60 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement { }, } as YAXisOption, legend: { + type: "custom", show: this.showNames, }, grid: { diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 57b66dac25..ea8469653a 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement { }, }, legend: { + type: "custom", show: !this.hideLegend, data: this._legendData, }, diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts index f14e8f93b7..0ef102c6a0 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -1,27 +1,26 @@ import "@material/mwc-button"; import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network"; -import { Network } from "vis-network/peer/esm/vis-network"; -import { navigate } from "../../../../../common/navigate"; -import "../../../../../components/search-input"; -import "../../../../../components/device/ha-device-picker"; -import "../../../../../components/ha-button-menu"; -import "../../../../../components/ha-checkbox"; -import type { HaCheckbox } from "../../../../../components/ha-checkbox"; -import "../../../../../components/ha-formfield"; -import type { DeviceRegistryEntry } from "../../../../../data/device_registry"; +import { customElement, property, state } from "lit/decorators"; +import type { + CallbackDataParams, + TopLevelFormatterParams, +} from "echarts/types/dist/shared"; +import { mdiRefresh } from "@mdi/js"; +import "../../../../../components/chart/ha-network-graph"; +import type { + NetworkData, + NetworkNode, + NetworkLink, +} from "../../../../../components/chart/ha-network-graph"; import type { ZHADevice } from "../../../../../data/zha"; import { fetchDevices, refreshTopology } from "../../../../../data/zha"; import "../../../../../layouts/hass-tabs-subpage"; -import type { - ValueChangedEvent, - HomeAssistant, - Route, -} from "../../../../../types"; +import type { HomeAssistant, Route } from "../../../../../types"; import { formatAsPaddedHex } from "./functions"; import { zhaTabs } from "./zha-config-dashboard"; +import { colorVariables } from "../../../../../resources/theme/color.globals"; +import { navigate } from "../../../../../common/navigate"; @customElement("zha-network-visualization-page") export class ZHANetworkVisualizationPage extends LitElement { @@ -33,103 +32,18 @@ export class ZHANetworkVisualizationPage extends LitElement { @property({ attribute: "is-wide", type: Boolean }) public isWide = false; - @property({ attribute: false }) - public zoomedDeviceIdFromURL?: string; + @state() + private _networkData?: NetworkData; @state() - private zoomedDeviceId?: string; - - @query("#visualization", true) - private _visualization?: HTMLElement; - - @state() - private _devices = new Map(); - - @state() - private _devicesByDeviceId = new Map(); - - @state() - private _nodes: Node[] = []; - - @state() - private _network?: Network; - - @state() - private _filter?: string; - - private _autoZoom = true; - - private _enablePhysics = true; + private _devices: ZHADevice[] = []; protected firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); - // prevent zoomedDeviceIdFromURL from being restored to zoomedDeviceId after the user clears it - if (this.zoomedDeviceIdFromURL) { - this.zoomedDeviceId = this.zoomedDeviceIdFromURL; - } - if (this.hass) { this._fetchData(); } - - this._network = new Network( - this._visualization!, - {}, - { - autoResize: true, - layout: { - improvedLayout: true, - }, - physics: { - barnesHut: { - springConstant: 0, - avoidOverlap: 10, - damping: 0.09, - }, - }, - nodes: { - font: { - multi: "html", - }, - }, - edges: { - smooth: { - enabled: true, - type: "continuous", - forceDirection: "none", - roundness: 0.6, - }, - }, - } - ); - - this._network.on("doubleClick", (properties) => { - const ieee = properties.nodes[0]; - if (ieee) { - const device = this._devices.get(ieee); - if (device) { - navigate(`/config/devices/device/${device.device_reg_id}`); - } - } - }); - - this._network.on("click", (properties) => { - const ieee = properties.nodes[0]; - if (ieee) { - const device = this._devices.get(ieee); - if (device && this._autoZoom) { - this.zoomedDeviceId = device.device_reg_id; - this._zoomToDevice(); - } - } - }); - - this._network.on("stabilized", () => { - if (this.zoomedDeviceId) { - this._zoomToDevice(); - } - }); } protected render() { @@ -144,359 +58,297 @@ export class ZHANetworkVisualizationPage extends LitElement { "ui.panel.config.zha.visualization.header" )} > - ${this.narrow - ? html` -
- - -
- ` - : ""} -
- ${!this.narrow - ? html`` - : ""} - + -
- - - - - - - - ${this.hass!.localize( - "ui.panel.config.zha.visualization.refresh_topology" - )} - -
-
-
+ > + `; } private async _fetchData() { - const devices = await fetchDevices(this.hass!); - this._devices = new Map( - devices.map((device: ZHADevice) => [device.ieee, device]) - ); - this._devicesByDeviceId = new Map( - devices.map((device: ZHADevice) => [device.device_reg_id, device]) - ); - this._updateDevices(devices); + this._devices = await fetchDevices(this.hass!); + this._networkData = this._createChartData(this._devices); } - private _updateDevices(devices: ZHADevice[]) { - this._nodes = []; - const edges: Edge[] = []; + private _tooltipFormatter = (params: TopLevelFormatterParams): string => { + const { dataType, data, name } = params as CallbackDataParams; + if (dataType === "edge") { + const { source, target, value } = data as any; + const targetName = this._networkData!.nodes.find( + (node) => node.id === target + )!.name; + const sourceName = this._networkData!.nodes.find( + (node) => node.id === source + )!.name; + const tooltipText = `${sourceName} → ${targetName}${value ? ` LQI: ${value}` : ""}`; - devices.forEach((device) => { - this._nodes.push({ - id: device.ieee, - label: this._buildLabel(device), - shape: this._getShape(device), - mass: this._getMass(device), - fixed: device.device_type === "Coordinator", - color: { - background: device.available ? "#66FF99" : "#FF9999", - }, - }); - - if (device.neighbors && device.neighbors.length > 0) { - device.neighbors.forEach((neighbor) => { - const idx = edges.findIndex( - (e) => device.ieee === e.to && neighbor.ieee === e.from - ); - if (idx === -1) { - const edge_options = this._getEdgeOptions(parseInt(neighbor.lqi)); - edges.push({ - from: device.ieee, - to: neighbor.ieee, - label: neighbor.lqi + "", - color: edge_options.color, - width: edge_options.width, - length: edge_options.length, - physics: edge_options.physics, - arrows: { - from: { - enabled: neighbor.relationship !== "Child", - }, - }, - dashes: neighbor.relationship !== "Child", - }); - } else { - const edge_options = this._getEdgeOptions( - Math.min(parseInt(edges[idx].label!), parseInt(neighbor.lqi)) - ); - edges[idx].label += " & " + neighbor.lqi; - edges[idx].color = edge_options.color; - edges[idx].width = edge_options.width; - edges[idx].length = edge_options.length; - edges[idx].physics = edge_options.physics; - delete edges[idx].arrows; - delete edges[idx].dashes; - } - }); + const reverseValue = this._networkData!.links.find( + (link) => link.source === source && link.target === target + )?.reverseValue; + if (reverseValue) { + return `${tooltipText}
${targetName} → ${sourceName} LQI: ${reverseValue}`; } - }); - - this._network?.setData({ nodes: this._nodes, edges: edges }); - } - - private _getEdgeOptions(lqi: number): EdgeOptions { - const length = 2000 - 4 * lqi; - if (lqi > 192) { - return { - color: { color: "#17ab00", highlight: "#17ab00" }, - width: lqi / 20, - length: length, - physics: false, - }; + return tooltipText; } - if (lqi > 128) { - return { - color: { color: "#e6b402", highlight: "#e6b402" }, - width: 9, - length: length, - physics: false, - }; + const device = this._devices.find((d) => d.ieee === (data as any).id); + if (!device) { + return name; } - return { - color: { color: "#bfbfbf", highlight: "#bfbfbf" }, - width: 1, - length: length, - physics: false, - }; - } - - private _getMass(device: ZHADevice): number { - if (!device.available) { - return 6; - } - if (device.device_type === "Coordinator") { - return 2; - } - if (device.device_type === "Router") { - return 4; - } - return 5; - } - - private _getShape(device: ZHADevice): string { - if (device.device_type === "Coordinator") { - return "box"; - } - if (device.device_type === "Router") { - return "ellipse"; - } - return "circle"; - } - - private _buildLabel(device: ZHADevice): string { - let label = - device.user_given_name !== null - ? `${device.user_given_name}\n` - : ""; - label += `IEEE: ${device.ieee}`; - label += `\nDevice Type: ${device.device_type.replace("_", " ")}`; + let label = `IEEE: ${device.ieee}`; + label += `
Device Type: ${device.device_type.replace("_", " ")}`; if (device.nwk != null) { - label += `\nNWK: ${formatAsPaddedHex(device.nwk)}`; + label += `
NWK: ${formatAsPaddedHex(device.nwk)}`; } if (device.manufacturer != null && device.model != null) { - label += `\nDevice: ${device.manufacturer} ${device.model}`; + label += `
Device: ${device.manufacturer} ${device.model}`; } else { - label += "\nDevice is not in 'zigbee.db'"; + label += "
Device is not in 'zigbee.db'"; } if (device.area_id) { - label += `\nArea ID: ${device.area_id}`; - } - return label; - } - - private _handleSearchChange(ev: CustomEvent) { - this._filter = ev.detail.value; - const filterText = this._filter!.toLowerCase(); - if (!this._network) { - return; - } - if (this._filter) { - const filteredNodeIds: (string | number)[] = []; - this._nodes.forEach((node) => { - if (node.label && node.label.toLowerCase().includes(filterText)) { - filteredNodeIds.push(node.id!); - } - }); - this.zoomedDeviceId = ""; - this._zoomOut(); - this._network.selectNodes(filteredNodeIds, true); - } else { - this._network.unselectAll(); - } - } - - private _onZoomToDevice(event: ValueChangedEvent) { - event.stopPropagation(); - this.zoomedDeviceId = event.detail.value; - if (!this._network) { - return; - } - this._zoomToDevice(); - } - - private _zoomToDevice() { - this._filter = ""; - if (!this.zoomedDeviceId) { - this._zoomOut(); - } else { - const device: ZHADevice | undefined = this._devicesByDeviceId.get( - this.zoomedDeviceId - ); - if (device) { - this._network!.fit({ - nodes: [device.ieee], - animation: { duration: 500, easingFunction: "easeInQuad" }, - }); + const area = this.hass.areas[device.area_id]; + if (area) { + label += `
Area: ${area.name}`; } } - } - - private _zoomOut() { - this._network!.fit({ - nodes: [], - animation: { duration: 500, easingFunction: "easeOutQuad" }, - }); - } + return label; + }; private async _refreshTopology(): Promise { await refreshTopology(this.hass); + await this._fetchData(); } - private _filterDevices = (device: DeviceRegistryEntry): boolean => { - if (!this.hass) { - return false; - } - for (const parts of device.identifiers) { - for (const part of parts) { - if (part === "zha") { - return true; - } + private _handleChartClick(e: CustomEvent): void { + if ( + e.detail.dataType === "node" && + e.detail.event.target.cursor === "pointer" + ) { + const { id } = e.detail.data; + const device = this._devices.find((d) => d.ieee === id); + if (device) { + navigate(`/config/devices/device/${device.device_reg_id}`); } } - return false; - }; - - private _handleAutoZoomCheckboxChange(ev: Event) { - this._autoZoom = (ev.target as HaCheckbox).checked; - } - - private _handlePhysicsCheckboxChange(ev: Event) { - this._enablePhysics = (ev.target as HaCheckbox).checked; - - this._network!.setOptions( - this._enablePhysics - ? { physics: { enabled: true } } - : { physics: { enabled: false } } - ); } static get styles(): CSSResultGroup { return [ css` - .header { - border-bottom: 1px solid var(--divider-color); - padding: 0 8px; - display: flex; - align-items: center; - justify-content: space-between; - height: var(--header-height); - box-sizing: border-box; - } - - .header > * { - padding: 0 8px; - } - - :host([narrow]) .header { - flex-direction: column; - align-items: stretch; - height: var(--header-height) * 2; - } - - .search-toolbar { - display: flex; - align-items: center; - color: var(--secondary-text-color); - padding: 0 16px; - } - - search-input { - flex: 1; - display: block; - } - - search-input.header { - color: var(--secondary-text-color); - } - - ha-device-picker { - flex: 1; - } - - .controls { - display: flex; - align-items: center; - justify-content: space-between; - } - - #visualization { - height: calc(100% - var(--header-height)); - width: 100%; - } - :host([narrow]) #visualization { - height: calc(100% - (var(--header-height) * 2)); + ha-network-graph { + height: 100%; } `, ]; } + + private _createChartData(devices: ZHADevice[]): NetworkData { + const primaryColor = colorVariables["primary-color"]; + const routerColor = colorVariables["cyan-color"]; + const endDeviceColor = colorVariables["teal-color"]; + const offlineColor = colorVariables["error-color"]; + const nodes: NetworkNode[] = []; + const links: NetworkLink[] = []; + const categories = [ + { + name: "Coordinator", + icon: "roundRect", + itemStyle: { color: primaryColor }, + }, + { name: "Router", icon: "circle", itemStyle: { color: routerColor } }, + { + name: "End Device", + icon: "circle", + itemStyle: { color: endDeviceColor }, + }, + { name: "Offline", icon: "circle", itemStyle: { color: offlineColor } }, + ]; + + // Create all the nodes and links + devices.forEach((device) => { + const isCoordinator = device.device_type === "Coordinator"; + let category: number; + if (!device.available) { + category = 3; // Offline + } else if (isCoordinator) { + category = 0; + } else if (device.device_type === "Router") { + category = 1; + } else { + category = 2; // End Device + } + + // Create node + nodes.push({ + id: device.ieee, + name: device.user_given_name || device.name || device.ieee, + category, + value: isCoordinator ? 3 : device.device_type === "Router" ? 2 : 1, + symbolSize: isCoordinator + ? 40 + : device.device_type === "Router" + ? 30 + : 20, + symbol: isCoordinator ? "roundRect" : "circle", + itemStyle: { + color: device.available + ? isCoordinator + ? primaryColor + : device.device_type === "Router" + ? routerColor + : endDeviceColor + : offlineColor, + }, + polarDistance: category === 0 ? 0 : category === 1 ? 0.5 : 0.9, + }); + + // Create links (edges) + const existingLinks = links.filter( + (link) => link.source === device.ieee || link.target === device.ieee + ); + if (device.routes && device.routes.length > 0) { + device.routes.forEach((route) => { + const neighbor = device.neighbors.find( + (n) => n.nwk === route.next_hop + ); + if (!neighbor) { + return; + } + const existingLink = existingLinks.find( + (link) => + link.source === neighbor.ieee || link.target === neighbor.ieee + ); + + if (existingLink) { + if (existingLink.source === device.ieee) { + existingLink.value = Math.max( + existingLink.value!, + parseInt(neighbor.lqi) + ); + } else { + existingLink.reverseValue = Math.max( + existingLink.reverseValue ?? 0, + parseInt(neighbor.lqi) + ); + } + const width = this._getLQIWidth(parseInt(neighbor.lqi)); + existingLink.symbolSize = (width / 4) * 6 + 3; // range 3-9 + existingLink.lineStyle = { + ...existingLink.lineStyle, + width, + color: + route.route_status === "Active" + ? primaryColor + : existingLink.lineStyle!.color, + type: ["Child", "Parent"].includes(neighbor.relationship) + ? "solid" + : existingLink.lineStyle!.type, + }; + } else { + // Create a new link + const width = this._getLQIWidth(parseInt(neighbor.lqi)); + const link: NetworkLink = { + source: device.ieee, + target: neighbor.ieee, + value: parseInt(neighbor.lqi), + lineStyle: { + width, + color: + route.route_status === "Active" + ? primaryColor + : colorVariables["disabled-color"], + type: ["Child", "Parent"].includes(neighbor.relationship) + ? "solid" + : "dotted", + }, + symbolSize: (width / 4) * 6 + 3, // range 3-9 + // By default, all links should be ignored for force layout + ignoreForceLayout: true, + }; + links.push(link); + existingLinks.push(link); + } + }); + } else if (existingLinks.length === 0) { + // If there are no links, create a link to the closest neighbor + const neighbors: { ieee: string; lqi: string }[] = + device.neighbors ?? []; + if (neighbors.length === 0) { + // If there are no neighbors, look for links from other devices + devices.forEach((d) => { + if (d.neighbors && d.neighbors.length > 0) { + const neighbor = d.neighbors.find((n) => n.ieee === device.ieee); + if (neighbor) { + neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi }); + } + } + }); + } + const closestNeighbor = neighbors.sort( + (a, b) => parseInt(b.lqi) - parseInt(a.lqi) + )[0]; + if (closestNeighbor) { + links.push({ + source: device.ieee, + target: closestNeighbor.ieee, + value: parseInt(closestNeighbor.lqi), + symbolSize: 5, + lineStyle: { + width: 1, + color: colorVariables["disabled-color"], + type: "dotted", + }, + ignoreForceLayout: true, + }); + } + } + }); + + // Now set ignoreForceLayout to false for the strongest connection of each device + // Except for the coordinator which can have multiple strong connections + devices.forEach((device) => { + if (device.device_type === "Coordinator") { + links.forEach((link) => { + if (link.source === device.ieee || link.target === device.ieee) { + link.ignoreForceLayout = false; + } + }); + } else { + // Find the link that corresponds to this strongest connection + let strongestLink: NetworkLink | undefined; + links.forEach((link) => { + if ( + (link.source === device.ieee || link.target === device.ieee) && + link.value! > (strongestLink?.value ?? 0) + ) { + strongestLink = link; + } + }); + + if (strongestLink) { + strongestLink.ignoreForceLayout = false; + } + } + }); + + return { nodes, links, categories }; + } + + private _getLQIWidth(lqi: number): number { + return Math.max(1, Math.floor((lqi / 256) * 4)); + } } declare global { diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts index 46d39042ab..c3d9c5cc23 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-detail-graph-card.ts @@ -184,13 +184,11 @@ export class HuiEnergyDevicesDetailGraphCard ...commonOptions, legend: { show: true, - type: "scroll", - animationDurationUpdate: 400, + type: "custom", selected: this._hiddenStats.reduce((acc, stat) => { acc[stat] = false; return acc; }, {}), - icon: "circle", }, grid: { top: 15, diff --git a/src/resources/echarts.ts b/src/resources/echarts.ts index 2f5c27e483..cd0720352a 100644 --- a/src/resources/echarts.ts +++ b/src/resources/echarts.ts @@ -29,6 +29,7 @@ import type { LineSeriesOption, CustomSeriesOption, SankeySeriesOption, + GraphSeriesOption, } from "echarts/charts"; import type { // The component option types are defined with the ComponentOption suffix @@ -53,6 +54,7 @@ export type ECOption = ComposeOption< | DataZoomComponentOption | VisualMapComponentOption | SankeySeriesOption + | GraphSeriesOption >; // Register the required components diff --git a/src/resources/theme/color.globals.ts b/src/resources/theme/color.globals.ts index e2ef9db20e..e80932117e 100644 --- a/src/resources/theme/color.globals.ts +++ b/src/resources/theme/color.globals.ts @@ -355,6 +355,7 @@ const darkColorStyles = css` } `; export const colorDerivedVariables = extractDerivedVars(colorStyles); +export const colorVariables = extractVars(colorStyles); export const darkColorVariables = extractVars(darkColorStyles); export const DefaultPrimaryColor = extractVar(colorStyles, "primary-color"); diff --git a/src/translations/en.json b/src/translations/en.json index 59dcef401f..8d055f8420 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2113,7 +2113,10 @@ "learn_more": "Learn more", "show_url": "Show full URL", "hide_url": "Hide URL", - "copy_link": "Copy link" + "copy_link": "Copy link", + "graph": { + "toggle_physics": "Toggle physics" + } }, "updates": { "caption": "Updates", @@ -5696,10 +5699,6 @@ "visualization": { "header": "Network visualization", "caption": "Visualization", - "highlight_label": "Highlight devices", - "zoom_label": "Zoom to device", - "auto_zoom": "Auto zoom", - "enable_physics": "Enable physics", "refresh_topology": "Refresh topology" }, "device_binding": { diff --git a/src/types/echarts.d.ts b/src/types/echarts.d.ts new file mode 100644 index 0000000000..408e810496 --- /dev/null +++ b/src/types/echarts.d.ts @@ -0,0 +1,3 @@ +declare module "echarts/lib/chart/graph/install" { + export const install: EChartsExtensionInstaller; +} diff --git a/yarn.lock b/yarn.lock index 6938707c63..e51b8dea3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9545,7 +9545,6 @@ __metadata: typescript-eslint: "npm:8.32.1" ua-parser-js: "npm:2.0.3" vis-data: "npm:7.1.9" - vis-network: "npm:9.1.9" vite-tsconfig-paths: "npm:5.1.4" vitest: "npm:3.1.3" vue: "npm:2.7.16" @@ -15123,20 +15122,6 @@ __metadata: languageName: node linkType: hard -"vis-network@npm:9.1.9": - version: 9.1.9 - resolution: "vis-network@npm:9.1.9" - peerDependencies: - "@egjs/hammerjs": ^2.0.0 - component-emitter: ^1.3.0 - keycharm: ^0.2.0 || ^0.3.0 || ^0.4.0 - uuid: ^3.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 - vis-data: ^6.3.0 || ^7.0.0 - vis-util: ^5.0.1 - checksum: 10/929b2645ff62645d030e6a03f2f618d0e66c9a83782c4f6f3257160d48ea49fc06eb62403762b010eb07d137bee72a1bd62e5c58ebbfb9091dbb3ca1f8f1887d - languageName: node - linkType: hard - "vite-node@npm:3.1.3": version: 3.1.3 resolution: "vite-node@npm:3.1.3"