From b8b493d56d63348afd10c28ab4ab6f63faf884fe Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 23 Apr 2024 14:29:36 +0300 Subject: [PATCH] feat!(#109): vertical layout (#167) Release-As: 3.0.0-beta --- README.md | 14 +- __tests__/__snapshots__/basic.test.ts.snap | 88 +++++++--- .../__snapshots__/remaining.test.ts.snap | 154 ++++++++++++------ __tests__/setupTests.ts | 8 +- src/chart.ts | 94 ++++++----- src/const.ts | 6 + src/editor/index.ts | 26 ++- src/ha-sankey-chart.ts | 5 +- src/label.ts | 73 +++++++++ src/layout.ts | 13 ++ src/localize/languages/en.json | 8 +- src/localize/languages/sk.json | 2 +- src/section.ts | 109 ++++++------- src/styles.ts | 49 +++++- src/types.ts | 79 ++++----- src/utils.ts | 4 +- 16 files changed, 496 insertions(+), 236 deletions(-) create mode 100644 src/label.ts create mode 100644 src/layout.ts diff --git a/README.md b/README.md index da93165..e846296 100644 --- a/README.md +++ b/README.md @@ -22,23 +22,24 @@ This card is intended to display connections between entities with numeric state | type | string | **Required** | | `custom:sankey-chart` | autoconfig | object | **Optional** | | Experimental. See [autoconfig](#autoconfig) | sections | list | **Required** | | Not required if using autoconfig. Entities to show divided by sections, see [sections object](#sections-object) for additional options. +| layout | string | **Optional** | auto | Valid options are: 'horizontal' - flow left to right, 'vertical' - flow top to bottom & 'auto' - determine based on available space | energy_date_selection | boolean | **Optional** | false | Integrate with the Energy Dashboard. Filters data based on the [energy-date-selection](https://www.home-assistant.io/dashboards/energy/) card. Use this only for accumulated data sensors (energy/water/gas) and with a `type:energy-date-selection` card. You still need to specify all your entities as HA doesn't know exactly how to connect them but you can use the general kWh entities that you have in the energy dashboard. In the future we may use areas to auto configure the chart. | title | string | **Optional** | | Optional header title for the card | unit_prefix | string | **Optional** | | Metric prefix for the unit of measurment. See . Supported values are m, k, M, G, T | round | number | **Optional** | 0 | Round the value to at most N decimal places. May not apply to near zero values, see issue [#29](https://github.com/MindFreeze/ha-sankey-chart/issues/29) -| height | number | **Optional** | 200 | The height of the card in pixels -| wide | boolean | **Optional** | false | Set this to true if you see extra empty space in the right side of the card. This will expand it horizontally to cover all the available space. Enable if you see empty space on the right size. +| height | number | **Optional** | 200 | The height of the card in pixels. Only matters while in horizontal layout. Vertical layout height is dynamic based on content +| wide | boolean | **Optional** | false | Set this to true if you see extra empty space in the right side of the card. This will expand it horizontally to cover all the available space. Enable if you see empty space on the right size | show_icons | boolean | **Optional** | false | Display entity icons | show_names | boolean | **Optional** | false | Display entity names | show_states | boolean | **Optional** | true | Display entity states | show_units | boolean | **Optional** | true | Display unit of measurement -| min_box_height | number | **Optional** | 3 | Minimum size of an entity box +| min_box_size | number | **Optional** | 3 | Minimum size of an entity box | min_box_distance | number | **Optional** | 5 | Minimum space between entity boxes | min_state | number | **Optional** | >0 | Any entity below this value will not be displayed. Only positive numbers above 0 are allowed. The default is to show everything above 0. | throttle | number | **Optional** | | Minimum time in ms between updates/rerenders -| static_scale | number | **Optional** | | State value corresponding to the maximum height size of the card. For example, if this is set to 1000, then a box with state 500 will take up half of its section. If some section exceeds the value of `static_scale`, the card will dynamically rescale overriding this option. See (#153) +| static_scale | number | **Optional** | | State value corresponding to the maximum size (height for horizontal layout and width in vertical) of the card. For example, if this is set to 1000, then a box with state 500 will take up half of its section. If some section exceeds the value of `static_scale`, the card will dynamically rescale overriding this option. See (#153) | convert_units_to | string | **Optional** | | If entities are electricity (kWh) or gas (ft³) usage, convert them to energy (MJ), cost (monetary) or carbon (gCO2). For cost, you must also specify `electricity_price` and/or `gas_price`, as well as the `monetary_unit` of the price(s). For gCO2, all kWh values will be multiplied by the varying grid CO2 intensity, as with the Energy Dashboard. -| co2_intensity_entity |string | **Optional** | sensor.co2_signal_co2_intensity | Entity providing carbon intensity of electricity (gCO2eq/kWh). If you have solar or storage, you may wish to create a template sensor to convert grid CO2 intensity to consumption CO2 intensity. +| co2_intensity_entity |string | **Optional** | sensor. co2_signal_co2_intensity | Entity providing carbon intensity of electricity (gCO2eq/kWh). If you have solar or storage, you may wish to create a template sensor to convert grid CO2 intensity to consumption CO2 intensity. | gas_co2_intensity | number | **Optional** | 66.6 g/ft³ or 2352 g/m³ | Carbon intensity of gas, e.g. in gCO2eq/ft³. Default value depends on locale; units must match those of gas entities. | electricity_price | number | **Optional** | | Unit price of electricity, e.g. in USD/kWh. Automatic conversion does not support varying electricity prices like the Energy Dashboard does. | gas_price | number | **Optional** | | Unit price of gas, e.g. in USD/ft³. @@ -54,7 +55,7 @@ This card is intended to display connections between entities with numeric state | sort_by | string | **Optional** | | Sort the entities in this section. Overrides the top level option | sort_dir | string | **Optional** | desc | Sorting direction for this section. Overrides the top level option | sort_group_by_parent | boolean | **Optional** | false | Group entities by parent before sorting. See #135 -| min_width | string | **Optional** | | Minimum section width. Any CSS value is OK. Examples: 75px, 50%, 1em +| min_width | number | **Optional** | | Minimum section width in pixels. Only relevant while in horizontal layout ### Entities object @@ -245,7 +246,6 @@ Currently this chart just shows historical data based on a energy-date-selection [commits-shield]: https://img.shields.io/github/commit-activity/y/MindFreeze/ha-sankey-chart.svg?style=for-the-badge [commits]: https://github.com//MindFreeze/ha-sankey-chart/commits/master -[devcontainer]: https://code.visualstudio.com/docs/remote/containers [license-shield]: https://img.shields.io/github/license/MindFreeze/ha-sankey-chart.svg?style=for-the-badge [maintenance-shield]: https://img.shields.io/maintenance/yes/2023.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/MindFreeze/ha-sankey-chart.svg?style=for-the-badge diff --git a/__tests__/__snapshots__/basic.test.ts.snap b/__tests__/__snapshots__/basic.test.ts.snap index f25866d..ec025f8 100644 --- a/__tests__/__snapshots__/basic.test.ts.snap +++ b/__tests__/__snapshots__/basic.test.ts.snap @@ -15,27 +15,25 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
- - + - + - - - - - - + + + + +
@@ -45,9 +43,11 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
- - -
+ + W + + + @@ -63,9 +63,11 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
- - -
+ + W + + + @@ -76,9 +78,11 @@ exports[`SankeyChart matches a simple snapshot 2`] = `
- - -
+ + W + + + @@ -95,10 +99,14 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` position: relative; /* height: 210px; */ padding: 16px; + overflow: hidden; } .container.with-header { padding-top: 0; } + .container.vertical { + flex-direction: column; + } .section { flex: 1; flex-direction: column; @@ -106,11 +114,23 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` min-width: 0; max-width: 50%; } + .vertical .section { + display: flex; + flex: initial; + flex-direction: row-reverse; + align-items: flex-start; + max-width: 100%; + width: 100%; + height: 150px; + } .wide .section:last-child { flex: initial; } .spacerv { - transition: height 0.2s; + transition: height 0.25s; + } + .vertical .spacerv { + transition: width 0.25s; } .box { display: flex; @@ -119,6 +139,10 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` /* min-height: 1px; */ transition: height 0.25s; } + .vertical .box { + flex-direction: column; + transition: width 0.25s; + } /* .box::before { content: ""; position: absolute; @@ -140,6 +164,10 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` height: 100%; cursor: pointer; } + .vertical .box div:first-child { + width: 100%; + height: 15px; + } .box.type-passthrough div:first-child { opacity: 0.4; } @@ -155,8 +183,16 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` overflow: hidden; text-overflow: ellipsis; } - .box .label span { + .vertical .box .label { + padding: 5px 0 0; + flex-direction: column; + white-space: normal; + /* word-break: break-all; */ + text-align: center; + } + .box .label .name { font-style: italic; + font-size: inherit; } .connectors { position: absolute; @@ -166,11 +202,23 @@ exports[`SankeyChart matches a simple snapshot 2`] = ` height: 100%; overflow: hidden; } + .vertical .connectors { + top: 15px; + left: 0; + bottom: 0; + height: auto; + } .connectors svg { position: absolute; left: -1px; width: 101%; height: 100%; } + .vertical .connectors svg { + top: -1px; + left: 0; + width: 100%; + height: 101%; + } " `; diff --git a/__tests__/__snapshots__/remaining.test.ts.snap b/__tests__/__snapshots__/remaining.test.ts.snap index 220d258..43f211d 100644 --- a/__tests__/__snapshots__/remaining.test.ts.snap +++ b/__tests__/__snapshots__/remaining.test.ts.snap @@ -15,20 +15,18 @@ exports[`SankeyChart with remaining type entities matches snapshot 2`] = `
- - + - - - - + + +
@@ -38,9 +36,11 @@ exports[`SankeyChart with remaining type entities matches snapshot 2`] = `
- - -
+ + kW + + + @@ -49,34 +49,32 @@ exports[`SankeyChart with remaining type entities matches snapshot 2`] = `
- - + - + - + - - - - - - - - + + + + + + +
@@ -100,9 +98,11 @@ IDK" class="">
- - -
+ + kW + + + @@ -111,37 +111,35 @@ IDK" class="">
- - + - - + + - + - - + + - - +
@@ -152,9 +150,11 @@ Blaa" class="">
- - -
+ + kW + + + @@ -165,9 +165,11 @@ Blaa" class="">
- - -
+ + + + + @@ -178,9 +180,11 @@ Blaa" class="">
- - -
+ + kW + + + @@ -189,20 +193,18 @@ Blaa" class="">
- - + - - - - + + +
@@ -227,9 +229,11 @@ Blaa" class="">
- - -
+ + kW + + + @@ -246,10 +250,14 @@ Blaa" class=""> position: relative; /* height: 210px; */ padding: 16px; + overflow: hidden; } .container.with-header { padding-top: 0; } + .container.vertical { + flex-direction: column; + } .section { flex: 1; flex-direction: column; @@ -257,11 +265,23 @@ Blaa" class=""> min-width: 0; max-width: 50%; } + .vertical .section { + display: flex; + flex: initial; + flex-direction: row-reverse; + align-items: flex-start; + max-width: 100%; + width: 100%; + height: 150px; + } .wide .section:last-child { flex: initial; } .spacerv { - transition: height 0.2s; + transition: height 0.25s; + } + .vertical .spacerv { + transition: width 0.25s; } .box { display: flex; @@ -270,6 +290,10 @@ Blaa" class=""> /* min-height: 1px; */ transition: height 0.25s; } + .vertical .box { + flex-direction: column; + transition: width 0.25s; + } /* .box::before { content: ""; position: absolute; @@ -291,6 +315,10 @@ Blaa" class=""> height: 100%; cursor: pointer; } + .vertical .box div:first-child { + width: 100%; + height: 15px; + } .box.type-passthrough div:first-child { opacity: 0.4; } @@ -306,8 +334,16 @@ Blaa" class=""> overflow: hidden; text-overflow: ellipsis; } - .box .label span { + .vertical .box .label { + padding: 5px 0 0; + flex-direction: column; + white-space: normal; + /* word-break: break-all; */ + text-align: center; + } + .box .label .name { font-style: italic; + font-size: inherit; } .connectors { position: absolute; @@ -317,11 +353,23 @@ Blaa" class=""> height: 100%; overflow: hidden; } + .vertical .connectors { + top: 15px; + left: 0; + bottom: 0; + height: auto; + } .connectors svg { position: absolute; left: -1px; width: 101%; height: 100%; } + .vertical .connectors svg { + top: -1px; + left: 0; + width: 100%; + height: 101%; + } " `; diff --git a/__tests__/setupTests.ts b/__tests__/setupTests.ts index f11ba87..d0eda12 100644 --- a/__tests__/setupTests.ts +++ b/__tests__/setupTests.ts @@ -2,4 +2,10 @@ jest.mock('custom-card-helpers', () => ({ ...jest.requireActual('custom-card-helpers'), stateIcon: jest.fn().mockReturnValue('state-icon'), -})); \ No newline at end of file +})); + +Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: 1000, +}); \ No newline at end of file diff --git a/src/chart.ts b/src/chart.ts index c4fdc30..b49cbf9 100644 --- a/src/chart.ts +++ b/src/chart.ts @@ -14,6 +14,7 @@ import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { handleAction } from './handle-actions'; import { filterConfigByZoomEntity } from './zoom'; import { renderSection } from './section'; +import { shouldBeVertical } from './layout'; @customElement('sankey-chart-base') export class Chart extends LitElement { @@ -28,10 +29,12 @@ export class Chart extends LitElement { @state() private connections: ConnectionState[] = []; @state() private connectionsByParent: Map = new Map(); @state() private connectionsByChild: Map = new Map(); - @state() private statePerPixelY = 0; + @state() private statePerPixel = 0; @state() private entityStates: Map = new Map(); @state() private highlightedEntities: EntityConfigInternal[] = []; @state() private lastUpdate = 0; + @state() private vertical = false; + @state() private width = 0; // passed from parent @state() public zoomEntity?: EntityConfigInternal; @state() public error?: Error; @@ -44,7 +47,8 @@ export class Chart extends LitElement { changedProps.has('config') || changedProps.has('forceUpdateTs') || changedProps.has('highlightedEntities') || - changedProps.has('zoomEntity') + changedProps.has('zoomEntity') || + changedProps.has('width') ) { return true; } @@ -74,6 +78,7 @@ export class Chart extends LitElement { } public willUpdate(changedProps: PropertyValues): void { + this.vertical = shouldBeVertical(this.config, this.width); if (!this.entityIds.length || changedProps.has('config')) { this.entityIds = []; this.connections = []; @@ -91,9 +96,7 @@ export class Chart extends LitElement { 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, - ); + 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; @@ -199,8 +202,8 @@ export class Chart extends LitElement { } connection.ready = true; if ( - !force && - (child.type === 'remaining_parent_state' && + (!force && + child.type === 'remaining_parent_state' && (child.add_entities?.length || child.subtract_entities?.length) && childState === Infinity) || (parent.type === 'remaining_child_state' && @@ -219,11 +222,15 @@ export class Chart extends LitElement { const entityConf = typeof entityConfOrStr === 'string' ? { entity_id: entityConfOrStr, children: [] } : entityConfOrStr; const entity = this._getEntityState(entityConf); - const unit_of_measurement = this._getUnitOfMeasurement(entityConf.unit_of_measurement || entity.attributes.unit_of_measurement); + const unit_of_measurement = this._getUnitOfMeasurement( + entityConf.unit_of_measurement || entity.attributes.unit_of_measurement, + ); const normalized = normalizeStateValue(this.config.unit_prefix, Number(entity.state), unit_of_measurement); if (entityConf.type === 'passthrough') { - normalized.state = this.connections.filter(c => c.passthroughs.includes(entityConf)).reduce((sum, c) => (c.ready ? sum + c.state : Infinity), 0); + 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 => { @@ -258,9 +265,9 @@ export class Chart extends LitElement { } private _calcBoxes() { - this.statePerPixelY = 0; + this.statePerPixel = 0; if (this.config.static_scale) { - // use static scale to set a minimum statePerPixelY + // use static scale to set a minimum statePerPixel this._calcBoxHeights( [{ state: this.config.static_scale, size: 0 } as Box], this.config.height, @@ -269,6 +276,8 @@ export class Chart extends LitElement { } const filteredConfig = filterConfigByZoomEntity(this.config, this.zoomEntity); const sectionsStates: SectionState[] = []; + // 32 is the padding of the card + const sectionSize = this.vertical ? this.width - 32 : this.config.height; filteredConfig.sections.forEach(section => { let total = 0; const boxes: Box[] = section.entities @@ -293,9 +302,9 @@ export class Chart extends LitElement { const colorLimit = entityConf.color_limit ?? 1; const colorBelow = entityConf.color_below ?? 'var(--primary-color)'; const colorAbove = entityConf.color_above ?? 'var(--paper-item-icon-color)'; - if ( state4color > colorLimit ) { + if (state4color > colorLimit) { finalColor = colorAbove; - } else if ( state4color < colorLimit ) { + } else if (state4color < colorLimit) { finalColor = colorBelow; } } @@ -318,8 +327,8 @@ export class Chart extends LitElement { return; } // leave room for margin - const availableHeight = this.config.height - (boxes.length - 1) * this.config.min_box_distance; - // calc sizes to determine statePerPixelY ratio and find the best one + const availableHeight = sectionSize - (boxes.length - 1) * this.config.min_box_distance; + // calc sizes to determine statePerPixel ratio and find the best one const calcResults = this._calcBoxHeights(boxes, availableHeight, total); const parentBoxes = section.sort_group_by_parent ? sectionsStates[sectionsStates.length - 1]?.boxes || [] : []; const sortBy = section.sort_by || this.config.sort_by; @@ -327,19 +336,20 @@ export class Chart extends LitElement { sectionsStates.push({ boxes: sortBoxes(parentBoxes, calcResults.boxes, sortBy, sortDir), total, - statePerPixelY: calcResults.statePerPixelY, - spacerH: 0, + statePerPixel: calcResults.statePerPixel, + spacerSize: 0, config: section, + size: sectionSize, }); }); this.sections = sectionsStates.map(sectionState => { - // calc sizes again with the best statePerPixelY + // calc sizes again with the best statePerPixel let totalSize = 0; let sizedBoxes = sectionState.boxes; - if (sectionState.statePerPixelY !== this.statePerPixelY) { + if (sectionState.statePerPixel !== this.statePerPixel) { sizedBoxes = sizedBoxes.map(box => { - const size = Math.max(this.config.min_box_height, Math.floor(box.state / this.statePerPixelY)); + const size = Math.max(this.config.min_box_size, Math.floor(box.state / this.statePerPixel)); totalSize += size; return { ...box, @@ -349,14 +359,14 @@ export class Chart extends LitElement { } else { totalSize = sizedBoxes.reduce((sum, b) => sum + b.size, 0); } - // calc vertical margin size - const extraSpace = this.config.height - totalSize; - const spacerH = sizedBoxes.length > 1 ? extraSpace / (sizedBoxes.length - 1) : this.config.height; + // calc margin betwee boxes + const extraSpace = sectionSize - totalSize; + const spacerSize = sizedBoxes.length > 1 ? extraSpace / (sizedBoxes.length - 1) : sectionSize; let offset = 0; // calc y positions. needed for connectors sizedBoxes = sizedBoxes.map(box => { const top = offset; - offset += box.size + spacerH; + offset += box.size + spacerSize; return { ...box, top, @@ -365,7 +375,7 @@ export class Chart extends LitElement { return { ...sectionState, boxes: sizedBoxes, - spacerH, + spacerSize, }; }); } @@ -374,20 +384,20 @@ export class Chart extends LitElement { boxes: Box[], availableHeight: number, totalState: number, - ): { boxes: Box[]; statePerPixelY: number } { - const statePerPixelY = totalState / availableHeight; - if (statePerPixelY > this.statePerPixelY) { - this.statePerPixelY = statePerPixelY; + ): { boxes: Box[]; statePerPixel: number } { + const statePerPixel = totalState / availableHeight; + if (statePerPixel > this.statePerPixel) { + this.statePerPixel = statePerPixel; } let deficitHeight = 0; const result = boxes.map(box => { - if (box.size === this.config.min_box_height) { + if (box.size === this.config.min_box_size) { return box; } - let size = Math.floor(box.state / this.statePerPixelY); - if (size < this.config.min_box_height) { - deficitHeight += this.config.min_box_height - size; - size = this.config.min_box_height; + let size = Math.floor(box.state / this.statePerPixel); + if (size < this.config.min_box_size) { + deficitHeight += this.config.min_box_size - size; + size = this.config.min_box_size; } return { ...box, @@ -397,7 +407,7 @@ export class Chart extends LitElement { if (deficitHeight > 0) { return this._calcBoxHeights(result, availableHeight - deficitHeight, totalState); } - return { boxes: result, statePerPixelY: this.statePerPixelY }; + return { boxes: result, statePerPixel: this.statePerPixel }; } private highlightPath(entityConf: EntityConfigInternal, direction?: 'parents' | 'children') { @@ -504,7 +514,7 @@ export class Chart extends LitElement { } return this._getEntityState(realConnection.child); } - + let entity = this.states[getEntityId(entityConf)]; if (!entity) { throw new Error('Entity not found "' + getEntityId(entityConf) + '"'); @@ -528,7 +538,8 @@ export class Chart extends LitElement { 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); + const connections = + direction === 'parents' ? this.connectionsByChild.get(entityConf) : this.connectionsByParent.get(entityConf); if (!connections) { throw new Error('Invalid entity config ' + JSON.stringify(entityConf)); } @@ -555,12 +566,15 @@ export class Chart extends LitElement { container: true, wide: !!this.config.wide, 'with-header': !!this.config.title, + vertical: this.vertical, }); + const height = this.vertical ? 'auto' : this.config.height + 'px'; + if (!Object.keys(this.states).length) { return html` -
+
${localize('common.loading')}
@@ -574,7 +588,7 @@ export class Chart extends LitElement { return html` -
+
${this.sections.map((s, i) => renderSection({ locale: this.hass.locale, @@ -583,7 +597,6 @@ export class Chart extends LitElement { nextSection: this.sections[i + 1], sectionIndex: i, highlightedEntities: this.highlightedEntities, - statePerPixelY: this.statePerPixelY, connectionsByParent: this.connectionsByParent, connectionsByChild: this.connectionsByChild, allConnections: this.connections, @@ -591,6 +604,7 @@ export class Chart extends LitElement { onDoubleTap: this._handleBoxDoubleTap.bind(this), onMouseEnter: this._handleMouseEnter.bind(this), onMouseLeave: this._handleMouseLeave.bind(this), + vertical: this.vertical, }), )}
diff --git a/src/const.ts b/src/const.ts index 1bdd271..bacd246 100644 --- a/src/const.ts +++ b/src/const.ts @@ -9,9 +9,15 @@ export const UNIT_PREFIXES = { }; export const MIN_LABEL_HEIGHT = 15; +export const CHAR_WIDTH_RATIO = 8.05; // px per char, trial and error + +export const MIN_HORIZONTAL_SECTION_W = 150; +export const MIN_VERTICAL_SECTION_H = 150; export const DEFAULT_ENTITY_CONF: Omit = { type: 'entity', }; export const FT3_PER_M3 = 35.31; + +export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary'; \ No newline at end of file diff --git a/src/editor/index.ts b/src/editor/index.ts index e04e2f6..860ae3c 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -173,9 +173,22 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor type: 'grid', name: '', schema: [ + { + name: 'layout', + selector: { + select: { + mode: 'dropdown', + options: [ + { value: 'auto', label: localize('editor.layout.auto') }, + { value: 'horizontal', label: localize('editor.layout.horizontal') }, + { value: 'vertical', label: localize('editor.layout.vertical') }, + ], + }, + }, + }, { name: 'wide', selector: { boolean: {} } }, { name: 'height', selector: { number: { mode: 'box', unit_of_measurement: 'px' } } }, - { name: 'min_box_height', selector: { number: { mode: 'box', unit_of_measurement: 'px' } } }, + { name: 'min_box_size', selector: { number: { mode: 'box', unit_of_measurement: 'px' } } }, { name: 'min_box_distance', selector: { number: { mode: 'box', unit_of_measurement: 'px' } } }, ], }, @@ -183,7 +196,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor type: 'grid', name: '', schema: [ - { name: 'min_state', selector: { number: { mode: 'box', min: 0., step: 'any' } } }, + { name: 'min_state', selector: { number: { mode: 'box', min: 0, step: 'any' } } }, { name: 'static_scale', selector: { number: { mode: 'box' } } }, { name: 'round', selector: { number: { mode: 'box', unit_of_measurement: localize('editor.decimals') } } }, { @@ -200,7 +213,10 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor selector: { select: { mode: 'dropdown', - options: [{ value: 'none', label: localize('editor.sort_by.none') }, { value: 'state', label: localize('editor.sort_by.state') }], + options: [ + { value: 'none', label: localize('editor.sort_by.none') }, + { value: 'state', label: localize('editor.sort_by.state') }, + ], }, }, }, @@ -232,7 +248,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor return html``; } - const isMetric = this.hass.config.unit_system.length == "km"; + const isMetric = this.hass.config.unit_system.length == 'km'; const config = normalizeConfig(this._config || ({} as SankeyChartConfig), isMetric); const { autoconfig } = config; const sections: SectionConfig[] = config.sections || []; @@ -350,7 +366,7 @@ export class SankeyChartEditor extends LitElement implements LovelaceCardEditor margin-bottom: 20px; } ha-formfield { - padding-bottom: 8px; + padding-bottom: 8px; } `; } diff --git a/src/ha-sankey-chart.ts b/src/ha-sankey-chart.ts index 8564ba9..5f5a927 100644 --- a/src/ha-sankey-chart.ts +++ b/src/ha-sankey-chart.ts @@ -299,10 +299,6 @@ class SankeyChart extends SubscribeMixin(LitElement) { this.setNormalizedConfig({ ...this.config, sections }); } - public getCardSize(): number { - return 4; - } - // https://lit.dev/docs/components/rendering/ protected render(): TemplateResult | void { if (this.error) { @@ -316,6 +312,7 @@ class SankeyChart extends SubscribeMixin(LitElement) { .states=${this.config.energy_date_selection ? this.states : this.hass.states} .config=${this.config} .forceUpdateTs=${this.forceUpdateTs} + .width=${this.clientWidth || this.offsetWidth || this.parentElement?.clientWidth || window.innerWidth} > ${print_yaml && this.config.sections.length ? html`${until(renderError('', { ...this.config, autoconfig: undefined }, this.hass))}` diff --git a/src/label.ts b/src/label.ts new file mode 100644 index 0000000..15fa2b6 --- /dev/null +++ b/src/label.ts @@ -0,0 +1,73 @@ +import { html } from 'lit'; +import { Box, Config } from './types'; +import { styleMap } from 'lit/directives/style-map'; +import { CHAR_WIDTH_RATIO, MIN_LABEL_HEIGHT } from './const'; + +export function renderLabel( + box: Box, + config: Config, + formattedState: string, + name: string, + spacerSize: number, + vertical: boolean, +) { + const { show_names, show_states, show_units } = config; + const shouldShowLabel = box.config.type !== 'passthrough' && (show_names || show_states); + if (!shouldShowLabel) return null; + + const maxLabelSize = box.size + spacerSize - 1; + + // reduce label size if it doesn't fit + const labelStyle: Record = { lineHeight: MIN_LABEL_HEIGHT + 'px' }; + const nameStyle: Record = {}; + if (vertical) { + // count chars in the name and reduce font size if it doesn't fit maxLabelSize + labelStyle.width = maxLabelSize + 'px'; + const stateChars = (formattedState + (show_units ? box.unit_of_measurement : '')).length; + const desiredWidth = stateChars * CHAR_WIDTH_RATIO; + if (desiredWidth > maxLabelSize) { + const fontSize = maxLabelSize / desiredWidth; + labelStyle.fontSize = `${fontSize}em`; + labelStyle.lineHeight = `${fontSize}em`; + } + if (show_names) { + const nameChars = Math.max(...name.split(/[\s]+/).map(l => l.length)); + const desiredNameWidth = nameChars * CHAR_WIDTH_RATIO; + if (desiredNameWidth > maxLabelSize) { + const fontSize = maxLabelSize / desiredNameWidth; + nameStyle.fontSize = `${fontSize}rem`; + nameStyle.lineHeight = `${fontSize}rem`; + } + } + } else { + if (maxLabelSize < MIN_LABEL_HEIGHT) { + const fontSize = maxLabelSize / MIN_LABEL_HEIGHT; + // labelStyle.maxHeight = maxLabelSize + '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 (maxLabelSize < MIN_LABEL_HEIGHT * numLines) { + nameStyle.fontSize = `${(maxLabelSize / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; + nameStyle.lineHeight = `${(maxLabelSize / MIN_LABEL_HEIGHT / numLines) * 1.1}em`; + } + } + } + return html`
+ ${show_states + ? html` + ${formattedState}${show_units + ? html`${box.unit_of_measurement}` + : null} + ` + : null} + ${show_names + ? html`${!vertical ? html` ` : null}${name}` + : null} +
`; +} diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..ce432c7 --- /dev/null +++ b/src/layout.ts @@ -0,0 +1,13 @@ +import { MIN_HORIZONTAL_SECTION_W } from './const'; +import { Config } from './types'; + +export function shouldBeVertical(config: Config, width: number) { + if (config.layout === 'auto') { + const minWidth = config.sections.reduce((acc, section) => { + const width = section.min_width ?? MIN_HORIZONTAL_SECTION_W; + return width + acc; + }, 0); + return width < minWidth; + } + return config.layout === 'vertical'; +} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 2343cf7..cd57bc0 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -20,6 +20,7 @@ "fields": { "autoconfig": "Autoconfig", "print_yaml": "Print auto generated config yaml", + "layout": "Layout", "show_names": "Show names", "show_icons": "Show icons", "show_states": "Show states", @@ -27,7 +28,7 @@ "energy_date_selection": "Sync with energy_date_selection component", "height": "Height", "wide": "Wide", - "min_box_height": "Min box height", + "min_box_size": "Min box size", "min_box_distance": "Min box distance", "min_state": "Min state", "static_scale": "Static scale", @@ -68,6 +69,11 @@ "sort_dir": { "desc": "Descending", "asc": "Ascending" + }, + "layout": { + "auto": "Auto", + "vertical": "Vertical", + "horizontal": "Horizontal" } } } \ No newline at end of file diff --git a/src/localize/languages/sk.json b/src/localize/languages/sk.json index c7e0e78..b328985 100644 --- a/src/localize/languages/sk.json +++ b/src/localize/languages/sk.json @@ -27,7 +27,7 @@ "energy_date_selection": "Synchronizácia s komponentom energy_date_selection", "height": "Výška", "wide": "Šírka", - "min_box_height": "Min. výška boxu", + "min_box_size": "Min. výška boxu", "min_box_distance": "Minimálna vzdialenosť boxu", "min_state": "Min stav", "static_scale": "Statická mierka", diff --git a/src/section.ts b/src/section.ts index 6c530ad..f0038ec 100644 --- a/src/section.ts +++ b/src/section.ts @@ -5,46 +5,63 @@ import { Box, Config, ConnectionState, EntityConfigInternal, SectionState } from import { formatState, getChildConnections, getEntityId } from './utils'; import { FrontendLocaleData, stateIcon } from 'custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; -import { MIN_LABEL_HEIGHT } from './const'; +import { renderLabel } from './label'; export function renderBranchConnectors(props: { section: SectionState; nextSection?: SectionState; sectionIndex: number; - statePerPixelY: number; connectionsByParent: Map; connectionsByChild: Map; allConnections: ConnectionState[]; + vertical: boolean; }): SVGTemplateResult[] { - const { boxes } = props.section; + const { boxes, size } = props.section; return boxes .filter(b => b.children.length > 0) .map((b, boxIndex) => { const children = props.nextSection!.boxes.filter(child => b.children.some(c => getEntityId(c) === child.entity_id), ); - const connections = getChildConnections(b, children, props.allConnections, props.connectionsByParent).filter(c => { - return c.state > 0; - }); + const connections = getChildConnections(b, children, props.allConnections, props.connectionsByParent).filter( + c => { + return c.state > 0; + }, + ); return svg` ${connections.map( (c, i) => svg` - + `, )} - ${connections.map( - (c, i) => svg` - - `, - )} + ${connections.map((c, i) => { + let coords = [ + ['M', 0, c.startY], + ['C', 50, c.startY], + ['', 50, c.endY], + ['', 100, c.endY], + ['L', 100, c.endY + c.endSize], + ['C', 50, c.endY + c.endSize], + ['', 50, c.startY + c.startSize], + ['', 0, c.startY + c.startSize], + ]; + if (props.vertical) { + coords = coords.map(c => [c[0], size - (c[2] as number), c[1]]); + } + return svg` + + `; + })} `; }); } @@ -56,30 +73,32 @@ export function renderSection(props: { nextSection?: SectionState; sectionIndex: number; highlightedEntities: EntityConfigInternal[]; - statePerPixelY: number; connectionsByParent: Map; connectionsByChild: Map; allConnections: ConnectionState[]; + vertical: boolean; onTap: (config: Box) => void; onDoubleTap: (config: Box) => void; onMouseEnter: (config: Box) => void; onMouseLeave: () => void; }) { - const { show_names, show_icons, show_states, show_units } = props.config; + const { show_icons } = props.config; const { boxes, - spacerH, - config: { min_width: minWidth }, + spacerSize, + config: { min_width }, + size, } = props.section; const hasChildren = props.nextSection && boxes.some(b => b.children.length > 0); + const viewBox = props.vertical ? `0 0 ${size} 100` : `0 0 100 ${size}`; + const minWidth = min_width && !props.vertical ? min_width + 'px' : undefined; + return html`
${hasChildren ? html`
- - ${renderBranchConnectors(props)} - + ${renderBranchConnectors(props)}
` : null} ${boxes.map((box, i) => { @@ -88,36 +107,15 @@ export function renderSection(props: { 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`; - } - } - const shouldShowLabel = isNotPassthrough && (show_names || show_states); + const sizeProp = props.vertical ? 'width' : 'height'; return html` - ${i > 0 ? html`
` : null} + ${i > 0 ? html`
` : null} ${extraSpacers - ? html`
` + ? html`
` : null} -
+
props.onTap(box)} @@ -131,22 +129,9 @@ export function renderSection(props: { ? html`` : null}
- ${shouldShowLabel - ? html`
- ${show_states && isNotPassthrough - ? html`${formattedState}${show_units - ? html`${box.unit_of_measurement}` - : null}` - : null} - ${show_names && isNotPassthrough - ? html` ${name}` - : null} -
` - : null} + ${renderLabel(box, props.config, formattedState, name, spacerSize, props.vertical)}
- ${extraSpacers - ? html`
` - : null} + ${extraSpacers ? html`
` : null} `; })}
diff --git a/src/styles.ts b/src/styles.ts index cae766e..54310b6 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -1,4 +1,5 @@ import { css } from 'lit'; +import { MIN_VERTICAL_SECTION_H } from './const'; // https://lit.dev/docs/components/styles/ export default css` @@ -10,10 +11,14 @@ export default css` position: relative; /* height: 210px; */ padding: 16px; + overflow: hidden; } .container.with-header { padding-top: 0; } + .container.vertical { + flex-direction: column; + } .section { flex: 1; flex-direction: column; @@ -21,11 +26,23 @@ export default css` min-width: 0; max-width: 50%; } + .vertical .section { + display: flex; + flex: initial; + flex-direction: row-reverse; + align-items: flex-start; + max-width: 100%; + width: 100%; + height: ${MIN_VERTICAL_SECTION_H}px; + } .wide .section:last-child { flex: initial; } .spacerv { - transition: height 0.2s; + transition: height 0.25s; + } + .vertical .spacerv { + transition: width 0.25s; } .box { display: flex; @@ -34,6 +51,10 @@ export default css` /* min-height: 1px; */ transition: height 0.25s; } + .vertical .box { + flex-direction: column; + transition: width 0.25s; + } /* .box::before { content: ""; position: absolute; @@ -55,6 +76,10 @@ export default css` height: 100%; cursor: pointer; } + .vertical .box div:first-child { + width: 100%; + height: 15px; + } .box.type-passthrough div:first-child { opacity: 0.4; } @@ -70,8 +95,16 @@ export default css` overflow: hidden; text-overflow: ellipsis; } - .box .label span { + .vertical .box .label { + padding: 5px 0 0; + flex-direction: column; + white-space: normal; + /* word-break: break-all; */ + text-align: center; + } + .box .label .name { font-style: italic; + font-size: inherit; } .connectors { position: absolute; @@ -81,10 +114,22 @@ export default css` height: 100%; overflow: hidden; } + .vertical .connectors { + top: 15px; + left: 0; + bottom: 0; + height: auto; + } .connectors svg { position: absolute; left: -1px; width: 101%; height: 100%; } + .vertical .connectors svg { + top: -1px; + left: 0; + width: 100%; + height: 101%; + } `; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 1a903b9..b159f76 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,7 +7,39 @@ import { LovelaceCardEditor, } from 'custom-card-helpers'; import { HassEntity, HassServiceTarget } from 'home-assistant-js-websocket'; -import { UNIT_PREFIXES } from './const'; +import { UNIT_PREFIXES, CONVERSION_UNITS } from './const'; + +export interface SankeyChartConfig extends LovelaceCardConfig { + type: string; + autoconfig?: { + print_yaml?: boolean; + }; + title?: string; + sections?: SectionConfig[]; + convert_units_to?: '' | CONVERSION_UNITS; + co2_intensity_entity?: string; + gas_co2_intensity?: number; + monetary_unit?: string; + electricity_price?: number; + gas_price?: number; + unit_prefix?: '' | keyof typeof UNIT_PREFIXES; + round?: number; + height?: number; + wide?: boolean; + layout?: 'auto' | 'vertical' | 'horizontal'; + show_icons?: boolean; + show_names?: boolean; + show_states?: boolean; + show_units?: boolean; + energy_date_selection?: boolean; + min_box_size?: number; + min_box_distance?: number; + throttle?: number; + min_state?: number; + static_scale?: number; + sort_by?: 'none' | 'state'; + sort_dir?: 'asc' | 'desc'; +} declare global { interface HTMLElementTagNameMap { @@ -93,40 +125,7 @@ export interface SectionConfig { sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; - min_width?: string; -} - -export type CONVERSION_UNITS = 'MJ' | 'gCO2' | 'monetary'; - -export interface SankeyChartConfig extends LovelaceCardConfig { - type: string; - autoconfig?: { - print_yaml?: boolean; - }; - title?: string; - sections?: SectionConfig[]; - convert_units_to?: '' | CONVERSION_UNITS; - co2_intensity_entity?: string; - gas_co2_intensity?: number; - monetary_unit?: string; - electricity_price?: number; - gas_price?: number; - unit_prefix?: '' | keyof typeof UNIT_PREFIXES; - round?: number; - height?: number; - wide?: boolean; - show_icons?: boolean; - show_names?: boolean; - show_states?: boolean; - show_units?: boolean; - energy_date_selection?: boolean; - min_box_height?: number; - min_box_distance?: number; - throttle?: number; - min_state?: number; - static_scale?: number; - sort_by?: 'none' | 'state'; - sort_dir?: 'asc' | 'desc'; + min_width?: number; } export interface Section { @@ -134,14 +133,15 @@ export interface Section { sort_by?: 'none' | 'state'; sort_dir?: 'asc' | 'desc'; sort_group_by_parent?: boolean; - min_width?: string; + min_width?: number; } export interface Config extends SankeyChartConfig { + layout: 'auto' | 'vertical' | 'horizontal'; unit_prefix: '' | keyof typeof UNIT_PREFIXES; round: number; height: number; - min_box_height: number; + min_box_size: number; min_box_distance: number; min_state: number; sections: Section[]; @@ -180,9 +180,10 @@ export interface Box { export interface SectionState { boxes: Box[]; total: number; - spacerH: number; - statePerPixelY: number; + spacerSize: number; + statePerPixel: number; config: Section; + size: number; } export interface ConnectionState { diff --git a/src/utils.ts b/src/utils.ts index 766ca8a..d49cc17 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -189,13 +189,15 @@ export function normalizeConfig(conf: SankeyChartConfig, isMetric: boolean): Con const default_co2_per_ft3 = 55.0 // gCO2e/ft3 tailpipe + 11.6; // gCO2e/ft3 supply chain, US average return { + // set config defaults + layout: 'auto', height: 200, unit_prefix: '', round: 0, convert_units_to: '', co2_intensity_entity: 'sensor.co2_signal_co2_intensity', gas_co2_intensity: isMetric ? default_co2_per_ft3 * FT3_PER_M3 : default_co2_per_ft3, - min_box_height: 3, + min_box_size: 3, min_box_distance: 5, show_states: true, show_units: true,