1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Add search to network visualization graphs (#29908)

* Add search highlight to ZHA graph

* Move logic upstream and extend search to zwave and bluetooth

* Move search down to avoid collisions with graph legend

* Fix mobile; simplify code

* Apply highlights directly on search callback

* Revert "Move search down to avoid collisions with graph legend"

This reverts commit 4578aec9c3.

* Move legend down

* Make search bar shrink to avoid overlapping buttons

* Move search bar to topbar on mobile

* Fix inset

* Fix small controlls position

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
Abílio Costa
2026-03-25 09:16:26 +00:00
committed by GitHub
parent 15b1df5a58
commit 79743c0afa
5 changed files with 335 additions and 25 deletions

View File

@@ -280,18 +280,23 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="chart-controls ${classMap({ small: this.smallControls })}">
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
<div
class="chart-controls ${classMap({ small: this.smallControls })}"
>
${this._isZoomed && !this.hideResetButton
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
</div>
`;
@@ -1116,16 +1121,35 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart-controls {
.top-controls {
position: absolute;
top: 16px;
right: 4px;
top: var(--ha-space-4);
inset-inline-start: var(--ha-space-4);
inset-inline-end: var(--ha-space-1);
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
z-index: 1;
pointer-events: none;
}
::slotted([slot="search"]) {
flex: 1 1 250px;
min-width: 0;
max-width: 250px;
pointer-events: auto;
}
.chart-controls {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-inline-start: auto;
flex-shrink: 0;
pointer-events: auto;
}
.top-controls.small {
top: 0;
}
.chart-controls.small {
top: 0;
flex-direction: row;
}
.chart-controls ha-icon-button,

View File

@@ -1,7 +1,9 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -76,8 +78,20 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
params: TopLevelFormatterParams
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
* These are matched against the search filter in addition to the node's name and context.
*/
@property({ attribute: false }) public searchableAttributes?: (
nodeId: string
) => string[];
@property({ attribute: false }) public searchFilter = "";
public hass!: HomeAssistant;
@state() private _highlightedNodes?: Set<string>;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@@ -117,6 +131,9 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const hasHighlightedNodes =
this._highlightedNodes && this._highlightedNodes.size > 0;
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
@@ -124,12 +141,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
this._physicsEnabled,
this._reducedMotion,
this._showLabels,
isMobile
isMobile,
hasHighlightedNodes
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="search" slot="search"></slot>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
@@ -165,7 +184,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
...category,
icon: category.symbol,
})),
top: 8,
bottom: 8,
},
dataZoom: {
type: "inside",
@@ -175,13 +194,56 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
deepEqual
);
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has("searchFilter")) {
const filter = this.searchFilter;
if (!filter) {
this._highlightedNodes = undefined;
} else {
const lowerFilter = filter.toLowerCase();
const matchingIds = new Set<string>();
for (const node of this.data.nodes) {
if (this._nodeMatchesFilter(node, lowerFilter)) {
matchingIds.add(node.id);
}
}
this._highlightedNodes = matchingIds;
}
this._applyHighlighting();
this._updateMouseoverHandler();
}
}
private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
if (node.name?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.context?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (node.id?.toLowerCase().includes(lowerFilter)) {
return true;
}
if (this.searchableAttributes) {
const extraValues = this.searchableAttributes(node.id);
for (const value of extraValues) {
if (value?.toLowerCase().includes(lowerFilter)) {
return true;
}
}
}
return false;
}
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean,
isMobile: boolean
isMobile: boolean,
hasHighlightedNodes?: boolean
) => ({
id: "network",
type: "graph",
@@ -214,7 +276,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
},
},
emphasis: {
focus: isMobile ? "none" : "adjacency",
focus: hasHighlightedNodes ? "self" : isMobile ? "none" : "adjacency",
},
force: {
repulsion: [400, 600],
@@ -362,6 +424,68 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
});
}
private _applyHighlighting() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// Reset all nodes to normal opacity first
chart.dispatchAction({ type: "downplay" });
const highlighted = this._highlightedNodes;
if (!highlighted || highlighted.size === 0) {
return;
}
const dataIndices: number[] = [];
this.data.nodes.forEach((node, index) => {
if (highlighted.has(node.id)) {
dataIndices.push(index);
}
});
if (dataIndices.length > 0) {
chart.dispatchAction({ type: "highlight", dataIndex: dataIndices });
}
}
private _emphasisGuardHandler?: () => void;
private _updateMouseoverHandler() {
const chart = this._baseChart?.chart;
if (!chart) {
return;
}
// When there are highlighted nodes, re-apply highlighting on hover
// and mouseout to prevent hover from overriding the search state
if (this._highlightedNodes && this._highlightedNodes.size > 0) {
if (this._emphasisGuardHandler) {
// Guard already set
return;
}
this._emphasisGuardHandler = () => {
this._applyHighlighting();
};
chart.on("mouseover", this._emphasisGuardHandler);
chart.on("mouseout", this._emphasisGuardHandler);
} else {
if (!this._emphasisGuardHandler) {
return;
}
chart.off("mouseover", this._emphasisGuardHandler);
chart.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._emphasisGuardHandler) {
this._baseChart?.chart?.off("mouseover", this._emphasisGuardHandler);
this._baseChart?.chart?.off("mouseout", this._emphasisGuardHandler);
this._emphasisGuardHandler = undefined;
}
}
private _togglePhysics() {
this._saveNodePositions();
this._physicsEnabled = !this._physicsEnabled;

View File

@@ -4,7 +4,7 @@ import type {
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
@@ -17,6 +17,7 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/search-input-outlined";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
@@ -60,6 +61,8 @@ export class BluetoothNetworkVisualization extends LitElement {
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@state() private _searchFilter = "";
private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc;
@@ -126,16 +129,56 @@ export class BluetoothNetworkVisualization extends LitElement {
)}
back-path="/config/bluetooth/dashboard"
>
${this.narrow
? html`<div slot="header">
<search-input-outlined
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>
</div>`
: nothing}
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._formatNetworkData(this._data, this._scanners)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
></ha-network-graph>
>
${!this.narrow
? html`<search-input-outlined
slot="search"
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>`
: nothing}
</ha-network-graph>
</hass-subpage>
`;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const attributes: string[] = [];
const device = this._sourceDevices[nodeId];
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
const scanner = this._scanners[nodeId];
if (scanner?.name) {
attributes.push(scanner.name);
}
return attributes;
};
private _handleSearchChange(ev: CustomEvent): void {
this._searchFilter = ev.detail.value;
}
private _getRssiColorVar = memoizeOne((rssi: number): string => {
for (const [threshold, colorVar] of RSSI_COLOR_THRESHOLDS) {
if (rssi > threshold) {
@@ -347,6 +390,13 @@ export class BluetoothNetworkVisualization extends LitElement {
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
search-input-outlined {
flex: 1;
}
`,
];
}

View File

@@ -4,12 +4,13 @@ import type {
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/chart/ha-network-graph";
import type { NetworkData } from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/search-input-outlined";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type { ZHADevice } from "../../../../../data/zha";
import { fetchDevices, refreshTopology } from "../../../../../data/zha";
@@ -38,6 +39,8 @@ export class ZHANetworkVisualizationPage extends LitElement {
@state()
private _devices: ZHADevice[] = [];
@state() private _searchFilter = "";
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
@@ -55,12 +58,31 @@ export class ZHANetworkVisualizationPage extends LitElement {
"ui.panel.config.zha.visualization.header"
)}
>
${this.narrow
? html`<div slot="header">
<search-input-outlined
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>
</div>`
: nothing}
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._networkData}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow
? html`<search-input-outlined
slot="search"
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>`
: nothing}
<ha-icon-button
slot="button"
class="refresh-button"
@@ -84,6 +106,34 @@ export class ZHANetworkVisualizationPage extends LitElement {
);
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const device = this._devices.find((d) => d.ieee === nodeId);
if (!device) {
return [];
}
const attributes: string[] = [];
if (device.user_given_name) {
attributes.push(device.user_given_name);
}
if (device.manufacturer) {
attributes.push(device.manufacturer);
}
if (device.model) {
attributes.push(device.model);
}
if (device.device_type) {
attributes.push(device.device_type);
}
if (device.nwk != null) {
attributes.push(formatAsPaddedHex(device.nwk));
}
return attributes;
};
private _handleSearchChange(ev: CustomEvent): void {
this._searchFilter = ev.detail.value;
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
@@ -154,6 +204,13 @@ export class ZHANetworkVisualizationPage extends LitElement {
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
search-input-outlined {
flex: 1;
}
`,
];
}

View File

@@ -2,7 +2,7 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getDeviceContext } from "../../../../../common/entity/context/get_device_context";
@@ -14,6 +14,7 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/search-input-outlined";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
ZWaveJSNodeStatisticsUpdatedMessage,
@@ -49,6 +50,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
@state() private _searchFilter = "";
public hassSubscribe() {
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
@@ -80,15 +83,35 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
back-path="/config/zwave_js/dashboard?config_entry=${this
.configEntryId}"
>
${this.narrow
? html`<div slot="header">
<search-input-outlined
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>
</div>`
: nothing}
<ha-network-graph
.hass=${this.hass}
.searchFilter=${this._searchFilter}
.data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
></ha-network-graph>
>
${!this.narrow
? html`<search-input-outlined
slot="search"
.hass=${this.hass}
.filter=${this._searchFilter}
@value-changed=${this._handleSearchChange}
></search-input-outlined>`
: nothing}
</ha-network-graph>
</hass-subpage>
`;
}
@@ -105,6 +128,31 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
this._nodeStatuses = nodeStatuses;
}
private _getSearchableAttributes = (nodeId: string): string[] => {
const device = this._devices[Number(nodeId)];
const nodeStatus = this._nodeStatuses[Number(nodeId)];
const attributes: string[] = [];
if (device?.manufacturer) {
attributes.push(device.manufacturer);
}
if (device?.model) {
attributes.push(device.model);
}
if (nodeStatus) {
const statusText = this.hass.localize(
`ui.panel.config.zwave_js.node_status.${nodeStatus.status}` as any
);
if (statusText) {
attributes.push(statusText);
}
}
return attributes;
};
private _handleSearchChange(ev: CustomEvent): void {
this._searchFilter = ev.detail.value;
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
@@ -330,6 +378,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-network-graph {
height: 100%;
}
[slot="header"] {
display: flex;
align-items: center;
}
search-input-outlined {
flex: 1;
}
`,
];
}