diff --git a/src/chart.ts b/src/chart.ts index 4e2c14f..d2e84b1 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -1,19 +1,19 @@ -import { LitElement, html, svg, TemplateResult, SVGTemplateResult, PropertyValues, CSSResultGroup } from 'lit'; +import { LitElement, html, TemplateResult, PropertyValues, CSSResultGroup } from 'lit'; import { styleMap } from 'lit/directives/style-map'; import { classMap } from 'lit/directives/class-map'; import { until } from 'lit/directives/until.js'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property, state } from 'lit/decorators'; -import { HomeAssistant, stateIcon } from 'custom-card-helpers'; // This is a community maintained npm module with common helper functions/types. https://github.com/custom-cards/custom-card-helpers +import { HomeAssistant } from 'custom-card-helpers'; // This is a community maintained npm module with common helper functions/types. https://github.com/custom-cards/custom-card-helpers import type { Config, SectionState, Box, ConnectionState, EntityConfigInternal, NormalizedState } from './types'; -import { MIN_LABEL_HEIGHT } from './const'; import { localize } from './localize/localize'; import styles from './styles'; -import { formatState, getChildConnections, getEntityId, normalizeStateValue, renderError } from './utils'; +import { getEntityId, normalizeStateValue, renderError, sortBoxes } from './utils'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { handleAction } from './handle-actions'; import { filterConfigByZoomEntity } from './zoom'; +import './section'; @customElement('sankey-chart-base') export class Chart extends LitElement { @@ -279,10 +279,11 @@ export class Chart extends LitElement { const calcResults = this._calcBoxHeights(boxes, availableHeight, total); const parentBoxes = section.sort_group_by_parent ? sectionsStates[sectionsStates.length - 1]?.boxes || [] : []; sectionsStates.push({ - boxes: this._sortBoxes(parentBoxes, calcResults.boxes, section.sort_by, section.sort_dir), + boxes: sortBoxes(parentBoxes, calcResults.boxes, section.sort_by, section.sort_dir), total, statePerPixelY: calcResults.statePerPixelY, spacerH: 0, + config: section, }); }); @@ -323,23 +324,6 @@ export class Chart extends LitElement { }); } - private _sortBoxes(parentBoxes: Box[], boxes: Box[], sort?: string, dir = 'desc') { - if (sort === 'state') { - const sortByParent = (a: Box, b: Box, realSort: (a: Box, b: Box) => number) => { - const parentIndexA = parentBoxes.findIndex(p => p.children.includes(a.entity_id)); - const parentIndexB = parentBoxes.findIndex(p => p.children.includes(b.entity_id)); - return parentIndexA < parentIndexB ? -1 : parentIndexA > parentIndexB ? 1 : realSort(a, b); - }; - - if (dir === 'desc') { - boxes.sort((a, b) => sortByParent(a, b, (a, b) => (a.state > b.state ? -1 : a.state < b.state ? 1 : 0))); - } else { - boxes.sort((a, b) => sortByParent(a, b, (a, b) => (a.state < b.state ? -1 : a.state > b.state ? 1 : 0))); - } - } - return boxes; - } - private _calcBoxHeights( boxes: Box[], availableHeight: number, @@ -460,143 +444,6 @@ export class Chart extends LitElement { return styles; } - protected renderSection(index: number): TemplateResult { - const { show_names, show_icons, show_states, show_units } = this.config; - const section = this.sections[index]; - const { boxes, spacerH } = section; - const hasChildren = index < this.sections.length - 1 && boxes.some(b => b.children.length > 0); - const { min_width: minWidth } = this.config.sections[index]; - - return html` -
- ${hasChildren - ? html`
- - ${this.renderBranchConnectors(index)} - -
` - : null} - ${boxes.map((box, i) => { - const { entity, extraSpacers } = box; - const formattedState = formatState(box.state, this.config.round); - const isNotPassthrough = box.config.type !== 'passthrough'; - const name = box.config.name || entity.attributes.friendly_name || ''; - const icon = box.config.icon || stateIcon(entity as HassEntity); - const maxLabelH = box.size + spacerH - 1; - - // reduce label size if it doesn't fit - const labelStyle: Record = { lineHeight: MIN_LABEL_HEIGHT + 'px' }; - const nameStyle: Record = {}; - if (maxLabelH < MIN_LABEL_HEIGHT) { - const fontSize = maxLabelH / MIN_LABEL_HEIGHT; - // labelStyle.maxHeight = maxLabelH + 'px'; - labelStyle.fontSize = `${fontSize}em`; - labelStyle.lineHeight = `${fontSize}em`; - } - const numLines = name.split('\n').filter(v => v).length; - if (numLines > 1) { - nameStyle.whiteSpace = 'pre'; - if (labelStyle.fontSize) { - nameStyle.fontSize = `${1 / numLines + 0.1}rem`; - nameStyle.lineHeight = `${1 / numLines + 0.1}rem`; - } else if (maxLabelH < MIN_LABEL_HEIGHT * numLines) { - nameStyle.fontSize = `${(maxLabelH / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; - nameStyle.lineHeight = `${(maxLabelH / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; - } - } - - return html` - ${i > 0 ? html`
` : null} - ${extraSpacers - ? html`
` - : null} -
-
this._handleBoxTap(box)} - @dblclick=${() => this._handleBoxDoubleTap(box)} - @mouseenter=${() => this._handleMouseEnter(box)} - @mouseleave=${this._handleMouseLeave} - title=${formattedState + box.unit_of_measurement + ' ' + name} - class=${this.highlightedEntities.includes(box.config) ? 'hl' : ''} - > - ${show_icons && isNotPassthrough - ? html`` - : null} -
-
- ${show_states && isNotPassthrough - ? html`${formattedState}${show_units - ? html`${box.unit_of_measurement}` - : null}` - : null} - ${show_names && isNotPassthrough - ? html` ${name}` - : null} -
-
- ${extraSpacers - ? html`
` - : null} - `; - })} -
- `; - } - - protected renderBranchConnectors(index: number): SVGTemplateResult[] { - const section = this.sections[index]; - const { boxes } = section; - return boxes - .filter(b => b.children.length > 0) - .map(b => { - const children = this.sections[index + 1].boxes.filter(child => b.children.includes(child.entity_id)); - const connections = getChildConnections(b, children, this.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 this is needed anymore after v1.0.0 - const sumState = - this.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; - // this 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 / this.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; - }); - return svg` - - ${connections.map( - (c, i) => svg` - - - - - `, - )} - - ${connections.map( - (c, i) => svg` - - `, - )} - `; - }); - } - // https://lit.dev/docs/components/rendering/ protected render(): TemplateResult | void { try { @@ -625,7 +472,21 @@ export class Chart extends LitElement { return html`
- ${this.sections.map((s, i) => this.renderSection(i))} + ${this.sections.map( + (s, i) => html` `, + )}
`; diff --git a/src/editor.ts.old b/src/editor.ts.old deleted file mode 100644 index e69de29..0000000 diff --git a/src/editor/index.ts b/src/editor/index.ts index f6e4af6..9f714a2 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -5,7 +5,7 @@ import { HomeAssistant, fireEvent, LovelaceCardEditor, LovelaceConfig } from 'cu // eslint-disable-next-line @typescript-eslint/no-unused-vars import { customElement, property, state } from 'lit/decorators'; import { repeat } from 'lit/directives/repeat'; -import { EntityConfig, SankeyChartConfig, SectionConfig } from '../types'; +import { SankeyChartConfig, SectionConfig } from '../types'; import { localize } from '../localize/localize'; import { getEntityId, normalizeConfig } from '../utils'; import './entity'; @@ -132,7 +132,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor this._entityConfig = { sectionIndex, entityIndex, entity: sections[sectionIndex].entities[entityIndex] }; } - private _handleEntityConfig = (entityConf: EntityConfig): void => { + private _handleEntityConfig = (entityConf: EntityConfigOrStr): void => { this._editEntity({ detail: { value: entityConf }, target: { section: this._entityConfig?.sectionIndex, index: this._entityConfig?.entityIndex }, diff --git a/src/energy.ts b/src/energy.ts index 2ea6d6c..da6a67a 100644 --- a/src/energy.ts +++ b/src/energy.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { HomeAssistant } from "custom-card-helpers"; import { Collection } from "home-assistant-js-websocket"; -import { addHours, differenceInDays } from 'date-fns'; +import { differenceInDays } from 'date-fns'; export const ENERGY_SOURCE_TYPES = ['grid', 'solar', 'battery']; diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index 9d011f2..287738d 100644 --- a/src/ha-sankey-chart.ts +++ b/src/ha-sankey-chart.ts @@ -9,7 +9,6 @@ import { localize } from './localize/localize'; import { normalizeConfig, renderError } from './utils'; import { SubscribeMixin } from './subscribe-mixin'; import './chart'; -import { Chart } from './chart'; import { HassEntities } from 'home-assistant-js-websocket'; import { EnergyCollection, @@ -55,8 +54,6 @@ export class SankeyChart extends SubscribeMixin(LitElement) { // https://lit.dev/docs/components/properties/ @property({ attribute: false }) public hass!: HomeAssistantReal; - @query('ha-chart-base') private _chart?: Chart; - @state() private config!: Config; @state() private states: HassEntities = {}; @state() private entityIds: string[] = []; diff --git a/src/section.ts b/src/section.ts new file mode 100644 index 0000000..473e564 --- /dev/null +++ b/src/section.ts @@ -0,0 +1,164 @@ +import { LitElement, html, svg, SVGTemplateResult } from 'lit'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { customElement, property } from 'lit/decorators'; +import { styleMap } from 'lit/directives/style-map'; +import { Box, Config, ConnectionState, EntityConfigInternal, SectionState } from './types'; +import { formatState, getChildConnections } from './utils'; +import { stateIcon } from 'custom-card-helpers'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { MIN_LABEL_HEIGHT } from './const'; + +@customElement('sankey-chart-section') +class Section extends LitElement { + @property({ attribute: false }) public config!: Config; + @property({ attribute: false }) public section!: SectionState; + @property({ attribute: false }) public nextSection?: SectionState; + @property({ attribute: false }) public highlightedEntities!: EntityConfigInternal[]; + @property({ attribute: false }) public statePerPixelY!: number; + @property({ attribute: false }) public connectionsByParent: Map = new Map(); + @property({ attribute: false }) public connectionsByChild: Map = new Map(); + @property({ attribute: false }) public onTap!: (config: Box) => void; + @property({ attribute: false }) public onDoubleTap!: (config: Box) => void; + @property({ attribute: false }) public onMouseEnter!: (config: Box) => void; + @property({ attribute: false }) public onMouseLeave!: () => void; + + protected renderBranchConnectors(): SVGTemplateResult[] { + const { boxes } = this.section; + return boxes + .filter(b => b.children.length > 0) + .map(b => { + const children = this.nextSection!.boxes.filter(child => b.children.includes(child.entity_id)); + const connections = getChildConnections(b, children, this.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 this is needed anymore after v1.0.0 + const sumState = + this.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; + // this 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 / this.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; + }); + return svg` + + ${connections.map( + (c, i) => svg` + + + + + `, + )} + + ${connections.map( + (c, i) => svg` + + `, + )} + `; + }); + } + + public render() { + const { show_names, show_icons, show_states, show_units } = this.config; + const { + boxes, + spacerH, + config: { min_width: minWidth }, + } = this.section; + const hasChildren = this.nextSection && boxes.some(b => b.children.length > 0); + + return html` +
+ ${hasChildren + ? html`
+ + ${this.renderBranchConnectors()} + +
` + : null} + ${boxes.map((box, i) => { + const { entity, extraSpacers } = box; + const formattedState = formatState(box.state, this.config.round); + const isNotPassthrough = box.config.type !== 'passthrough'; + const name = box.config.name || entity.attributes.friendly_name || ''; + const icon = box.config.icon || stateIcon(entity as HassEntity); + const maxLabelH = box.size + spacerH - 1; + + // reduce label size if it doesn't fit + const labelStyle: Record = { lineHeight: MIN_LABEL_HEIGHT + 'px' }; + const nameStyle: Record = {}; + if (maxLabelH < MIN_LABEL_HEIGHT) { + const fontSize = maxLabelH / MIN_LABEL_HEIGHT; + // labelStyle.maxHeight = maxLabelH + 'px'; + labelStyle.fontSize = `${fontSize}em`; + labelStyle.lineHeight = `${fontSize}em`; + } + const numLines = name.split('\n').filter(v => v).length; + if (numLines > 1) { + nameStyle.whiteSpace = 'pre'; + if (labelStyle.fontSize) { + nameStyle.fontSize = `${1 / numLines + 0.1}rem`; + nameStyle.lineHeight = `${1 / numLines + 0.1}rem`; + } else if (maxLabelH < MIN_LABEL_HEIGHT * numLines) { + nameStyle.fontSize = `${(maxLabelH / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; + nameStyle.lineHeight = `${(maxLabelH / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; + } + } + + return html` + ${i > 0 ? html`
` : null} + ${extraSpacers + ? html`
` + : null} +
+
this.onTap(box)} + @dblclick=${() => this.onDoubleTap(box)} + @mouseenter=${() => this.onMouseEnter(box)} + @mouseleave=${this.onMouseLeave} + title=${formattedState + box.unit_of_measurement + ' ' + name} + class=${this.highlightedEntities.includes(box.config) ? 'hl' : ''} + > + ${show_icons && isNotPassthrough + ? html`` + : null} +
+
+ ${show_states && isNotPassthrough + ? html`${formattedState}${show_units + ? html`${box.unit_of_measurement}` + : null}` + : null} + ${show_names && isNotPassthrough + ? html` ${name}` + : null} +
+
+ ${extraSpacers + ? html`
` + : null} + `; + })} +
+ `; + } +} + +export default Section; diff --git a/src/subscribe-mixin.ts b/src/subscribe-mixin.ts index 8e05e0d..6af9373 100644 --- a/src/subscribe-mixin.ts +++ b/src/subscribe-mixin.ts @@ -5,6 +5,7 @@ import { HomeAssistant } from "custom-card-helpers"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { PropertyValues, ReactiveElement } from "lit"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars import { property } from "lit/decorators"; export interface HassSubscribeElement { diff --git a/src/types.ts b/src/types.ts index 1e20879..4ee1a44 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,6 +163,7 @@ export interface SectionState { total: number; spacerH: number; statePerPixelY: number; + config: Section; } export interface ConnectionState { diff --git a/src/utils.ts b/src/utils.ts index 793e312..d59a744 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -183,6 +183,23 @@ export function normalizeConfig(conf: SankeyChartConfig): Config { }; } +export function sortBoxes(parentBoxes: Box[], boxes: Box[], sort?: string, dir = 'desc') { + if (sort === 'state') { + const sortByParent = (a: Box, b: Box, realSort: (a: Box, b: Box) => number) => { + const parentIndexA = parentBoxes.findIndex(p => p.children.includes(a.entity_id)); + const parentIndexB = parentBoxes.findIndex(p => p.children.includes(b.entity_id)); + return parentIndexA < parentIndexB ? -1 : parentIndexA > parentIndexB ? 1 : realSort(a, b); + }; + + if (dir === 'desc') { + boxes.sort((a, b) => sortByParent(a, b, (a, b) => (a.state > b.state ? -1 : a.state < b.state ? 1 : 0))); + } else { + boxes.sort((a, b) => sortByParent(a, b, (a, b) => (a.state < b.state ? -1 : a.state > b.state ? 1 : 0))); + } + } + return boxes; +} + // private _showWarning(warning: string): TemplateResult { // return html` // ${warning}