From 71354bd1d14d5c6de832077c2f8dc5440509222c Mon Sep 17 00:00:00 2001 From: Mindfreeze Date: Sun, 1 Oct 2023 16:46:25 +0300 Subject: [PATCH] feat(#104): Drilldown / Zoom action --- README.md | 6 +++- __tests__/zoom.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++ src/chart.ts | 37 +++++++++------------- src/handle-actions.ts | 21 ++++++++----- src/types.ts | 8 ++++- src/zoom.ts | 21 +++++++++++++ 6 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 __tests__/zoom.test.ts create mode 100644 src/zoom.ts diff --git a/README.md b/README.md index a584598..fa9edde 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ This card is intended to display connections between entities with numeric state | color_below | string | **Optional** | var(--primary-color)| Color for state value below color_limit | add_entities | list | **Optional** | | Experimental. List of entity ids. Their states will be added to this entity, showing a sum. | subtract_entities | list | **Optional** | | Experimental. List of entity ids. Their states will be subtracted from this entity's state -| tap_action | action | **Optional** | more-info | Home assistant action to perform on tap. Supported action types are `more-info`, `navigate`, `url`, `toggle`, `call-service`, `fire-dom-event` +| tap_action | action | **Optional** | more-info | Home assistant action to perform on tap. Supported action types are `more-info`, `zoom`, `navigate`, `url`, `toggle`, `call-service`, `fire-dom-event` ### Entity types @@ -204,6 +204,10 @@ Currently this chart just shows historical data based on a energy-date-selection **A:** The easiest way is to do it with a template sensor in HA. However it can be done in the chart without a new HA entity. If you have an entity with `type: remaining_parent_state` and it is the only child of its parents, it will just be a sum of all the parents. Similarly if you have an entity with `type: remaining_child_state` and it is the only parent of all its children, it will be a sum of all the children. +**Q: How do I zoom back out after using the zoom action?** + +**A:** Tap the same (currently top level) entity again to reset the zoom level. + ## Development 1. `npm i` diff --git a/__tests__/zoom.test.ts b/__tests__/zoom.test.ts new file mode 100644 index 0000000..e94063a --- /dev/null +++ b/__tests__/zoom.test.ts @@ -0,0 +1,69 @@ +import type { Config } from '../src/types'; +import { filterConfigByZoomEntity } from '../src/zoom'; + +const config = { + type: '', + sections: [ + { + entities: [ + { + entity_id: 'ent1', + children: ['ent2', 'ent3'], + }, + ], + }, + { + entities: [ + { + entity_id: 'ent2', + children: ['ent4'], + }, + { + entity_id: 'ent3', + children: ['ent5'], + }, + ], + }, + { + entities: [ + { + entity_id: 'ent4', + children: [], + }, + { + entity_id: 'ent5', + children: [], + }, + ], + }, + ], +} as Config; + +describe('zoom action', () => { + it('filters a config based on zoom entity', async () => { + expect(filterConfigByZoomEntity(config, config.sections[1].entities[0])).toEqual({ + type: '', + sections: [ + { + entities: [ + { + entity_id: 'ent2', + children: ['ent4'], + }, + ], + }, + { + entities: [ + { + entity_id: 'ent4', + children: [], + }, + ], + }, + ], + }); + }); + it('returns the same config when there is no zoom entity', async () => { + expect(filterConfigByZoomEntity(config, undefined)).toEqual(config); + }); +}); diff --git a/src/chart.ts b/src/chart.ts index 9d38819..494d971 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -13,6 +13,7 @@ import styles from './styles'; import { formatState, getChildConnections, getEntityId, normalizeStateValue, renderError } from './utils'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { handleAction } from './handle-actions'; +import { filterConfigByZoomEntity } from './zoom'; @customElement('sankey-chart-base') export class Chart extends LitElement { @@ -31,13 +32,14 @@ export class Chart extends LitElement { @state() private entityStates: Map = new Map(); @state() private highlightedEntities: EntityConfigInternal[] = []; @state() private lastUpdate = 0; + @state() public zoomEntity?: EntityConfigInternal; // https://lit.dev/docs/components/lifecycle/#reactive-update-cycle-performing protected shouldUpdate(changedProps: PropertyValues): boolean { if (!this.config) { return false; } - if (changedProps.has('forceUpdateTs')) { + if (changedProps.has('config') || changedProps.has('forceUpdateTs') || changedProps.has('highlightedEntities') || changedProps.has('zoomEntity')) { return true; } const now = Date.now(); @@ -52,12 +54,7 @@ export class Chart extends LitElement { }, now - this.lastUpdate); return false; } - if (changedProps.has('highlightedEntities')) { - return true; - } - if (changedProps.has('config')) { - return true; - } + const oldStates = changedProps.get('states') as HomeAssistant | undefined; if (!oldStates) { return false; @@ -224,7 +221,8 @@ export class Chart extends LitElement { private _calcBoxes() { this.statePerPixelY = 0; - this.sections = this.config.sections + const filteredConfig = filterConfigByZoomEntity(this.config, this.zoomEntity); + this.sections = filteredConfig.sections .map(section => { let total = 0; const boxes: Box[] = section.entities @@ -379,10 +377,14 @@ export class Chart extends LitElement { } } - private _handleBoxClick(box: Box): void { + private _handleBoxTap(box: Box): void { handleAction(this, this.hass, box.config, 'tap'); } + private _handleBoxDoubleTap(box: Box): void { + handleAction(this, this.hass, box.config, 'double_tap'); + } + private _handleMouseEnter(box: Box): void { this.highlightPath(box.config, 'children'); this.highlightPath(box.config, 'parents'); @@ -397,13 +399,6 @@ export class Chart extends LitElement { }); } - // private _handleAction(ev: ActionHandlerEvent): void { - // console.log('@TODO'); - // if (this.hass && this.config && ev.detail.action) { - // // handleAction(this, this.hass, this.config, ev.detail.action); - // } - // } - private _getEntityState(entityConf: EntityConfigInternal) { if (entityConf.type === 'remaining_parent_state') { const connections = this.connectionsByChild.get(entityConf); @@ -510,7 +505,8 @@ export class Chart extends LitElement {
this._handleBoxClick(box)} + @click=${() => this._handleBoxTap(box)} + @dblclick=${() => this._handleBoxDoubleTap(box)} @mouseenter=${() => this._handleMouseEnter(box)} @mouseleave=${this._handleMouseLeave} title=${name} @@ -616,11 +612,6 @@ export class Chart extends LitElement { this.lastUpdate = Date.now(); - // @action=${this._handleAction} - // .actionHandler=${actionHandler({ - // hasHold: hasAction(this.config.hold_action), - // hasDoubleClick: hasAction(this.config.double_tap_action), - // })} return html`
@@ -634,3 +625,5 @@ export class Chart extends LitElement { } } } + +export default Chart; \ No newline at end of file diff --git a/src/handle-actions.ts b/src/handle-actions.ts index 0c1c9f4..1d3612a 100644 --- a/src/handle-actions.ts +++ b/src/handle-actions.ts @@ -1,5 +1,6 @@ import { HomeAssistant, fireEvent, forwardHaptic, navigate, toggleEntity } from "custom-card-helpers"; -import { ActionConfigExtended } from "./types"; +import type { EntityConfigInternal } from "./types"; +import type Chart from "./chart"; interface ToastActionParams { action: () => void; @@ -15,14 +16,9 @@ const showToast = (el: HTMLElement, params: ShowToastParams) => fireEvent(el, "hass-notification", params); export const handleAction = async ( - node: HTMLElement, + node: Chart, hass: HomeAssistant, - config: { - entity_id: string; - hold_action?: ActionConfigExtended; - tap_action?: ActionConfigExtended; - double_tap_action?: ActionConfigExtended; - }, + config: EntityConfigInternal, action: string ): Promise => { let actionConfig = config.tap_action; @@ -119,6 +115,15 @@ export const handleAction = async ( } case "fire-dom-event": { fireEvent(node, "ll-custom", actionConfig); + break; + } + case "zoom": { + if (node.zoomEntity === config) { + node.zoomEntity = undefined; + break; + } + node.zoomEntity = config; + break; } } }; diff --git a/src/types.ts b/src/types.ts index 0012825..5b98587 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,6 +36,8 @@ export interface EntityConfig { color_below?: string; color_limit?: number; tap_action?: ActionConfigExtended; + double_tap_action?: ActionConfigExtended; + hold_action?: ActionConfigExtended; // @deprecated remaining?: | string @@ -53,7 +55,7 @@ export type EntityConfigInternal = EntityConfig & { export type EntityConfigOrStr = string | EntityConfig; -export type ActionConfigExtended = ActionConfig | CallServiceActionConfig | MoreInfoActionConfig; +export type ActionConfigExtended = ActionConfig | CallServiceActionConfig | MoreInfoActionConfig | ZoomActionConfig; export interface MoreInfoActionConfig extends BaseActionConfig { action: 'more-info'; @@ -63,6 +65,10 @@ export interface MoreInfoActionConfig extends BaseActionConfig { }; } +export interface ZoomActionConfig extends BaseActionConfig { + action: 'zoom'; +} + export interface CallServiceActionConfig extends BaseActionConfig { action: 'call-service'; service: string; diff --git a/src/zoom.ts b/src/zoom.ts new file mode 100644 index 0000000..898998f --- /dev/null +++ b/src/zoom.ts @@ -0,0 +1,21 @@ +import { Config, EntityConfigInternal } from "./types"; + +export function filterConfigByZoomEntity(config: Config, zoomEntity?: EntityConfigInternal) { + if (!zoomEntity) { + return config; + } + let children: string[] = []; + const newSections = config.sections.map(section => { + const newEntities = section.entities.filter(entity => entity === zoomEntity || children.includes(entity.entity_id)); + children = newEntities.flatMap(entity => entity.children); + return { + ...section, + entities: newEntities, + }; + }).filter(section => section.entities.length > 0); + + return { + ...config, + sections: newSections, + }; +} \ No newline at end of file