diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index 86e421eb14..eb484fa2b2 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -280,18 +280,23 @@ export class HaChartBase extends LitElement {
${this._renderLegend()} -
- ${this._isZoomed && !this.hideResetButton - ? html`` - : nothing} - +
+ +
+ ${this._isZoomed && !this.hideResetButton + ? html`` + : nothing} + +
`; @@ -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, diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts index de9a7fb5e3..ccc9debe01 100644 --- a/src/components/chart/ha-network-graph.ts +++ b/src/components/chart/ha-network-graph.ts @@ -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; + @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` + (); + 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; diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts index 5d9c241854..9f285a2450 100644 --- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts @@ -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 = {}; + @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`
+ +
` + : nothing} + > + ${!this.narrow + ? html`` + : nothing} + `; } + 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; + } `, ]; } 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 53b69870ab..abd68a798c 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 @@ -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`
+ +
` + : nothing} + ${!this.narrow + ? html`` + : nothing} { + 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; + } `, ]; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts index 4a455aed80..56018b79fb 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-network-visualization.ts @@ -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 = {}; + @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`
+ +
` + : nothing} + > + ${!this.narrow + ? html`` + : nothing} +
`; } @@ -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; + } `, ]; }