mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 03:36:44 +00:00
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>
This commit is contained in:
parent
4b72a6029c
commit
c4e391c264
@ -137,7 +137,6 @@
|
|||||||
"tinykeys": "3.0.0",
|
"tinykeys": "3.0.0",
|
||||||
"ua-parser-js": "2.0.3",
|
"ua-parser-js": "2.0.3",
|
||||||
"vis-data": "7.1.9",
|
"vis-data": "7.1.9",
|
||||||
"vis-network": "9.1.9",
|
|
||||||
"vue": "2.7.16",
|
"vue": "2.7.16",
|
||||||
"vue2-daterange-picker": "0.6.8",
|
"vue2-daterange-picker": "0.6.8",
|
||||||
"weekstart": "2.0.0",
|
"weekstart": "2.0.0",
|
||||||
|
@ -48,7 +48,8 @@ export class HaChartBase extends LitElement {
|
|||||||
@property({ attribute: "expand-legend", type: Boolean })
|
@property({ attribute: "expand-legend", type: Boolean })
|
||||||
public expandLegend?: boolean;
|
public expandLegend?: boolean;
|
||||||
|
|
||||||
@property({ attribute: false }) public extraComponents?: any[];
|
// extraComponents is not reactive and should not trigger updates
|
||||||
|
public extraComponents?: any[];
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@consume({ context: themesContext, subscribe: true })
|
@consume({ context: themesContext, subscribe: true })
|
||||||
@ -106,48 +107,49 @@ export class HaChartBase extends LitElement {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add keyboard event listeners
|
if (!this.options?.dataZoom) {
|
||||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
// Add keyboard event listeners
|
||||||
if (
|
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||||
!this._modifierPressed &&
|
if (
|
||||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
!this._modifierPressed &&
|
||||||
) {
|
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||||
this._modifierPressed = true;
|
) {
|
||||||
if (!this.options?.dataZoom) {
|
this._modifierPressed = true;
|
||||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
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) => {
|
const handleKeyUp = (ev: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
this._modifierPressed &&
|
this._modifierPressed &&
|
||||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||||
) {
|
) {
|
||||||
this._modifierPressed = false;
|
this._modifierPressed = false;
|
||||||
if (!this.options?.dataZoom) {
|
if (!this.options?.dataZoom) {
|
||||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||||
|
}
|
||||||
|
this.chart?.dispatchAction({
|
||||||
|
type: "takeGlobalCursor",
|
||||||
|
key: "dataZoomSelect",
|
||||||
|
dataZoomSelectActive: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.chart?.dispatchAction({
|
};
|
||||||
type: "takeGlobalCursor",
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
key: "dataZoomSelect",
|
window.addEventListener("keyup", handleKeyUp);
|
||||||
dataZoomSelectActive: false,
|
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() {
|
protected firstUpdated() {
|
||||||
@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
|
|||||||
<div class="chart"></div>
|
<div class="chart"></div>
|
||||||
</div>
|
</div>
|
||||||
${this._renderLegend()}
|
${this._renderLegend()}
|
||||||
${this._isZoomed
|
<div class="chart-controls">
|
||||||
? html`<ha-icon-button
|
${this._isZoomed
|
||||||
class="zoom-reset"
|
? html`<ha-icon-button
|
||||||
.path=${mdiRestart}
|
class="zoom-reset"
|
||||||
@click=${this._handleZoomReset}
|
.path=${mdiRestart}
|
||||||
title=${this.hass.localize(
|
@click=${this._handleZoomReset}
|
||||||
"ui.components.history_charts.zoom_reset"
|
title=${this.hass.localize(
|
||||||
)}
|
"ui.components.history_charts.zoom_reset"
|
||||||
></ha-icon-button>`
|
)}
|
||||||
: nothing}
|
></ha-icon-button>`
|
||||||
|
: nothing}
|
||||||
|
<slot name="button"></slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@ -210,7 +215,7 @@ export class HaChartBase extends LitElement {
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
|
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
|
||||||
if (!legend.show) {
|
if (!legend.show || legend.type !== "custom") {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
const datasets = ensureArray(this.data);
|
const datasets = ensureArray(this.data);
|
||||||
@ -315,7 +320,9 @@ export class HaChartBase extends LitElement {
|
|||||||
this.chart.on("click", (e: ECElementEvent) => {
|
this.chart.on("click", (e: ECElementEvent) => {
|
||||||
fireEvent(this, "chart-click", e);
|
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) {
|
if (this._isTouchDevice) {
|
||||||
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
||||||
if (!e.zrByTouch) {
|
if (!e.zrByTouch) {
|
||||||
@ -410,6 +417,12 @@ export class HaChartBase extends LitElement {
|
|||||||
} as XAXisOption;
|
} as XAXisOption;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let legend = this.options?.legend;
|
||||||
|
if (legend) {
|
||||||
|
legend = ensureArray(legend).map((l) =>
|
||||||
|
l.type === "custom" ? { show: false } : l
|
||||||
|
);
|
||||||
|
}
|
||||||
const options = {
|
const options = {
|
||||||
animation: !this._reducedMotion,
|
animation: !this._reducedMotion,
|
||||||
darkMode: this._themes.darkMode ?? false,
|
darkMode: this._themes.darkMode ?? false,
|
||||||
@ -424,7 +437,7 @@ export class HaChartBase extends LitElement {
|
|||||||
iconStyle: { opacity: 0 },
|
iconStyle: { opacity: 0 },
|
||||||
},
|
},
|
||||||
...this.options,
|
...this.options,
|
||||||
legend: { show: false },
|
legend,
|
||||||
xAxis,
|
xAxis,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -725,16 +738,26 @@ export class HaChartBase extends LitElement {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.zoom-reset {
|
.chart-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 16px;
|
top: 16px;
|
||||||
right: 4px;
|
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);
|
background: var(--card-background-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
--mdc-icon-button-size: 32px;
|
--mdc-icon-button-size: 32px;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border: 1px solid var(--divider-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 {
|
.chart-legend {
|
||||||
max-height: 60%;
|
max-height: 60%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
278
src/components/chart/ha-network-graph.ts
Normal file
278
src/components/chart/ha-network-graph.ts
Normal file
@ -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<string, { x: number; y: number }> = {};
|
||||||
|
|
||||||
|
@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`<ha-chart-base
|
||||||
|
.hass=${this.hass}
|
||||||
|
.data=${this._getSeries(
|
||||||
|
this.data,
|
||||||
|
this._physicsEnabled,
|
||||||
|
this._reducedMotion
|
||||||
|
)}
|
||||||
|
.options=${this._createOptions(this.data?.categories)}
|
||||||
|
height="100%"
|
||||||
|
.extraComponents=${[GraphChart]}
|
||||||
|
>
|
||||||
|
<slot name="button" slot="button"></slot>
|
||||||
|
<ha-icon-button
|
||||||
|
slot="button"
|
||||||
|
class=${this._physicsEnabled ? "active" : "inactive"}
|
||||||
|
.path=${mdiGoogleCirclesGroup}
|
||||||
|
@click=${this._togglePhysics}
|
||||||
|
label=${this.hass.localize(
|
||||||
|
"ui.panel.config.common.graph.toggle_physics"
|
||||||
|
)}
|
||||||
|
></ha-icon-button>
|
||||||
|
</ha-chart-base>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<GraphSeriesOption["data"]>[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 };
|
||||||
|
}
|
||||||
|
}
|
@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
},
|
},
|
||||||
} as YAXisOption,
|
} as YAXisOption,
|
||||||
legend: {
|
legend: {
|
||||||
|
type: "custom",
|
||||||
show: this.showNames,
|
show: this.showNames,
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
|
@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
|
type: "custom",
|
||||||
show: !this.hideLegend,
|
show: !this.hideLegend,
|
||||||
data: this._legendData,
|
data: this._legendData,
|
||||||
},
|
},
|
||||||
|
@ -1,27 +1,26 @@
|
|||||||
import "@material/mwc-button";
|
import "@material/mwc-button";
|
||||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import type { Edge, EdgeOptions, Node } from "vis-network/peer/esm/vis-network";
|
import type {
|
||||||
import { Network } from "vis-network/peer/esm/vis-network";
|
CallbackDataParams,
|
||||||
import { navigate } from "../../../../../common/navigate";
|
TopLevelFormatterParams,
|
||||||
import "../../../../../components/search-input";
|
} from "echarts/types/dist/shared";
|
||||||
import "../../../../../components/device/ha-device-picker";
|
import { mdiRefresh } from "@mdi/js";
|
||||||
import "../../../../../components/ha-button-menu";
|
import "../../../../../components/chart/ha-network-graph";
|
||||||
import "../../../../../components/ha-checkbox";
|
import type {
|
||||||
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
|
NetworkData,
|
||||||
import "../../../../../components/ha-formfield";
|
NetworkNode,
|
||||||
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
|
NetworkLink,
|
||||||
|
} from "../../../../../components/chart/ha-network-graph";
|
||||||
import type { ZHADevice } from "../../../../../data/zha";
|
import type { ZHADevice } from "../../../../../data/zha";
|
||||||
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
|
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
|
||||||
import "../../../../../layouts/hass-tabs-subpage";
|
import "../../../../../layouts/hass-tabs-subpage";
|
||||||
import type {
|
import type { HomeAssistant, Route } from "../../../../../types";
|
||||||
ValueChangedEvent,
|
|
||||||
HomeAssistant,
|
|
||||||
Route,
|
|
||||||
} from "../../../../../types";
|
|
||||||
import { formatAsPaddedHex } from "./functions";
|
import { formatAsPaddedHex } from "./functions";
|
||||||
import { zhaTabs } from "./zha-config-dashboard";
|
import { zhaTabs } from "./zha-config-dashboard";
|
||||||
|
import { colorVariables } from "../../../../../resources/theme/color.globals";
|
||||||
|
import { navigate } from "../../../../../common/navigate";
|
||||||
|
|
||||||
@customElement("zha-network-visualization-page")
|
@customElement("zha-network-visualization-page")
|
||||||
export class ZHANetworkVisualizationPage extends LitElement {
|
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: "is-wide", type: Boolean }) public isWide = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@state()
|
||||||
public zoomedDeviceIdFromURL?: string;
|
private _networkData?: NetworkData;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private zoomedDeviceId?: string;
|
private _devices: ZHADevice[] = [];
|
||||||
|
|
||||||
@query("#visualization", true)
|
|
||||||
private _visualization?: HTMLElement;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private _devices = new Map<string, ZHADevice>();
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private _devicesByDeviceId = new Map<string, ZHADevice>();
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private _nodes: Node[] = [];
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private _network?: Network;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private _filter?: string;
|
|
||||||
|
|
||||||
private _autoZoom = true;
|
|
||||||
|
|
||||||
private _enablePhysics = true;
|
|
||||||
|
|
||||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||||
super.firstUpdated(changedProperties);
|
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) {
|
if (this.hass) {
|
||||||
this._fetchData();
|
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() {
|
protected render() {
|
||||||
@ -144,359 +58,297 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
|||||||
"ui.panel.config.zha.visualization.header"
|
"ui.panel.config.zha.visualization.header"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
${this.narrow
|
<ha-network-graph
|
||||||
? html`
|
.hass=${this.hass}
|
||||||
<div slot="header">
|
.data=${this._networkData}
|
||||||
<search-input
|
.tooltipFormatter=${this._tooltipFormatter}
|
||||||
.hass=${this.hass}
|
@chart-click=${this._handleChartClick}
|
||||||
class="header"
|
>
|
||||||
@value-changed=${this._handleSearchChange}
|
<ha-icon-button
|
||||||
.filter=${this._filter}
|
slot="button"
|
||||||
.label=${this.hass.localize(
|
class="refresh-button"
|
||||||
"ui.panel.config.zha.visualization.highlight_label"
|
.path=${mdiRefresh}
|
||||||
)}
|
@click=${this._refreshTopology}
|
||||||
>
|
label=${this.hass.localize(
|
||||||
</search-input>
|
"ui.panel.config.zha.visualization.refresh_topology"
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ""}
|
|
||||||
<div class="header">
|
|
||||||
${!this.narrow
|
|
||||||
? html`<search-input
|
|
||||||
.hass=${this.hass}
|
|
||||||
@value-changed=${this._handleSearchChange}
|
|
||||||
.filter=${this._filter}
|
|
||||||
.label=${this.hass.localize(
|
|
||||||
"ui.panel.config.zha.visualization.highlight_label"
|
|
||||||
)}
|
|
||||||
></search-input>`
|
|
||||||
: ""}
|
|
||||||
<ha-device-picker
|
|
||||||
.hass=${this.hass}
|
|
||||||
.value=${this.zoomedDeviceId}
|
|
||||||
.label=${this.hass.localize(
|
|
||||||
"ui.panel.config.zha.visualization.zoom_label"
|
|
||||||
)}
|
)}
|
||||||
.deviceFilter=${this._filterDevices}
|
></ha-icon-button>
|
||||||
@value-changed=${this._onZoomToDevice}
|
</ha-network-graph>
|
||||||
></ha-device-picker>
|
|
||||||
<div class="controls">
|
|
||||||
<ha-formfield
|
|
||||||
.label=${this.hass!.localize(
|
|
||||||
"ui.panel.config.zha.visualization.auto_zoom"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ha-checkbox
|
|
||||||
@change=${this._handleAutoZoomCheckboxChange}
|
|
||||||
.checked=${this._autoZoom}
|
|
||||||
>
|
|
||||||
</ha-checkbox>
|
|
||||||
</ha-formfield>
|
|
||||||
<ha-formfield
|
|
||||||
.label=${this.hass!.localize(
|
|
||||||
"ui.panel.config.zha.visualization.enable_physics"
|
|
||||||
)}
|
|
||||||
><ha-checkbox
|
|
||||||
@change=${this._handlePhysicsCheckboxChange}
|
|
||||||
.checked=${this._enablePhysics}
|
|
||||||
>
|
|
||||||
</ha-checkbox
|
|
||||||
></ha-formfield>
|
|
||||||
<mwc-button @click=${this._refreshTopology}>
|
|
||||||
${this.hass!.localize(
|
|
||||||
"ui.panel.config.zha.visualization.refresh_topology"
|
|
||||||
)}
|
|
||||||
</mwc-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="visualization"></div>
|
|
||||||
</hass-tabs-subpage>
|
</hass-tabs-subpage>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _fetchData() {
|
private async _fetchData() {
|
||||||
const devices = await fetchDevices(this.hass!);
|
this._devices = await fetchDevices(this.hass!);
|
||||||
this._devices = new Map(
|
this._networkData = this._createChartData(this._devices);
|
||||||
devices.map((device: ZHADevice) => [device.ieee, device])
|
|
||||||
);
|
|
||||||
this._devicesByDeviceId = new Map(
|
|
||||||
devices.map((device: ZHADevice) => [device.device_reg_id, device])
|
|
||||||
);
|
|
||||||
this._updateDevices(devices);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _updateDevices(devices: ZHADevice[]) {
|
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
|
||||||
this._nodes = [];
|
const { dataType, data, name } = params as CallbackDataParams;
|
||||||
const edges: Edge[] = [];
|
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 ? ` <b>LQI:</b> ${value}` : ""}`;
|
||||||
|
|
||||||
devices.forEach((device) => {
|
const reverseValue = this._networkData!.links.find(
|
||||||
this._nodes.push({
|
(link) => link.source === source && link.target === target
|
||||||
id: device.ieee,
|
)?.reverseValue;
|
||||||
label: this._buildLabel(device),
|
if (reverseValue) {
|
||||||
shape: this._getShape(device),
|
return `${tooltipText}<br>${targetName} → ${sourceName} <b>LQI:</b> ${reverseValue}`;
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
return tooltipText;
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (lqi > 128) {
|
const device = this._devices.find((d) => d.ieee === (data as any).id);
|
||||||
return {
|
if (!device) {
|
||||||
color: { color: "#e6b402", highlight: "#e6b402" },
|
return name;
|
||||||
width: 9,
|
|
||||||
length: length,
|
|
||||||
physics: false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return {
|
let label = `<b>IEEE: </b>${device.ieee}`;
|
||||||
color: { color: "#bfbfbf", highlight: "#bfbfbf" },
|
label += `<br><b>Device Type: </b>${device.device_type.replace("_", " ")}`;
|
||||||
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
|
|
||||||
? `<b>${device.user_given_name}</b>\n`
|
|
||||||
: "";
|
|
||||||
label += `<b>IEEE: </b>${device.ieee}`;
|
|
||||||
label += `\n<b>Device Type: </b>${device.device_type.replace("_", " ")}`;
|
|
||||||
if (device.nwk != null) {
|
if (device.nwk != null) {
|
||||||
label += `\n<b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
|
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
|
||||||
}
|
}
|
||||||
if (device.manufacturer != null && device.model != null) {
|
if (device.manufacturer != null && device.model != null) {
|
||||||
label += `\n<b>Device: </b>${device.manufacturer} ${device.model}`;
|
label += `<br><b>Device: </b>${device.manufacturer} ${device.model}`;
|
||||||
} else {
|
} else {
|
||||||
label += "\n<b>Device is not in <i>'zigbee.db'</i></b>";
|
label += "<br><b>Device is not in <i>'zigbee.db'</i></b>";
|
||||||
}
|
}
|
||||||
if (device.area_id) {
|
if (device.area_id) {
|
||||||
label += `\n<b>Area ID: </b>${device.area_id}`;
|
const area = this.hass.areas[device.area_id];
|
||||||
}
|
if (area) {
|
||||||
return label;
|
label += `<br><b>Area: </b>${area.name}`;
|
||||||
}
|
|
||||||
|
|
||||||
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<string>) {
|
|
||||||
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" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return label;
|
||||||
|
};
|
||||||
private _zoomOut() {
|
|
||||||
this._network!.fit({
|
|
||||||
nodes: [],
|
|
||||||
animation: { duration: 500, easingFunction: "easeOutQuad" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _refreshTopology(): Promise<void> {
|
private async _refreshTopology(): Promise<void> {
|
||||||
await refreshTopology(this.hass);
|
await refreshTopology(this.hass);
|
||||||
|
await this._fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
|
private _handleChartClick(e: CustomEvent): void {
|
||||||
if (!this.hass) {
|
if (
|
||||||
return false;
|
e.detail.dataType === "node" &&
|
||||||
}
|
e.detail.event.target.cursor === "pointer"
|
||||||
for (const parts of device.identifiers) {
|
) {
|
||||||
for (const part of parts) {
|
const { id } = e.detail.data;
|
||||||
if (part === "zha") {
|
const device = this._devices.find((d) => d.ieee === id);
|
||||||
return true;
|
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 {
|
static get styles(): CSSResultGroup {
|
||||||
return [
|
return [
|
||||||
css`
|
css`
|
||||||
.header {
|
ha-network-graph {
|
||||||
border-bottom: 1px solid var(--divider-color);
|
height: 100%;
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
declare global {
|
||||||
|
@ -184,13 +184,11 @@ export class HuiEnergyDevicesDetailGraphCard
|
|||||||
...commonOptions,
|
...commonOptions,
|
||||||
legend: {
|
legend: {
|
||||||
show: true,
|
show: true,
|
||||||
type: "scroll",
|
type: "custom",
|
||||||
animationDurationUpdate: 400,
|
|
||||||
selected: this._hiddenStats.reduce((acc, stat) => {
|
selected: this._hiddenStats.reduce((acc, stat) => {
|
||||||
acc[stat] = false;
|
acc[stat] = false;
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
icon: "circle",
|
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
top: 15,
|
top: 15,
|
||||||
|
@ -29,6 +29,7 @@ import type {
|
|||||||
LineSeriesOption,
|
LineSeriesOption,
|
||||||
CustomSeriesOption,
|
CustomSeriesOption,
|
||||||
SankeySeriesOption,
|
SankeySeriesOption,
|
||||||
|
GraphSeriesOption,
|
||||||
} from "echarts/charts";
|
} from "echarts/charts";
|
||||||
import type {
|
import type {
|
||||||
// The component option types are defined with the ComponentOption suffix
|
// The component option types are defined with the ComponentOption suffix
|
||||||
@ -53,6 +54,7 @@ export type ECOption = ComposeOption<
|
|||||||
| DataZoomComponentOption
|
| DataZoomComponentOption
|
||||||
| VisualMapComponentOption
|
| VisualMapComponentOption
|
||||||
| SankeySeriesOption
|
| SankeySeriesOption
|
||||||
|
| GraphSeriesOption
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// Register the required components
|
// Register the required components
|
||||||
|
@ -355,6 +355,7 @@ const darkColorStyles = css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
export const colorDerivedVariables = extractDerivedVars(colorStyles);
|
export const colorDerivedVariables = extractDerivedVars(colorStyles);
|
||||||
|
export const colorVariables = extractVars(colorStyles);
|
||||||
export const darkColorVariables = extractVars(darkColorStyles);
|
export const darkColorVariables = extractVars(darkColorStyles);
|
||||||
|
|
||||||
export const DefaultPrimaryColor = extractVar(colorStyles, "primary-color");
|
export const DefaultPrimaryColor = extractVar(colorStyles, "primary-color");
|
||||||
|
@ -2113,7 +2113,10 @@
|
|||||||
"learn_more": "Learn more",
|
"learn_more": "Learn more",
|
||||||
"show_url": "Show full URL",
|
"show_url": "Show full URL",
|
||||||
"hide_url": "Hide URL",
|
"hide_url": "Hide URL",
|
||||||
"copy_link": "Copy link"
|
"copy_link": "Copy link",
|
||||||
|
"graph": {
|
||||||
|
"toggle_physics": "Toggle physics"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"caption": "Updates",
|
"caption": "Updates",
|
||||||
@ -5696,10 +5699,6 @@
|
|||||||
"visualization": {
|
"visualization": {
|
||||||
"header": "Network visualization",
|
"header": "Network visualization",
|
||||||
"caption": "Visualization",
|
"caption": "Visualization",
|
||||||
"highlight_label": "Highlight devices",
|
|
||||||
"zoom_label": "Zoom to device",
|
|
||||||
"auto_zoom": "Auto zoom",
|
|
||||||
"enable_physics": "Enable physics",
|
|
||||||
"refresh_topology": "Refresh topology"
|
"refresh_topology": "Refresh topology"
|
||||||
},
|
},
|
||||||
"device_binding": {
|
"device_binding": {
|
||||||
|
3
src/types/echarts.d.ts
vendored
Normal file
3
src/types/echarts.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare module "echarts/lib/chart/graph/install" {
|
||||||
|
export const install: EChartsExtensionInstaller;
|
||||||
|
}
|
15
yarn.lock
15
yarn.lock
@ -9545,7 +9545,6 @@ __metadata:
|
|||||||
typescript-eslint: "npm:8.32.1"
|
typescript-eslint: "npm:8.32.1"
|
||||||
ua-parser-js: "npm:2.0.3"
|
ua-parser-js: "npm:2.0.3"
|
||||||
vis-data: "npm:7.1.9"
|
vis-data: "npm:7.1.9"
|
||||||
vis-network: "npm:9.1.9"
|
|
||||||
vite-tsconfig-paths: "npm:5.1.4"
|
vite-tsconfig-paths: "npm:5.1.4"
|
||||||
vitest: "npm:3.1.3"
|
vitest: "npm:3.1.3"
|
||||||
vue: "npm:2.7.16"
|
vue: "npm:2.7.16"
|
||||||
@ -15123,20 +15122,6 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"vite-node@npm:3.1.3":
|
||||||
version: 3.1.3
|
version: 3.1.3
|
||||||
resolution: "vite-node@npm:3.1.3"
|
resolution: "vite-node@npm:3.1.3"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user