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 { mdiFormatTextVariant, 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"; import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { deepEqual } from "../../common/util/deep-equal"; export interface NetworkNode { id: string; name?: string; category?: number; 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; x?: number; y?: number; } export interface NetworkLink { source: string; target: string; value?: number; reverseValue?: number; lineStyle?: { width?: number; color?: string; type?: "solid" | "dashed" | "dotted"; }; symbolSize?: number | number[]; symbol?: string; label?: { show?: boolean; formatter?: string; }; ignoreForceLayout?: boolean; } export interface NetworkData { nodes: NetworkNode[]; links: NetworkLink[]; categories?: { name: string; symbol: 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 SubscribeMixin(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; @state() private _showLabels = true; 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(); }); } } protected hassSubscribe() { return [ listenMediaQuery("(prefers-reduced-motion)", (matches) => { if (this._reducedMotion !== matches) { this._reducedMotion = matches; } }), ]; } protected render() { if (!GraphChart || !this.data.nodes?.length) { return nothing; } const isMobile = window.matchMedia( "all and (max-width: 450px), all and (max-height: 500px)" ).matches; return html` `; } private _createOptions = memoizeOne( (categories?: NetworkData["categories"]): ECOption => ({ tooltip: { trigger: "item", confine: true, formatter: this.tooltipFormatter, }, legend: { show: !!categories?.length, data: categories?.map((category) => ({ ...category, icon: category.symbol, })), top: 8, }, dataZoom: { type: "inside", filterMode: "none", }, }), deepEqual ); private _getSeries = memoizeOne( ( data: NetworkData, physicsEnabled: boolean, reducedMotion: boolean, showLabels: boolean, isMobile: boolean ) => ({ id: "network", type: "graph", layout: physicsEnabled ? "force" : "none", draggable: true, roam: true, selectedMode: "single", label: { show: showLabels, position: "right", }, emphasis: { focus: isMobile ? "none" : "adjacency", }, force: { repulsion: [400, 600], edgeLength: [200, 350], gravity: 0.05, layoutAnimation: !reducedMotion && data.nodes.length < 100, }, edgeSymbol: ["none", "arrow"], edgeSymbolSize: 10, data: this._getSeriesData(data.nodes, data.links, this._nodePositions), 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 || [], }), deepEqual ); private _getSeriesData = memoizeOne( ( nodes: NetworkNode[], links: NetworkLink[], nodePositions: Record ) => this._getPositionedNodes(nodes, links, nodePositions).map( (node) => ({ 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, x: node.x, y: node.y, }) as NonNullable[number] ), deepEqual ); private _getPositionedNodes( nodes: NetworkNode[], links: NetworkLink[], nodePositions: Record ) { const containerWidth = this.clientWidth; const containerHeight = this.clientHeight; const positionedNodes: NetworkNode[] = nodes.map((node) => ({ ...node })); positionedNodes.forEach((node) => { if (nodePositions[node.id]) { node.x = nodePositions[node.id].x; node.y = nodePositions[node.id].y; } if (node.polarDistance === 0) { if (node.x == null && node.y == null) { node.x = containerWidth / 2; node.y = containerHeight / 2; } this._positionNodeNeighbors( node, positionedNodes, links, nodePositions ); } }); positionedNodes.forEach((node) => { // set positions for unconnected nodes if (node.polarDistance && node.x == null && node.y == null) { // set the position of the node at polarDistance from the center in a random direction const angle = Math.random() * 2 * Math.PI; node.x = ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance + containerWidth / 2; node.y = ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance + containerHeight / 2; // save the random position this._nodePositions[node.id] = { x: node.x, y: node.y, }; } }); return positionedNodes; } private _positionNodeNeighbors( node: NetworkNode, nodes: NetworkNode[], links: NetworkLink[], nodePositions: Record, parentId?: string, minAngle = 0, maxAngle = Math.PI * 2 ) { const neighbors = links .map((l) => l.source === node.id && l.target !== parentId && !l.ignoreForceLayout ? nodes.find((n) => n.id === l.target) : l.target === node.id && l.source !== parentId && !l.ignoreForceLayout ? nodes.find((n) => n.id === l.source) : null ) .filter(Boolean) as NetworkNode[]; if (!neighbors.length) { return; } const angle = Math.abs(maxAngle - minAngle) / neighbors.length; const toContinue: { neighbor: NetworkNode; angle: number }[] = []; neighbors.forEach((neighbor, i) => { if (neighbor.x == null && neighbor.y == null) { const nodeAngle = minAngle + angle * i + angle / 2; toContinue.push({ neighbor, angle: nodeAngle }); if (nodePositions[neighbor.id]) { neighbor.x = nodePositions[neighbor.id].x; neighbor.y = nodePositions[neighbor.id].y; } else { neighbor.x = node.x! + (Math.cos(nodeAngle) * this.clientWidth) / 4; neighbor.y = node.y! + (Math.sin(nodeAngle) * this.clientHeight) / 4; } } }); toContinue.forEach(({ neighbor, angle: neighborAngle }) => { this._positionNodeNeighbors( neighbor, nodes, links, nodePositions, node.id, neighborAngle - Math.PI / 2, neighborAngle + Math.PI / 2 ); }); } private _togglePhysics() { this._saveNodePositions(); this._physicsEnabled = !this._physicsEnabled; } private _toggleLabels() { this._showLabels = !this._showLabels; } private _saveNodePositions() { const positions = {}; 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) { positions[node.id] = { x: layout[0], y: layout[1], }; } }); } this._nodePositions = positions; } 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 }; } }