diff --git a/CHANGELOG.md b/CHANGELOG.md index f411594..9863164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.0.4](https://github.com/MindFreeze/ha-sankey-chart/compare/v2.0.3...v2.0.4) (2024-04-18) + + +### Bug Fixes + +* **#180:** passthrough and remaining_child_state ([c5832ec](https://github.com/MindFreeze/ha-sankey-chart/commit/c5832ec4534a000319c923cfdcca617ed26d963a)) +* **#180:** refactor passthrough logic to support more complex configs ([e539913](https://github.com/MindFreeze/ha-sankey-chart/commit/e539913576a66ef336c338b52ad0e829bd55447f)) + ## [2.0.3](https://github.com/MindFreeze/ha-sankey-chart/compare/v2.0.2...v2.0.3) (2024-04-16) diff --git a/__tests__/__mocks__/hass.mock.ts b/__tests__/__mocks__/hass.mock.ts new file mode 100644 index 0000000..cb7e408 --- /dev/null +++ b/__tests__/__mocks__/hass.mock.ts @@ -0,0 +1,22 @@ +import { HassEntity } from "home-assistant-js-websocket"; + +export default (states: Record> = {}) => ({ + // `mock: true` is there so Object.keys(hass.states) is not 0 + states: new Proxy({mock: true, ...states}, { + get: function(target, entityId, receiver) { + if (Reflect.has(target, entityId)) { + return Reflect.get(target, entityId, receiver); + } else if (typeof entityId === 'string' && /[a-z]+\..+/.test(entityId)) { + return { + entity_id: entityId, + // deterministaically generate a number in the range 0-10000 based on the entity id + state: String(entityId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 10000), + attributes: { + unit_of_measurement: 'W', + }, + }; + } + return undefined; + }, + }) +}); \ No newline at end of file diff --git a/__tests__/__snapshots__/basic.test.ts.snap b/__tests__/__snapshots__/basic.test.ts.snap index 4c5ca8d..f25866d 100644 --- a/__tests__/__snapshots__/basic.test.ts.snap +++ b/__tests__/__snapshots__/basic.test.ts.snap @@ -6,3 +6,171 @@ exports[`SankeyChart matches a simple snapshot 1`] = ` " `; + +exports[`SankeyChart matches a simple snapshot 2`] = ` +" + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+
+ + +
+
+ + +
+ +
+ + + + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ " +`; diff --git a/__tests__/__snapshots__/remaining.test.ts.snap b/__tests__/__snapshots__/remaining.test.ts.snap new file mode 100644 index 0000000..220d258 --- /dev/null +++ b/__tests__/__snapshots__/remaining.test.ts.snap @@ -0,0 +1,327 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SankeyChart with remaining type entities matches snapshot 1`] = ` +" + + + " +`; + +exports[`SankeyChart with remaining type entities matches snapshot 2`] = ` +" + +
+ +
+
+ + + + + + + + + + + + + + + +
+ + + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ +
+ + +
+ +
+ + + + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+ " +`; diff --git a/__tests__/basic.test.ts b/__tests__/basic.test.ts index a2730d2..fc425ef 100644 --- a/__tests__/basic.test.ts +++ b/__tests__/basic.test.ts @@ -1,34 +1,35 @@ // import '../dist/ha-sankey-chart'; import { HomeAssistant } from 'custom-card-helpers'; import '../src/ha-sankey-chart'; +import '../src/chart'; import SankeyChart from '../src/ha-sankey-chart'; import { SankeyChartConfig } from '../src/types'; +import mockHass from './__mocks__/hass.mock'; +import { LitElement } from 'lit'; -const hass = { - states: { - ent1: { - entity_id: 'ent1', - state: 2, - attributes: { - unit_of_measurement: 'W', - }, +const hass = mockHass({ + ent1: { + entity_id: 'ent1', + state: '2', + attributes: { + unit_of_measurement: 'W', }, - ent2: { - entity_id: 'ent2', - state: 1, - attributes: { - unit_of_measurement: 'W', - }, + }, + ent2: { + entity_id: 'ent2', + state: '1', + attributes: { + unit_of_measurement: 'W', }, - ent3: { - entity_id: 'ent3', - state: 1, - attributes: { - unit_of_measurement: 'W', - }, + }, + ent3: { + entity_id: 'ent3', + state: '1', + attributes: { + unit_of_measurement: 'W', }, }, -}; +}); describe('SankeyChart', () => { const ROOT_TAG = 'sankey-chart'; @@ -41,7 +42,7 @@ describe('SankeyChart', () => { }); afterEach(() => { - document.body.getElementsByTagName(ROOT_TAG)[0].remove(); + sankeyChart.remove(); }); it('matches a simple snapshot', async () => { @@ -62,7 +63,12 @@ describe('SankeyChart', () => { sankeyChart.setConfig(config, true); document.body.appendChild(sankeyChart); await sankeyChart.updateComplete; - expect(sankeyChart.shadowRoot?.innerHTML.replace(//g, '')).toMatchSnapshot(); + + // Wait for the sankey-chart-base component to finish updating + const sankeyChartBase = sankeyChart.shadowRoot?.querySelector('sankey-chart-base') as LitElement; + expect(sankeyChartBase).not.toBeNull(); + + expect(sankeyChartBase.shadowRoot?.innerHTML.replace(//g, '')).toMatchSnapshot(); }); }); diff --git a/__tests__/remaining.test.ts b/__tests__/remaining.test.ts new file mode 100644 index 0000000..37d0400 --- /dev/null +++ b/__tests__/remaining.test.ts @@ -0,0 +1,118 @@ +// import '../dist/ha-sankey-chart'; +import { HomeAssistant } from 'custom-card-helpers'; +import '../src/ha-sankey-chart'; +import SankeyChart from '../src/ha-sankey-chart'; +import { SankeyChartConfig } from '../src/types'; +import mockHass from './__mocks__/hass.mock'; +import { LitElement } from 'lit'; + +const hass = mockHass(); + +describe('SankeyChart with remaining type entities', () => { + const ROOT_TAG = 'sankey-chart'; + let sankeyChart: SankeyChart; + + beforeEach(() => { + sankeyChart = window.document.createElement(ROOT_TAG) as SankeyChart; + // @ts-ignore + sankeyChart.hass = hass as HomeAssistant; + }); + + afterEach(() => { + sankeyChart.remove(); + }); + + it('matches snapshot', async () => { + const config: SankeyChartConfig = { + type: 'custom:sankey-chart', + title: 'Remaining test', + show_states: true, + min_state: 0.1, + unit_prefix: 'k', + sections: [ + { + entities: [ + { + entity_id: 'sensor.test_power', + name: 'Total', + color: 'var(--warning-color)', + children: ['tt', 'sensor.test_power3', 'Annet'], + }, + ], + }, + { + entities: [ + { + entity_id: 'tt', + type: 'remaining_child_state', + name: 'Total\nAll\nAll\nAll\nAll\nAll\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK\nIDK', + color: 'var(--warning-color)', + children: ['sensor.test_power1', 'sensor.test_power2', 'sensor.test_power4'], + color_on_state: true, + color_limit: 10.1, + color_below: 'darkslateblue', + }, + ], + }, + { + entities: [ + { + entity_id: 'sensor.test_power1', + name: 'Varmtvann\nBlaa', + children: ['sensor.test_power3'], + }, + { + entity_id: 'sensor.test_power2', + name: 'Avfukter', + unit_of_measurement: 'В', + children: ['sensor.test_power3'], + }, + { + entity_id: 'sensor.test_power4', + children: ['sensor.test_power3'], + }, + { + entity_id: 'Annet', + type: 'remaining_child_state', + name: 'Annet', + children: ['sensor.test_power3'], + }, + ], + }, + { + entities: [ + { + entity_id: 'switch.plug_158d00022adfd9', + attribute: 'load_power', + unit_of_measurement: 'Wh', + tap_action: { + action: 'toggle', + }, + }, + ], + }, + { + entities: [ + { + entity_id: 'sensor.test_power3', + color_below: 'red', + color: 'red', + color_limit: 14000, + }, + ], + }, + ], + }; + sankeyChart.setConfig(config, true); + document.body.appendChild(sankeyChart); + await sankeyChart.updateComplete; + + expect(sankeyChart.shadowRoot?.innerHTML.replace(//g, '')).toMatchSnapshot(); + + // Wait for the sankey-chart-base component to finish updating + const sankeyChartBase = sankeyChart.shadowRoot?.querySelector('sankey-chart-base') as LitElement; + expect(sankeyChartBase).not.toBeNull(); + + expect(sankeyChartBase.shadowRoot?.innerHTML.replace(//g, '')).toMatchSnapshot(); + }); +}); diff --git a/__tests__/setupTests.ts b/__tests__/setupTests.ts new file mode 100644 index 0000000..f11ba87 --- /dev/null +++ b/__tests__/setupTests.ts @@ -0,0 +1,5 @@ +//mock custom-card-helpers +jest.mock('custom-card-helpers', () => ({ + ...jest.requireActual('custom-card-helpers'), + stateIcon: jest.fn().mockReturnValue('state-icon'), +})); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index f6531d5..101edac 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'jest-environment-jsdom', - setupFiles: [], + setupFiles: ['/__tests__/setupTests.ts'], // roots: [ // '', // '/node_modules', @@ -15,4 +15,5 @@ module.exports = { }, transform: {"^.+\\.(js|ts)x?$": 'ts-jest'}, transformIgnorePatterns: ['node_modules/(?!lit-element|lit-html|lit|@lit/)'], + testMatch: ['**/*.test.ts'], }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 537b6da..6239aa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ha-sankey-chart", - "version": "2.0.3", + "version": "2.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "ha-sankey-chart", - "version": "2.0.3", + "version": "2.0.4", "license": "MIT", "dependencies": { "custom-card-helpers": "^1.9.0", diff --git a/package.json b/package.json index 16cdaea..6c9bf53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ha-sankey-chart", - "version": "2.0.3", + "version": "2.0.4", "description": "Lovelace sankey diagram card", "keywords": [ "home-assistant", diff --git a/src/chart.ts b/src/chart.ts index 67861dd..c4fdc30 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -83,22 +83,34 @@ export class Chart extends LitElement { entities.forEach(ent => { if (ent.type === 'entity') { this.entityIds.push(ent.entity_id); + } else if (ent.type === 'passthrough') { + return; } ent.children.forEach(childConf => { - const child = this.config.sections[sectionIndex + 1]?.entities.find( - e => e.entity_id === getEntityId(childConf), - ); - if (!child) { - this.error = new Error(localize('common.missing_child') + ' ' + getEntityId(childConf)); - throw this.error; + const passthroughs: EntityConfigInternal[] = []; + const childId = getEntityId(childConf); + let child: EntityConfigInternal | undefined = ent; + for (let i = 1; i < this.config.sections.length; i++) { + child = this.config.sections[sectionIndex + i]?.entities.find( + e => e.entity_id === childId, + ); + if (!child) { + this.error = new Error(localize('common.missing_child') + ' ' + getEntityId(childConf)); + throw this.error; + } + if (child.type !== 'passthrough') { + break; + } + passthroughs.push(child); } const connection: ConnectionState = { parent: ent, - child, + child: child, state: 0, prevParentState: 0, prevChildState: 0, ready: false, + passthroughs, }; this.connections.push(connection); if (!this.connectionsByParent.has(ent)) { @@ -142,25 +154,18 @@ export class Chart extends LitElement { if (ent.type === 'remaining_child_state') { this.connectionsByParent.get(ent)!.forEach(c => { if (!c.ready) { - if (c.calculating && c !== connection) { - throw new Error('Circular reference detected in/near ' + JSON.stringify(ent)); - } this.connectionsByChild.get(c.child)?.forEach(conn => { - if (conn.parent !== parent) { + if (conn !== connection && !conn.calculating) { this._calcConnection(conn, accountedIn, accountedOut); } }); } }); - } - if (ent.type === 'remaining_parent_state') { - this.connectionsByChild.get(ent)?.forEach(c => { + } else if (ent.type === 'remaining_parent_state') { + this.connectionsByChild.get(ent)!.forEach(c => { if (!c.ready) { - if (c.calculating && c !== connection) { - throw new Error('Circular reference detected in/near ' + JSON.stringify(ent)); - } this.connectionsByParent.get(c.parent)?.forEach(conn => { - if (conn.child !== child) { + if (conn !== connection && !conn.calculating) { this._calcConnection(conn, accountedIn, accountedOut); } }); @@ -207,9 +212,6 @@ export class Chart extends LitElement { accountedIn.set(child, connection.prevChildState); this._calcConnection(connection, accountedIn, accountedOut, true); } - if (child.type === 'passthrough') { - this.entityStates.delete(child); - } } private _getMemoizedState(entityConfOrStr: EntityConfigInternal | string) { @@ -221,14 +223,7 @@ export class Chart extends LitElement { const normalized = normalizeStateValue(this.config.unit_prefix, Number(entity.state), unit_of_measurement); if (entityConf.type === 'passthrough') { - const connections = this.connectionsByChild.get(entityConf); - if (!connections) { - throw new Error('Invalid entity config ' + JSON.stringify(entityConf)); - } - const state = connections.reduce((sum, c) => (c.ready ? sum + c.state : Infinity), 0); - if (state !== Infinity) { - normalized.state = state; - } + normalized.state = this.connections.filter(c => c.passthroughs.includes(entityConf)).reduce((sum, c) => (c.ready ? sum + c.state : Infinity), 0); } if (entityConf.add_entities) { entityConf.add_entities.forEach(subId => { @@ -316,6 +311,7 @@ export class Chart extends LitElement { connections: { parents: [] }, top: 0, size: 0, + connectedParentState: 0, }; }); if (!boxes.length) { @@ -404,17 +400,34 @@ export class Chart extends LitElement { return { boxes: result, statePerPixelY: this.statePerPixelY }; } - private highlightPath(entityConf: EntityConfigInternal, direction: 'parents' | 'children') { + private highlightPath(entityConf: EntityConfigInternal, direction?: 'parents' | 'children') { this.highlightedEntities.push(entityConf); - if (direction === 'children') { - this.connectionsByParent.get(entityConf)?.forEach(c => { - c.highlighted = true; - this.highlightPath(c.child, 'children'); + if (!direction || direction === 'children') { + this.connections.forEach(c => { + if (c.passthroughs.includes(entityConf) || c.parent === entityConf) { + if (!c.highlighted) { + c.passthroughs.forEach(p => this.highlightedEntities.push(p)); + c.highlighted = true; + } + if (!this.highlightedEntities.includes(c.child)) { + this.highlightedEntities.push(c.child); + this.highlightPath(c.child, 'children'); + } + } }); - } else { - this.connectionsByChild.get(entityConf)?.forEach(c => { - c.highlighted = true; - this.highlightPath(c.parent, 'parents'); + } + if (!direction || direction === 'parents') { + this.connections.forEach(c => { + if (c.passthroughs.includes(entityConf) || c.child === entityConf) { + if (!c.highlighted) { + c.passthroughs.forEach(p => this.highlightedEntities.push(p)); + c.highlighted = true; + } + if (!this.highlightedEntities.includes(c.parent)) { + this.highlightedEntities.push(c.parent); + this.highlightPath(c.parent, 'parents'); + } + } }); } } @@ -428,8 +441,7 @@ export class Chart extends LitElement { } private _handleMouseEnter(box: Box): void { - this.highlightPath(box.config, 'children'); - this.highlightPath(box.config, 'parents'); + this.highlightPath(box.config); // trigger rerender this.highlightedEntities = [...this.highlightedEntities]; } @@ -485,16 +497,16 @@ export class Chart extends LitElement { ); return { ...childEntity, state, attributes: { ...childEntity.attributes, unit_of_measurement } }; } + if (entityConf.type === 'passthrough') { + const realConnection = this.connections.find(c => c.passthroughs.includes(entityConf)); + if (!realConnection) { + throw new Error('Invalid entity config ' + JSON.stringify(entityConf)); + } + return this._getEntityState(realConnection.child); + } let entity = this.states[getEntityId(entityConf)]; if (!entity) { - if (entityConf.type === 'passthrough') { - const connections = this.connectionsByParent.get(entityConf); - if (!connections) { - throw new Error('Invalid entity config ' + JSON.stringify(entityConf)); - } - return this._getEntityState(connections[0].child); - } throw new Error('Entity not found "' + getEntityId(entityConf) + '"'); } @@ -510,18 +522,22 @@ export class Chart extends LitElement { return entity; } - // find the first parent/child that is not type: passthrough + // find the first parent/child that is type: entity private _findRelatedRealEntity(entityConf: EntityConfigInternal, direction: 'parents' | 'children') { + let connection: ConnectionState | undefined; + if (entityConf.type === 'passthrough') { + connection = this.connections.find(c => c.passthroughs.includes(entityConf)); + } else { const connections = direction === 'parents' ? this.connectionsByChild.get(entityConf) : this.connectionsByParent.get(entityConf); if (!connections) { throw new Error('Invalid entity config ' + JSON.stringify(entityConf)); } - // a deep search on the first connection must be enough - const candidate = direction === 'parents' ? connections[0].parent : connections[0].child; - if (candidate.type !== 'passthrough') { - return candidate; - } - return this._findRelatedRealEntity(candidate, direction); + connection = connections[0]; + } + if (connection) { + return direction === 'parents' ? connection.parent : connection.child; + } + return entityConf; } static get styles(): CSSResultGroup { @@ -570,6 +586,7 @@ export class Chart extends LitElement { statePerPixelY: this.statePerPixelY, connectionsByParent: this.connectionsByParent, connectionsByChild: this.connectionsByChild, + allConnections: this.connections, onTap: this._handleBoxTap.bind(this), onDoubleTap: this._handleBoxDoubleTap.bind(this), onMouseEnter: this._handleMouseEnter.bind(this), diff --git a/src/section.ts b/src/section.ts index 219b83a..6c530ad 100644 --- a/src/section.ts +++ b/src/section.ts @@ -14,6 +14,7 @@ export function renderBranchConnectors(props: { statePerPixelY: number; connectionsByParent: Map; connectionsByChild: Map; + allConnections: ConnectionState[]; }): SVGTemplateResult[] { const { boxes } = props.section; return boxes @@ -22,28 +23,8 @@ export function renderBranchConnectors(props: { const children = props.nextSection!.boxes.filter(child => b.children.some(c => getEntityId(c) === child.entity_id), ); - const connections = getChildConnections(b, children, props.connectionsByParent.get(b.config)).filter((c, i) => { - if (c.state > 0) { - children[i].connections.parents.push(c); - if (children[i].config.type === 'passthrough') { - // @FIXME not sure if props is needed anymore after v1.0.0 - const sumState = - props.connectionsByChild.get(children[i].config)?.reduce((sum, conn) => sum + conn.state, 0) || 0; - if (sumState !== children[i].state) { - // virtual entity that must only pass state to the next section - children[i].state = sumState; - // props could reduce the size of the box moving lower boxes up - // so we have to add spacers and adjust some positions - const newSize = Math.floor(sumState / props.statePerPixelY); - children[i].extraSpacers = (children[i].size - newSize) / 2; - c.endY += children[i].extraSpacers!; - children[i].top += children[i].extraSpacers!; - children[i].size = newSize; - } - } - return true; - } - return false; + const connections = getChildConnections(b, children, props.allConnections, props.connectionsByParent).filter(c => { + return c.state > 0; }); return svg` @@ -78,6 +59,7 @@ export function renderSection(props: { statePerPixelY: number; connectionsByParent: Map; connectionsByChild: Map; + allConnections: ConnectionState[]; onTap: (config: Box) => void; onDoubleTap: (config: Box) => void; onMouseEnter: (config: Box) => void; diff --git a/src/types.ts b/src/types.ts index a5eebb2..1a903b9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,6 +174,7 @@ export interface Box { connections: { parents: Connection[]; }; + connectedParentState: number; } export interface SectionState { @@ -193,6 +194,7 @@ export interface ConnectionState { ready: boolean; calculating?: boolean; highlighted?: boolean; + passthroughs: EntityConfigInternal[]; } export interface NormalizedState { diff --git a/src/utils.ts b/src/utils.ts index fb473a0..766ca8a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,7 @@ import { Config, Connection, ConnectionState, + EntityConfigInternal, EntityConfigOrStr, SankeyChartConfig, Section, @@ -61,15 +62,21 @@ export function getEntityId(entity: EntityConfigOrStr | ChildConfigOrStr): strin return typeof entity === 'string' ? entity : entity.entity_id; } -export function getChildConnections(parent: Box, children: Box[], connections?: ConnectionState[]): Connection[] { +export function getChildConnections(parent: Box, children: Box[], allConnections: ConnectionState[], connectionsByParent: Map): Connection[] { // @NOTE don't take prevParentState from connection because it is different let prevParentState = 0; + let state = 0; + const childConnections = connectionsByParent.get(parent.config); return children.map(child => { - const connection = connections?.find(c => c.child.entity_id === child.entity_id); - if (!connection) { - throw new Error(`Missing connection: ${parent.entity_id} - ${child.entity_id}`); + let connections = childConnections?.filter(c => c.child.entity_id === child.entity_id); + if (!connections?.length) { + connections = allConnections + .filter(c => c.passthroughs.includes(child) || c.passthroughs.includes(parent.config)); + if (!connections.length) { + throw new Error(`Missing connection: ${parent.entity_id} - ${child.entity_id}`); + } } - const { state, prevChildState } = connection; + state = connections.reduce((sum, c) => sum + c.state, 0); if (state <= 0) { // only continue if this connection will be rendered return { state } as Connection; @@ -77,9 +84,11 @@ export function getChildConnections(parent: Box, children: Box[], connections?: const startY = (prevParentState / parent.state) * parent.size + parent.top; prevParentState += state; const startSize = Math.max((state / parent.state) * parent.size, 0); - const endY = (prevChildState / child.state) * child.size + child.top; + const endY = (child.connectedParentState / child.state) * child.size + child.top; const endSize = Math.max((state / child.state) * child.size, 0); + child.connectedParentState += state; + return { startY, startSize, @@ -88,7 +97,7 @@ export function getChildConnections(parent: Box, children: Box[], connections?: endSize, endColor: child.color, state, - highlighted: connection.highlighted, + highlighted: connections.some(c => c.highlighted), }; }); }