From aa70bf952e351c5c720411362a5bb780774bff08 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:38:47 +0000 Subject: [PATCH 01/41] feat: set a gauge's angles, its centre text and needle scale numbers Ring, arc and needle gauges get three extras, all in the Style section of the editor under the style picker (only while a gauge style is chosen): - start_angle / end_angle: where the gauge's arc starts and ends, in degrees clockwise from the top (-90 is the left, 90 the right). Leave either out and the style's usual end is used (ring -180/180, arc -135/135, needle -90/90). The end must come after the start and the sweep can be at most 360 degrees. The drawing is cropped to the arc, so a half-circle arc leaves no empty space below it. - center: what sits in the middle (on the needle, under the hub): the value (as before), the current colour band's label drawn in the band's colour (the value when the band has no label), the entity's name (the name under the gauge is then left out), your own text (center_text), or nothing. - tick_labels (needle only): round numbers along the scale from min to max, the ends always shown, outside the arc and past the target marker. The options are the card's: they are read once when the config is set and carried on each entity, so every gauge on a card is drawn the same way, including entities added by an include rule. With none of them set the gauges draw exactly as before. Unavailable and missing entities keep their status text whatever the centre shows. Closes #46 --- src/config.ts | 4 + src/editor/chartdeck-card-editor.ts | 17 +- src/editor/schemas.ts | 50 +++++ src/gauge.ts | 182 ++++++++++++++++++ src/localize.ts | 12 ++ src/styles/needle.ts | 156 +++++++++++++-- src/styles/radial.ts | 60 ++++-- src/types.ts | 3 + test/gauge.test.ts | 282 ++++++++++++++++++++++++++++ 9 files changed, 736 insertions(+), 30 deletions(-) create mode 100644 src/gauge.ts create mode 100644 test/gauge.test.ts diff --git a/src/config.ts b/src/config.ts index 6e33dad..417037f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { normalizeRule } from './fill'; +import { DEFAULT_ANGLES, parseGaugeOptions } from './gauge'; import { STYLE_NAMES } from './types'; import type { ActionConfig, @@ -122,6 +123,9 @@ export function mergeEntity(raw: string | EntityConfig, card: EntityOptions, ind if (target !== undefined) merged.target = target; if (decimals !== undefined) merged.decimals = decimals; if (!own.attribute) delete merged.attribute; + // Gauge options are the card's alone: every gauge on a card is drawn the same way. + const gauge = parseGaugeOptions(card as CardConfig); + if (DEFAULT_ANGLES[(card as CardConfig).style ?? 'bar']) merged.gauge = gauge; return merged; } diff --git a/src/editor/chartdeck-card-editor.ts b/src/editor/chartdeck-card-editor.ts index 102f777..bc385df 100644 --- a/src/editor/chartdeck-card-editor.ts +++ b/src/editor/chartdeck-card-editor.ts @@ -10,17 +10,20 @@ import { COLOUR_TOGGLE_FIELDS, DISPLAY_FIELDS, DISPLAY_TOGGLE_FIELDS, + GAUGE_FIELDS, TITLE_FIELDS, actionsSchema, applyFormData, deepEqual, displaySchema, formData, + gaugeSchema, RULE_FIELDS, ruleSchema, titleSchema, } from './schemas'; import type { FieldBinding } from './schemas'; + import { thumbnail } from './thumbnails'; import { STYLES, STYLE_GROUPS, isChart } from '../styles/index'; import { sensorUnit } from './entities-editor'; @@ -28,6 +31,9 @@ import './bands-editor'; import './entities-editor'; import './limit-field'; +/** Styles with the gauge options (angles, centre text, tick labels). */ +const GAUGE_STYLES: StyleName[] = ['ring', 'arc', 'needle']; + interface CardHelpers { createCardElement(config: Record): Promise; } @@ -91,7 +97,8 @@ export class ChartdeckCardEditor extends LitElement { margin: 16px 0 4px; font-weight: 500; } - .rules-help { + .rules-help, + .gauge-help { margin: 0 0 8px; color: var(--secondary-text-color); font-size: 0.875em; @@ -322,7 +329,13 @@ export class ChartdeckCardEditor extends LitElement { `; })} `; - })}`; + })} + ${ + GAUGE_STYLES.includes(current as StyleName) + ? html`

${this._label('editor_gauge_help')}

+ ${this._form('gauge', gaugeSchema(this.hass, current as StyleName, this._config?.center), GAUGE_FIELDS)}` + : nothing + }`; } /** The current chart style's own options, when it has any. */ diff --git a/src/editor/schemas.ts b/src/editor/schemas.ts index 46d638f..aface3c 100644 --- a/src/editor/schemas.ts +++ b/src/editor/schemas.ts @@ -42,6 +42,56 @@ export const ACTION_FIELDS: FieldBinding[] = [ { name: 'double_tap_action', path: ['double_tap_action'], default: { action: 'none' } }, ]; +/** Ring, arc and needle options, under the style picker. Empty angles use the style's own. */ +export const GAUGE_FIELDS: FieldBinding[] = [ + { name: 'start_angle', path: ['start_angle'] }, + { name: 'end_angle', path: ['end_angle'] }, + { name: 'center', path: ['center'], default: 'value' }, + { name: 'center_text', path: ['center_text'] }, + { name: 'tick_labels', path: ['tick_labels'], default: false }, +]; + +const gaugeSchemas = new Map(); + +/** + * The gauge options for a style: the text field only while the centre shows custom text, and tick + * labels only on the needle (ring and arc have no scale). + */ +export function gaugeSchema(hass: HomeAssistant | undefined, style: string, center: unknown) { + const text = center === 'text'; + const key = `${languageOf(hass)}|${style}|${text}`; + let schema = gaugeSchemas.get(key); + if (!schema) { + const angle = { number: { min: -360, max: 360, step: 1, mode: 'box', unit_of_measurement: '°' } }; + schema = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'start_angle', selector: angle }, + { name: 'end_angle', selector: angle }, + ], + }, + { + name: 'center', + selector: { + select: { + mode: 'dropdown', + options: ['value', 'band', 'name', 'text', 'none'].map((value) => ({ + value, + label: localize(hass, `editor_center_mode_${value}`), + })), + }, + }, + }, + ...(text ? [{ name: 'center_text', selector: { text: {} } }] : []), + ...(style === 'needle' ? [{ name: 'tick_labels', selector: { boolean: {} } }] : []), + ]; + gaugeSchemas.set(key, schema); + } + return schema; +} + /** Per-entity overrides edited through ha-form; paths are relative to the entity object. */ export const ENTITY_TEXT_FIELDS: FieldBinding[] = [ { name: 'name', path: ['name'] }, diff --git a/src/gauge.ts b/src/gauge.ts new file mode 100644 index 0000000..6b3dad4 --- /dev/null +++ b/src/gauge.ts @@ -0,0 +1,182 @@ +import { polar } from './geometry'; +import type { CardConfig, StyleName } from './types'; + +/** + * Options the gauge styles (ring, arc, needle) share. Angles are degrees clockwise from the top + * (12 o'clock), as in geometry.ts: -90 is 9 o'clock, 90 is 3 o'clock, ±180 is 6 o'clock. + */ +export const CENTER_MODES = ['value', 'band', 'name', 'text', 'none'] as const; +export type CenterMode = (typeof CENTER_MODES)[number]; + +export interface GaugeOptions { + /** Set only when the card sets it; otherwise the style's own default angle. */ + startAngle?: number; + endAngle?: number; + center: CenterMode; + centerText: string; + tickLabels: boolean; +} + +/** Each gauge style's sweep when no angle is set. */ +export const DEFAULT_ANGLES: Partial> = { + ring: [-180, 180], + arc: [-135, 135], + needle: [-90, 90], +}; + +function angle(raw: unknown, field: string): number | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + const n = typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : raw; + if (typeof n !== 'number' || !Number.isFinite(n) || n < -360 || n > 360) { + throw new Error(`"${field}" must be a number of degrees from -360 to 360`); + } + return n; +} + +/** Read and check the gauge options of a card config. Throws a readable Error. */ +export function parseGaugeOptions(raw: CardConfig, style: StyleName = raw.style ?? 'bar'): GaugeOptions { + const startAngle = angle(raw.start_angle, 'start_angle'); + const endAngle = angle(raw.end_angle, 'end_angle'); + const defaults = DEFAULT_ANGLES[style]; + if (defaults && (startAngle !== undefined || endAngle !== undefined)) { + const [a0, a1] = [startAngle ?? defaults[0], endAngle ?? defaults[1]]; + if (!(a1 > a0)) throw new Error(`"end_angle" (${a1}) must be greater than "start_angle" (${a0})`); + if (a1 - a0 > 360) throw new Error(`"end_angle" can be at most 360 degrees after "start_angle"`); + } + const center = raw.center ?? 'value'; + if (!CENTER_MODES.includes(center as CenterMode)) { + throw new Error(`"center" must be one of: ${CENTER_MODES.join(', ')}`); + } + if (raw.center_text !== undefined && raw.center_text !== null && typeof raw.center_text !== 'string') { + throw new Error('"center_text" must be text'); + } + if (raw.tick_labels !== undefined && typeof raw.tick_labels !== 'boolean') { + throw new Error('"tick_labels" must be true or false'); + } + const options: GaugeOptions = { + center: center as CenterMode, + centerText: (raw.center_text as string | undefined) ?? '', + tickLabels: raw.tick_labels === true, + }; + if (startAngle !== undefined) options.startAngle = startAngle; + if (endAngle !== undefined) options.endAngle = endAngle; + return options; +} + +/** The angles a gauge draws between: the card's, else the style's default. */ +export function gaugeAngles(defaults: [number, number], options?: GaugeOptions): [number, number] { + return [options?.startAngle ?? defaults[0], options?.endAngle ?? defaults[1]]; +} + +/** Whether the card changed the angles from the style's default. */ +export function customAngles(options?: GaugeOptions): boolean { + return options?.startAngle !== undefined || options?.endAngle !== undefined; +} + +/** The angle for a 0..1 position along a sweep. */ +export function angleAt([a0, a1]: [number, number], t: number): number { + return a0 + (a1 - a0) * t; +} + +export interface Box { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** The union of boxes. */ +export function union(...boxes: Box[]): Box { + return { + x0: Math.min(...boxes.map((b) => b.x0)), + y0: Math.min(...boxes.map((b) => b.y0)), + x1: Math.max(...boxes.map((b) => b.x1)), + y1: Math.max(...boxes.map((b) => b.y1)), + }; +} + +/** + * The box around an arc of radius `r` drawn with a stroke `pad` wide on each side (round caps + * included): its two ends and every compass point (0, 90, 180, 270 degrees) it passes. + */ +export function arcBox(cx: number, cy: number, r: number, a0: number, a1: number, pad: number): Box { + const angles = [a0, a1]; + for (let k = Math.ceil(a0 / 90); k * 90 <= a1; k++) angles.push(k * 90); + const points = angles.map((a) => polar(cx, cy, r, a)); + const box = { + x0: Math.min(...points.map((p) => p[0])), + y0: Math.min(...points.map((p) => p[1])), + x1: Math.max(...points.map((p) => p[0])), + y1: Math.max(...points.map((p) => p[1])), + }; + return { x0: box.x0 - pad, y0: box.y0 - pad, x1: box.x1 + pad, y1: box.y1 + pad }; +} + +/** A tick step of 1, 2, 2.5 or 5 times a power of ten, near `span / count`. */ +export function tickStep(span: number, count = 5): number { + if (!Number.isFinite(span) || span <= 0) return 1; + const raw = span / count; + const power = 10 ** Math.floor(Math.log10(raw)); + const f = raw / power; + return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * power; +} + +/** + * Round tick values from `min` to `max`, about `count` steps apart. The scale's own ends are + * always labelled; a round tick too close to an end (under 40 % of a step) is dropped so the + * labels don't collide. + */ +export function scaleTicks(min: number, max: number, count = 5): number[] { + if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return []; + const step = tickStep(max - min, count); + const inner: number[] = []; + for (let v = Math.ceil(min / step) * step; v < max - step * 0.4; v += step) { + if (v > min + step * 0.4) inner.push(Number(v.toPrecision(12))); + } + return [min, ...inner, max]; +} + +/** Decimals a tick needs: those of the step, or the value itself if it has more (a scale end). */ +export function tickDecimals(value: number, step: number): number { + const places = (n: number) => { + const text = String(Number(n.toPrecision(12))); + return text.includes('e') ? 0 : (text.split('.')[1]?.length ?? 0); + }; + return Math.min(3, Math.max(places(step), places(value))); +} + +/** Text anchor and vertical nudge (em) for a label placed outward at `deg`. */ +export function labelPlacement(deg: number): { anchor: 'start' | 'middle' | 'end'; dy: number } { + const rad = (deg * Math.PI) / 180; + const sin = Math.sin(rad); + const cos = Math.cos(rad); + const anchor = sin > 0.3 ? 'start' : sin < -0.3 ? 'end' : 'middle'; + // Labels above the centre sit on their baseline, those below hang from it, sides are centred. + const dy = cos > 0.3 ? 0 : cos < -0.3 ? 0.75 : 0.35; + return { anchor, dy }; +} + +/** What the gauge shows in place of its value: the value itself, or text. */ +export type CenterContent = { kind: 'value' } | { kind: 'text'; text: string; band: boolean } | { kind: 'none' }; + +/** + * The centre content for an available entity: its band's label in `band` mode (the value when the + * band has none), its name, the set text, or nothing. + */ +export function centerContent( + e: { name: string; band: { label?: string } }, + options: GaugeOptions | undefined, +): CenterContent { + switch (options?.center ?? 'value') { + case 'band': + return e.band.label ? { kind: 'text', text: e.band.label, band: true } : { kind: 'value' }; + case 'name': + return { kind: 'text', text: e.name, band: false }; + case 'text': + return options!.centerText ? { kind: 'text', text: options!.centerText, band: false } : { kind: 'none' }; + case 'none': + return { kind: 'none' }; + default: + return { kind: 'value' }; + } +} diff --git a/src/localize.ts b/src/localize.ts index a47d0f5..ef2c710 100644 --- a/src/localize.ts +++ b/src/localize.ts @@ -47,6 +47,18 @@ const STRINGS: Record> = { editor_style_ring: 'Ring', editor_style_needle: 'Needle', editor_style_arc: 'Arc', + editor_gauge_help: + "Angles are in degrees, clockwise from the top: -90 is the left, 90 the right. Leave them empty for the style's usual shape.", + editor_start_angle: 'Start angle', + editor_end_angle: 'End angle', + editor_center: 'In the middle', + editor_center_mode_value: 'The value', + editor_center_mode_band: "The colour band's label", + editor_center_mode_name: 'The name', + editor_center_mode_text: 'My own text', + editor_center_mode_none: 'Nothing', + editor_center_text: 'Text', + editor_tick_labels: 'Numbers on the scale', editor_show_name: 'Show name', editor_show_value: 'Show value', editor_show_icon: 'Show icon', diff --git a/src/styles/needle.ts b/src/styles/needle.ts index 65a9015..5702767 100644 --- a/src/styles/needle.ts +++ b/src/styles/needle.ts @@ -1,5 +1,19 @@ import { css, html, nothing, svg } from 'lit'; import { baseColor } from '../bands'; +import { formatNumber } from '../format'; +import { + angleAt, + arcBox, + centerContent, + customAngles, + gaugeAngles, + labelPlacement, + scaleTicks, + tickDecimals, + tickStep, + union, +} from '../gauge'; +import type { Box } from '../gauge'; import { arcPath, needlePath, polar } from '../geometry'; import { fitStatusText } from '../status-text'; import type { ResolvedBand } from '../types'; @@ -10,6 +24,13 @@ const CX = 65; const CY = 66; const R = 50; const GAP = 1; +const DEFAULT_ANGLES: [number, number] = [-90, 90]; +/** Tick labels sit just outside the band arc (outer edge R + 6), or past the target marker (R + 14). */ +const LABEL_R = R + 10; +const LABEL_R_TARGET = R + 17; +const LABEL_SIZE = 9; +/** The value line's baseline under the hub in the fixed layout. */ +const VALUE_Y = 94; /** A short plain number for inline style (no float noise, no -0). */ const num = (x: number) => String(Math.round(x * 1000) / 1000 + 0); @@ -44,9 +65,9 @@ function statusInDial(text: string) { } /** Status text in the value's place under a full-height gauge, fitted to the tile's width. */ -function statusBelow(text: string) { +function statusBelow(text: string, baseline = VALUE_Y) { // One line whenever it fits at full size (126 / (0.58 × 12) ≈ 18 characters). - return statusLines(text, 126, 12, 8, 94, 18); + return statusLines(text, 126, 12, 8, baseline, 18); } function statusLines(text: string, width: number, max: number, min: number, lastBaseline: number, splitAbove = 11) { @@ -104,6 +125,9 @@ export const needle: ValueStyleModule = { :host(:not([ready])) .needle-gauge .needle { transform: rotate(-90deg) !important; } + :host(:not([ready])) .needle-gauge.fitted .needle { + transform: rotate(var(--needle-start)) !important; + } .needle-gauge .hub { fill: var(--primary-text-color); } @@ -118,6 +142,15 @@ export const needle: ValueStyleModule = { font-weight: 500; fill: var(--primary-text-color); } + .needle-gauge .tick-label { + font-size: 9px; + fill: var(--secondary-text-color); + } + .needle-gauge .center-text { + font-size: 15px; + font-weight: 500; + fill: var(--primary-text-color); + } .needle-gauge .na { font-size: 12px; fill: var(--secondary-text-color); @@ -141,21 +174,42 @@ export const needle: ValueStyleModule = { } `, render(e, { show, hass }) { + const options = e.config.gauge; + const angles = gaugeAngles(DEFAULT_ANGLES, options); + const at = (t: number) => angleAt(angles, t); const segs = bandSegments(e.bands, e.min, e.max); const arcs = segs.map((s, i) => { - const a0 = -90 + 180 * s.from + (i > 0 ? GAP : 0); - const a1 = -90 + 180 * s.to - (i < segs.length - 1 ? GAP : 0); + const a0 = at(s.from) + (i > 0 ? GAP : 0); + const a1 = at(s.to) - (i < segs.length - 1 ? GAP : 0); if (!(a1 > a0)) return nothing; return svg``; }); - const ticks = Array.from({ length: 11 }, (_, i) => { - const [x1, y1] = polar(CX, CY, 36, -90 + 18 * i); - const [x2, y2] = polar(CX, CY, 40, -90 + 18 * i); + const tickmark = (deg: number) => { + const [x1, y1] = polar(CX, CY, 36, deg); + const [x2, y2] = polar(CX, CY, 40, deg); return svg``; + }; + // With tick labels, a mark at each labelled value; otherwise ten even steps. + const values = options?.tickLabels ? scaleTicks(e.min, e.max) : []; + const range = e.max - e.min; + const ticks = values.length + ? values.map((v) => tickmark(at((v - e.min) / range))) + : Array.from({ length: 11 }, (_, i) => tickmark(at(i / 10))); + const step = tickStep(range); + const hasTarget = show.target && e.targetPosition !== null; + const labelR = hasTarget ? LABEL_R_TARGET : LABEL_R; + const fitted = customAngles(options) || values.length > 0; + // A sweep that comes down past the hub would run through the value line: put it under the arc. + const valueY = fitted ? Math.max(VALUE_Y, Math.ceil(arcBox(CX, CY, R, angles[0], angles[1], 6).y1) + 14) : VALUE_Y; + const labels = values.map((v) => { + const deg = at((v - e.min) / range); + const [x, y] = polar(CX, CY, labelR, deg); + const { anchor, dy } = labelPlacement(deg); + return svg`${formatNumber(v, tickDecimals(v, step), hass)}`; }); let mark: unknown = nothing; if (show.target && e.targetPosition !== null && e.available) { - const deg = -90 + 180 * e.targetPosition; + const deg = at(e.targetPosition); const [tx, ty] = polar(CX, CY, R + 7, deg); const [lx, ly] = polar(CX, CY, R + 14, deg - 3); const [rx, ry] = polar(CX, CY, R + 14, deg + 3); @@ -163,12 +217,30 @@ export const needle: ValueStyleModule = { } // Without a value line under the hub, the drawing ends at the hub and the name moves up. const compact = !show.value; - return html`
-
+ ${ - show.name || show.icon + (show.name && options?.center !== 'name') || show.icon ? html`
${ show.icon @@ -196,10 +278,52 @@ export const needle: ValueStyleModule = { >` : nothing } - ${show.name ? html`${e.name}` : nothing} + ${show.name && options?.center !== 'name' ? html`${e.name}` : nothing}
` : nothing }
`; }, }; + +/** + * The box around a needle gauge as drawn: its band arc, hub and needle, the value line (or status + * text) under the hub, the target marker and the tick labels, with a little margin. + */ +function drawingBox( + e: { min: number; max: number }, + angles: [number, number], + ticks: number[], + step: number, + layout: { hasTarget: boolean; labelR: number; valueY: number; compact: boolean }, + hass: Parameters[2], +): Box { + const boxes: Box[] = [ + arcBox(CX, CY, R, angles[0], angles[1], layout.hasTarget ? 15 : 7), + // The needle reaches 42 from the hub, all the way round. + arcBox(CX, CY, 43, angles[0], angles[1], 0), + { x0: CX - 8, y0: CY - 8, x1: CX + 8, y1: CY + 8 }, + // The value (or status) line under the hub, as in the fixed layout. + layout.compact + ? { x0: 2, y0: 40, x1: 128, y1: 82 } + : { x0: 2, y0: layout.valueY - 16, x1: 128, y1: layout.valueY + 6 }, + ]; + const range = e.max - e.min; + for (const v of ticks) { + const deg = angleAt(angles, (v - e.min) / range); + const [x, y] = polar(CX, CY, layout.labelR, deg); + const { anchor, dy } = labelPlacement(deg); + // About 0.6 em per character for these digits. + const width = formatNumber(v, tickDecimals(v, step), hass).length * LABEL_SIZE * 0.6; + const left = anchor === 'start' ? x : anchor === 'end' ? x - width : x - width / 2; + const base = y + dy * LABEL_SIZE; + boxes.push({ x0: left, y0: base - LABEL_SIZE, x1: left + width, y1: base + 2 }); + } + const box = union(...boxes); + return { + x0: Math.floor(box.x0 - 2), + y0: Math.floor(box.y0 - 2), + x1: Math.ceil(box.x1 + 2), + y1: Math.ceil(box.y1 + 2), + }; +} diff --git a/src/styles/radial.ts b/src/styles/radial.ts index 4859aed..5c3354e 100644 --- a/src/styles/radial.ts +++ b/src/styles/radial.ts @@ -1,5 +1,6 @@ import { css, html, nothing, svg } from 'lit'; -import { solidColor } from '../bands'; +import { baseColor, solidColor } from '../bands'; +import { centerContent, customAngles, gaugeAngles, angleAt, arcBox, union } from '../gauge'; import { arcPath, polar, sweepAngles } from '../geometry'; import { iconOverride } from '../resolve'; import { fitStatusText } from '../status-text'; @@ -51,14 +52,14 @@ function statusText(text: string) { /** Ring (360) and three-quarter arc (270) gauges share one drawing. */ export function radialStyle(sweep: 360 | 270): ValueStyleModule { - const [a0, a1] = sweepAngles(sweep, [0, 1]); - const trackD = arcPath(CX, CY, R, a0, a1); + const defaults = sweepAngles(sweep, [0, 1]); + const defaultTrack = arcPath(CX, CY, R, defaults[0], defaults[1]); // The arc's open bottom is empty: crop the drawing just below its round end caps (~98). - const cropped = sweep < 360; + const defaultCropped = sweep < 360; return { layout: 'tiles', group: 'gauges', - height: cropped ? 135 : 155, + height: defaultCropped ? 135 : 155, minColumnWidth: 128, styles: css` /* A definite width: the dial is a size container and can't size the tile itself. */ @@ -129,6 +130,10 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { font-weight: 500; fill: var(--primary-text-color); } + .radial .center-text { + font-weight: 500; + fill: var(--primary-text-color); + } .radial .unit { font-size: 11px; fill: var(--secondary-text-color); @@ -152,6 +157,13 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { transform: translate(-50%, -50%); --mdc-icon-size: 22.58cqw; } + /* A custom sweep crops --y0 drawing units off the top: move the icon up by as much. */ + .radial .dial.fitted .icon { + top: calc(25cqw - var(--y0) * 0.8065cqw); + } + .radial .dial.fitted .icon.centre { + top: calc(48.39cqw - var(--y0) * 0.8065cqw); + } .radial .name { max-width: min(124px, 100%); font-size: 12px; @@ -163,6 +175,20 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { `, render(e, { show, hass }) { const id = gradientId(e.config); + const options = e.config.gauge; + const angles = gaugeAngles(defaults, options); + const custom = customAngles(options); + const trackD = custom ? arcPath(CX, CY, R, angles[0], angles[1]) : defaultTrack; + // A custom sweep crops the drawing to the arc and the centre disc, full width kept. + let y0 = 0; + let h = defaultCropped ? 100 : 120; + if (custom) { + const box = union(arcBox(CX, CY, R, angles[0], angles[1], STROKE / 2 + 1), { x0: 26, y0: 26, x1: 94, y1: 94 }); + y0 = Math.max(0, Math.floor(box.y0)); + h = Math.min(120, Math.ceil(box.y1)) - y0; + } + const cropped = h < 120; + const center = e.available && show.value ? centerContent(e, options) : { kind: 'none' as const }; const empty = e.span[1] - e.span[0] <= 0; // Plain numbers (pathLength 100), not calc() on custom properties, so every engine draws them. const dash = `stroke-dasharray: ${num((e.span[1] - e.span[0]) * 100)} 100; stroke-dashoffset: ${num(-e.span[0] * 100)}`; @@ -175,14 +201,14 @@ export function radialStyle(sweep: 360 | 270): ValueStyleModule { const size = valueFontSize(e.valueText); let tick = nothing as unknown; if (show.target && e.targetPosition !== null && e.available) { - const deg = sweepAngles(sweep, [e.targetPosition, e.targetPosition])[0]; + const deg = angleAt(angles, e.targetPosition); const [x1, y1] = polar(CX, CY, R - STROKE / 2 - 1, deg); const [x2, y2] = polar(CX, CY, R + STROKE / 2 + 1, deg); tick = svg``; } return html`
-
-
+ ${e.name}` : nothing} + ${show.name && options?.center !== 'name' ? html`${e.name}` : nothing}
`; }, }; diff --git a/src/types.ts b/src/types.ts index 6c7cbbe..d261d14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; export type StyleName = | 'bar' @@ -128,6 +129,8 @@ export interface MergedEntity extends EntityOptions { tap_action: ActionConfig; hold_action: ActionConfig; double_tap_action: ActionConfig; + /** Ring, arc and needle: angles, centre text and tick labels (the card's). */ + gauge?: GaugeOptions; } export interface NormalizedConfig { title?: string; diff --git a/test/gauge.test.ts b/test/gauge.test.ts new file mode 100644 index 0000000..3f24d97 --- /dev/null +++ b/test/gauge.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + angleAt, + arcBox, + centerContent, + customAngles, + gaugeAngles, + labelPlacement, + parseGaugeOptions, + scaleTicks, + tickDecimals, + tickStep, + union, +} from '../src/gauge'; +import { arcPath } from '../src/geometry'; +import type { CardConfig } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class StubElement extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends StubElement {}); +} +await import('../src/chartdeck-card'); +await import('../src/editor/chartdeck-card-editor'); + +const cfg = (c: Partial) => ({ type: 'custom:chartdeck-card', ...c }) as CardConfig; + +describe('gauge options', () => { + it("defaults to the value in the middle, no tick labels, the style's own angles", () => { + const o = parseGaugeOptions(cfg({ style: 'ring' })); + expect(o).toEqual({ center: 'value', centerText: '', tickLabels: false }); + expect(customAngles(o)).toBe(false); + expect(gaugeAngles([-135, 135], o)).toEqual([-135, 135]); + }); + + it('reads angles, numeric strings too, and fills a missing one from the style', () => { + const o = parseGaugeOptions(cfg({ style: 'needle', start_angle: '-120' })); + expect(o.startAngle).toBe(-120); + expect(customAngles(o)).toBe(true); + expect(gaugeAngles([-90, 90], o)).toEqual([-120, 90]); + expect(parseGaugeOptions(cfg({ style: 'arc', start_angle: 0, end_angle: 360 })).endAngle).toBe(360); + }); + + it('rejects bad options with readable errors', () => { + expect(() => parseGaugeOptions(cfg({ style: 'ring', start_angle: 'up' }))).toThrow( + '"start_angle" must be a number of degrees from -360 to 360', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', end_angle: 400 }))).toThrow(/end_angle/); + expect(() => parseGaugeOptions(cfg({ style: 'needle', start_angle: 100 }))).toThrow( + '"end_angle" (90) must be greater than "start_angle" (100)', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', start_angle: -200, end_angle: 200 }))).toThrow( + /at most 360 degrees/, + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', center: 'middle' }))).toThrow( + '"center" must be one of: value, band, name, text, none', + ); + expect(() => parseGaugeOptions(cfg({ style: 'ring', center_text: 5 }))).toThrow('"center_text" must be text'); + expect(() => parseGaugeOptions(cfg({ style: 'needle', tick_labels: 'yes' }))).toThrow(/tick_labels/); + // Not a gauge: the order of the angles isn't checked (there is no default to fill from). + expect(() => parseGaugeOptions(cfg({ style: 'bar', start_angle: 100 }))).not.toThrow(); + }); + + it('chooses the centre content', () => { + const e = { name: 'Air', band: { label: 'Good' } }; + const opts = (c: Partial) => parseGaugeOptions(cfg({ style: 'arc', ...c })); + expect(centerContent(e, undefined)).toEqual({ kind: 'value' }); + expect(centerContent(e, opts({ center: 'band' }))).toEqual({ kind: 'text', text: 'Good', band: true }); + expect(centerContent({ name: 'Air', band: {} }, opts({ center: 'band' }))).toEqual({ kind: 'value' }); + expect(centerContent(e, opts({ center: 'name' }))).toEqual({ kind: 'text', text: 'Air', band: false }); + expect(centerContent(e, opts({ center: 'text', center_text: 'Hi' }))).toEqual({ + kind: 'text', + text: 'Hi', + band: false, + }); + expect(centerContent(e, opts({ center: 'text' }))).toEqual({ kind: 'none' }); + expect(centerContent(e, opts({ center: 'none' }))).toEqual({ kind: 'none' }); + }); +}); + +describe('gauge geometry', () => { + it('maps a position along the sweep', () => { + expect(angleAt([-90, 90], 0.5)).toBe(0); + expect(angleAt([-120, 60], 1)).toBe(60); + }); + + it('boxes an arc by its ends and the compass points it passes', () => { + // Top half of a circle at (60, 60), radius 10: from 9 o'clock over the top to 3 o'clock. + expect(arcBox(60, 60, 10, -90, 90, 0)).toEqual({ x0: 50, y0: 50, x1: 70, y1: 60 }); + const full = arcBox(60, 60, 10, -180, 180, 1); + expect(full.x0).toBeCloseTo(49); + expect(full.y1).toBeCloseTo(71); + expect(union({ x0: 0, y0: 5, x1: 3, y1: 6 }, { x0: 1, y0: -1, x1: 2, y1: 9 })).toEqual({ + x0: 0, + y0: -1, + x1: 3, + y1: 9, + }); + }); + + it('makes round tick values from min to max, ends always labelled', () => { + expect(tickStep(5)).toBe(1); + expect(tickStep(100)).toBe(20); + expect(tickStep(0)).toBe(1); + expect(scaleTicks(0, 5)).toEqual([0, 1, 2, 3, 4, 5]); + expect(scaleTicks(0, 100)).toEqual([0, 20, 40, 60, 80, 100]); + expect(scaleTicks(-2000, 2000)).toEqual([-2000, -1000, 0, 1000, 2000]); + // 13 and 87 are the scale's ends; 20 and 80 sit too close to them (under 40 % of a step). + expect(scaleTicks(13, 87)).toEqual([13, 40, 60, 87]); + // Here 20 and 80 are far enough from the ends to keep. + expect(scaleTicks(11, 99)).toEqual([11, 20, 40, 60, 80, 99]); + expect(scaleTicks(5, 5)).toEqual([]); + expect(tickDecimals(2.5, 2.5)).toBe(1); + expect(tickDecimals(13.25, 20)).toBe(2); + expect(tickDecimals(1000, 1000)).toBe(0); + }); + + it('anchors labels outward from the dial', () => { + expect(labelPlacement(-90)).toEqual({ anchor: 'end', dy: 0.35 }); + expect(labelPlacement(90)).toEqual({ anchor: 'start', dy: 0.35 }); + expect(labelPlacement(0)).toEqual({ anchor: 'middle', dy: 0 }); + expect(labelPlacement(180)).toEqual({ anchor: 'middle', dy: 0.75 }); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: unknown; updateComplete: Promise }; + +async function mount(config: Partial): Promise { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig(cfg({ entities: ['sensor.a'], ...config })); + card.hass = makeHass({ + 'sensor.a': ['42', { unit_of_measurement: '%', friendly_name: 'Air' }], + 'sensor.b': 'unavailable', + }); + document.body.appendChild(card); + await card.updateComplete; + return card; +} +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('gauges with extras', () => { + it('leave the default drawing alone', async () => { + const ring = await mount({ style: 'ring' }); + expect(q(ring, '.dial')!.classList.contains('fitted')).toBe(false); + expect(q(ring, '.radial svg')!.getAttribute('viewBox')).toBe('0 0 120 120'); + const needle = await mount({ style: 'needle' }); + expect(q(needle, '.needle-gauge svg')!.getAttribute('viewBox')).toBe('0 0 130 104'); + expect(qa(needle, '.tickmark')).toHaveLength(11); + expect(qa(needle, '.tick-label')).toHaveLength(0); + }); + + it('draw a ring or arc over custom angles, cropped to the sweep', async () => { + const card = await mount({ style: 'arc', start_angle: -90, end_angle: 90, target: 50 }); + expect(q(card, '.track')!.getAttribute('d')).toBe(arcPath(60, 60, 46, -90, 90)); + const dial = q(card, '.dial') as HTMLElement; + expect(dial.classList.contains('fitted')).toBe(true); + // Top half: the arc's top (60 - 46 - 6.5) to the centre disc's bottom (94). + expect(q(card, '.radial svg')!.getAttribute('viewBox')).toBe('0 7 120 87'); + expect(dial.getAttribute('style')).toContain('--y0: 7'); + // The target at 50 % is straight up. + const tick = q(card, '.tick')!; + expect(tick.getAttribute('x1')).toBe('60.00'); + }); + + it('show band label, name, own text or nothing in the middle', async () => { + const bands = [ + { from: 0, color: 'red', label: 'Poor' }, + { from: 30, color: 'green', label: 'Good' }, + ]; + const band = await mount({ style: 'ring', center: 'band', severity: bands }); + expect(q(band, '.center-text')!.textContent).toBe('Good'); + expect(q(band, '.center-text')!.getAttribute('style')).toContain('--success-color'); + expect(q(band, '.value')).toBeNull(); + + const name = await mount({ style: 'arc', center: 'name' }); + expect(q(name, '.center-text')!.textContent).toBe('Air'); + expect(q(name, '.name')).toBeNull(); + + const text = await mount({ style: 'ring', center: 'text', center_text: 'Comfy' }); + expect(q(text, '.center-text')!.textContent).toBe('Comfy'); + + const none = await mount({ style: 'ring', center: 'none' }); + expect(q(none, '.center-text')).toBeNull(); + expect(q(none, '.value')).toBeNull(); + expect(q(none, '.icon')!.classList.contains('centre')).toBe(true); + + const needle = await mount({ style: 'needle', center: 'band', severity: bands }); + expect(q(needle, '.center-text')!.textContent).toBe('Good'); + }); + + it('still show status text for an unavailable entity whatever the centre', async () => { + const card = await mount({ style: 'ring', center: 'text', center_text: 'Hi', entities: ['sensor.b'] }); + expect(q(card, '.na')!.textContent).toContain('Unavailable'); + expect(q(card, '.center-text')).toBeNull(); + const needle = await mount({ + style: 'needle', + tick_labels: true, + start_angle: -135, + end_angle: 135, + entities: ['sensor.b', 'sensor.missing'], + }); + expect(qa(needle, '.na')).toHaveLength(2); + expect(qa(needle, '.needle')).toHaveLength(0); + }); + + it('label the needle scale, following custom angles, outside the target marker', async () => { + const card = await mount({ style: 'needle', tick_labels: true, max: 5, target: 3 }); + expect(qa(card, '.tick-label').map((t) => t.textContent)).toEqual(['0', '1', '2', '3', '4', '5']); + expect(qa(card, '.tickmark')).toHaveLength(6); + const gauge = q(card, '.needle-gauge') as HTMLElement; + expect(gauge.classList.contains('fitted')).toBe(true); + expect(gauge.getAttribute('style')).toContain('--needle-start: -90deg'); + // Left end label: at 9 o'clock, past the target marker (R + 17 = 67 from the hub at x 65). + const first = qa(card, '.tick-label')[0]; + expect(first.getAttribute('x')).toBe('-2.00'); + expect(first.getAttribute('text-anchor')).toBe('end'); + + const wide = await mount({ style: 'needle', start_angle: -135, end_angle: 135, show: { target: false } }); + expect(q(wide, '.needle g, g.needle')!.getAttribute('style')).toMatch(/rotate\(-21\.6deg\)/); + // The value line moves under the arc, which now comes down past the hub. + expect(Number(q(wide, '.value')!.getAttribute('y'))).toBeGreaterThan(110); + }); +}); + +describe('gauge editor', () => { + type Editor = HTMLElement & { setConfig(c: CardConfig): void; hass?: unknown; updateComplete: Promise }; + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as Editor; + ed.hass = makeHass({ 'sensor.a': '1' }); + ed.setConfig(cfg(config)); + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + const form = (ed: Editor) => ed.shadowRoot!.querySelector('ha-form[data-section="gauge"]') as StubElement | null; + const names = (schema: unknown) => + (schema as { name: string; schema?: { name: string }[] }[]).flatMap((f) => + f.schema ? f.schema.map((x) => x.name) : [f.name], + ); + + it('shows gauge options only for gauge styles, tick labels only on the needle', async () => { + expect(form((await editor({ style: 'bar' })).ed)).toBeNull(); + const ring = form((await editor({ style: 'ring' })).ed)!; + expect(names(ring.schema)).toEqual(['start_angle', 'end_angle', 'center']); + const needle = form((await editor({ style: 'needle', center: 'text' })).ed)!; + expect(names(needle.schema)).toEqual(['start_angle', 'end_angle', 'center', 'center_text', 'tick_labels']); + expect(needle.data).toEqual({ center: 'text', tick_labels: false }); + }); + + it('writes the options to the card, dropping defaults', async () => { + const { ed, events } = await editor({ style: 'arc', center: 'band' }); + const f = form(ed)!; + f.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { start_angle: -90, end_angle: 90, center: 'value' } }, + bubbles: true, + composed: true, + }), + ); + const last = events.at(-1)!; + expect(last.start_angle).toBe(-90); + expect(last.end_angle).toBe(90); + expect('center' in last).toBe(false); + }); +}); From a56f66be3ee03f4469cb4dc5ccf4528d3083a0f7 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:33:58 +0000 Subject: [PATCH 02/41] feat: show stages that narrow down as a funnel or pyramid A new "Funnel" style, under "Parts of a total" in the style picker, draws the card's entities as stages, each as wide as its value against the largest one. It suits values that are each a share of the last: solar produced, then used in the house, then exported. The shape option picks a funnel (first stage on top, each stage narrowing to the width of the next) or a pyramid (first stage at the bottom, the top one coming to a point). Each stage shows its percentage of the first stage, of the stage before, or none (percent_of). Stages take one colour per entity (an entity's own color, else the series palette) or their colour band (color_by: bands), and the percentage switches to dark text on light fills. Names and values sit in a column beside the shape and follow the Show name / Show value switches. An unavailable, missing, zero or negative stage is drawn as an empty dashed outline with its state beside it, and percentages that would divide by nothing are left out. Every stage is a tappable entity, so tap, hold and double-tap actions work as on the other styles. The stages grow in from the centre unless animation is off or the browser asks for reduced motion. All three options are in the editor's Chart section. The small editor helpers (chartForm, cachedSchema, choice) are the same as on the line chart branch. Closes #29 --- src/chart/colors.ts | 18 +++ src/styles/funnel.ts | 274 +++++++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 45 +------ test/funnel.test.ts | 212 +++++++++++++++++++++++++++++++++ 5 files changed, 508 insertions(+), 43 deletions(-) create mode 100644 src/chart/colors.ts create mode 100644 src/styles/funnel.ts create mode 100644 test/funnel.test.ts diff --git a/src/chart/colors.ts b/src/chart/colors.ts new file mode 100644 index 0000000..7b0fc93 --- /dev/null +++ b/src/chart/colors.ts @@ -0,0 +1,18 @@ +import { baseColor } from '../bands'; + +/** Series colours in order, for entities and groups without their own `color`. */ +export const SERIES_COLORS = ['primary', 'amber', 'green', 'purple', 'red', '#14b8a6', '#ec4899', 'grey']; + +/** The n-th series colour (wrapping round), or `own` when set, ready for CSS. */ +export function seriesColor(index: number, own?: unknown): string { + return baseColor(typeof own === 'string' && own ? own : SERIES_COLORS[index % SERIES_COLORS.length]); +} + +/** + * `color` faded towards the card background: 100 is the colour itself, 0 the background. Works + * in light and dark themes alike, since it mixes with whatever the card is drawn on. + */ +export function shade(color: string, percent: number): string { + const p = Math.round(Math.min(100, Math.max(0, percent))); + return p >= 100 ? color : `color-mix(in srgb, ${color} ${p}%, var(--card-background-color, #fff))`; +} diff --git a/src/styles/funnel.ts b/src/styles/funnel.ts new file mode 100644 index 0000000..1f9aaaa --- /dev/null +++ b/src/styles/funnel.ts @@ -0,0 +1,274 @@ +import { css, html, nothing, svg } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS } from '../chart/colors'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { isInteractive } from '../actions'; +import { formatNumber } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const FUNNEL_SHAPES = ['funnel', 'pyramid'] as const; +export const PERCENT_OF = ['first', 'previous', 'none'] as const; +export const FUNNEL_COLOR_BY = ['series', 'bands'] as const; +export type FunnelShape = (typeof FUNNEL_SHAPES)[number]; +export type PercentOf = (typeof PERCENT_OF)[number]; + +/** A stage below this width (percent of the widest) is too narrow for its percentage inside. */ +const NARROW = 22; +/** How much the last funnel stage narrows towards its bottom edge. */ +const TAPER = 0.8; + +registerStrings('en', { + editor_style_funnel: 'Funnel', + editor_funnel_shape: 'Shape', + editor_funnel_shape_funnel: 'Funnel (first stage at the top)', + editor_funnel_shape_pyramid: 'Pyramid (first stage at the bottom)', + editor_funnel_percent_of: 'Percentages', + editor_funnel_percent_of_first: 'Share of the first stage', + editor_funnel_percent_of_previous: 'Share of the stage before', + editor_funnel_percent_of_none: "Don't show", + editor_funnel_color_by: 'Colour', + editor_funnel_color_by_series: 'One colour per entity', + editor_funnel_color_by_bands: 'By colour band', + funnel_aria: '{style} of {names}', +}); + +/** Each stage's width as a percentage of the largest; no number (or none above 0) is 0. */ +export function stageWidths(values: (number | null)[]): number[] { + const max = Math.max(0, ...values.map((v) => (v !== null && v > 0 ? v : 0))); + return values.map((v) => (max > 0 && v !== null && v > 0 ? (v / max) * 100 : 0)); +} + +/** + * Each stage as a percentage of the first, or of the stage before (the first is then 100). + * Null where there is nothing to divide by or no value. + */ +export function stagePercents(values: (number | null)[], of: PercentOf): (number | null)[] { + if (of === 'none') return values.map(() => null); + return values.map((v, i) => { + if (v === null) return null; + if (of === 'previous' && i === 0) return 100; + const base = of === 'first' ? values[0] : values[i - 1]; + return base !== null && base !== undefined && base > 0 ? (v / base) * 100 : null; + }); +} + +/** + * The top and bottom widths of each stage's trapezoid, in the order the stages are listed. + * Funnel: a stage narrows to the width of the one below it; the last narrows a little. Pyramid + * (first stage at the bottom): a stage narrows to the width of the one above it; the top one + * comes to a point. A stage next to one without a value narrows a little instead. + */ +export function stageEdges(widths: number[], shape: FunnelShape): { top: number; bottom: number }[] { + return widths.map((w, i) => { + if (w <= 0) return { top: 0, bottom: 0 }; + const next = widths[i + 1]; + const last = i === widths.length - 1; + if (shape === 'pyramid') { + const top = last ? 0 : next > 0 ? next : w * TAPER; + return { top, bottom: w }; + } + return { top: w, bottom: !last && next > 0 ? next : w * TAPER }; + }); +} + +/** The trapezoid's corners, centred in a 100 × 10 box, as an SVG `points` attribute. */ +export function trapezoid(top: number, bottom: number): string { + const r = (n: number) => Math.round(n * 100) / 100; + const t = r((100 - top) / 2); + const b = r((100 - bottom) / 2); + return `${t},0 ${r(100 - t)},0 ${r(100 - b)},10 ${b},10`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'funnel_shape', path: ['shape'], default: 'funnel' }, + { name: 'funnel_percent_of', path: ['percent_of'], default: 'first' }, + { name: 'funnel_color_by', path: ['color_by'], default: 'series' }, +]; + +function funnelEditor(ctx: ChartEditorContext) { + const schema = cachedSchema('funnel', ctx.hass, () => [ + { name: 'funnel_shape', selector: choice(ctx.hass, 'funnel_shape', FUNNEL_SHAPES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'funnel_percent_of', selector: choice(ctx.hass, 'funnel_percent_of', PERCENT_OF) }, + { name: 'funnel_color_by', selector: choice(ctx.hass, 'funnel_color_by', FUNNEL_COLOR_BY) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +function check(raw: CardConfig, key: string, allowed: readonly string[]): void { + const v = raw[key]; + if (v !== undefined && !allowed.includes(v as string)) { + throw new Error(`"${key}" must be one of: ${allowed.join(', ')}`); + } +} + +/** The colour name (palette or CSS) a stage is filled with. */ +function stageColor(e: ResolvedEntity, index: number, byBands: boolean): string { + if (byBands) return e.band.color; + const own = (e.config as { color?: unknown }).color; + return typeof own === 'string' && own ? own : SERIES_COLORS[index % SERIES_COLORS.length]; +} + +export const funnel: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + .style-funnel .funnel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; + } + .style-funnel .stage { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + align-items: center; + gap: 12px; + } + .style-funnel .funnel.no-labels .stage { + grid-template-columns: minmax(0, 1fr); + } + .style-funnel .shape { + position: relative; + height: 44px; + } + .style-funnel .shape svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + transform: scaleX(0); + transform-origin: 50% 50%; + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .style-funnel .shape svg { + transform: scaleX(1); + } + .style-funnel polygon.empty { + fill: none; + stroke: var(--secondary-text-color); + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + } + .style-funnel .percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + pointer-events: none; + } + .style-funnel .percent.dark { + color: #1d1f24; + text-shadow: none; + } + .style-funnel .percent.theme { + color: var(--primary-text-color); + text-shadow: none; + } + .style-funnel .label { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + } + .style-funnel .name { + color: var(--secondary-text-color); + font-size: 0.875em; + overflow: hidden; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .style-funnel .value { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .style-funnel .unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + } + `, + thumbnail: () => svg` + + + `, + validate(raw: CardConfig) { + check(raw, 'shape', FUNNEL_SHAPES); + check(raw, 'percent_of', PERCENT_OF); + check(raw, 'color_by', FUNNEL_COLOR_BY); + }, + editor: funnelEditor, + render(ctx) { + const { items, raw, hass, config } = ctx; + const shape = (raw.shape as FunnelShape | undefined) ?? 'funnel'; + const byBands = raw.color_by === 'bands'; + const values = items.map((e) => (e.available ? e.value : null)); + const widths = stageWidths(values); + const edges = stageEdges(widths, shape); + const percents = stagePercents(values, (raw.percent_of as PercentOf | undefined) ?? 'first'); + const { show } = config; + const labels = show.name || show.value; + const stages = items.map((e, i) => { + const color = stageColor(e, i, byBands); + const percent = percents[i]; + const percentText = percent === null ? '' : `${formatNumber(percent, 0, hass)}%`; + const textTone = widths[i] < NARROW ? 'theme' : onFillText(color); + const interactive = isInteractive(e.config); + const { top, bottom } = edges[i]; + const drawn = widths[i] > 0; + return html``; + }); + // A pyramid builds up from its first stage, so the first is drawn at the bottom. + if (shape === 'pyramid') stages.reverse(); + const label = localize(hass, 'funnel_aria', { + style: localize(hass, `editor_funnel_shape_${shape}`).replace(/ \(.*\)$/, ''), + names: items.map((e) => e.name).join(', '), + }); + return html`
${stages}
`; + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 6e0645d..1d877bc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -21,6 +21,7 @@ import { segmented } from './segmented'; import { tank } from './tank'; import { thin } from './thin'; import { waffle } from './waffle'; +import { funnel } from './funnel'; export interface RenderContext { show: ShowOptions; @@ -130,4 +131,5 @@ export const STYLES: Record = { bubble, heatmap, box, + funnel, }; diff --git a/src/types.ts b/src/types.ts index d261d14..99c393c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,48 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = - | 'bar' - | 'thin' - | 'segmented' - | 'tank' - | 'ring' - | 'needle' - | 'arc' - | 'line' - | 'column' - | 'area' - | 'combined' - | 'pie' - | 'rings' - | 'pictograph' - | 'waffle' - | 'histogram' - | 'scatter' - | 'bubble' - | 'heatmap' - | 'box'; -export const STYLE_NAMES: StyleName[] = [ - 'bar', - 'thin', - 'segmented', - 'tank', - 'ring', - 'needle', - 'arc', - 'line', - 'column', - 'area', - 'combined', - 'pie', - 'rings', - 'pictograph', - 'waffle', - 'histogram', - 'scatter', - 'bubble', - 'heatmap', - 'box', -]; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/funnel.test.ts b/test/funnel.test.ts new file mode 100644 index 0000000..9ca0194 --- /dev/null +++ b/test/funnel.test.ts @@ -0,0 +1,212 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { stageEdges, stagePercents, stageWidths, trapezoid } from '../src/styles/funnel'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass() { + return makeHass({ + 'sensor.solar': ['24.6', { friendly_name: 'Solar produced', unit_of_measurement: 'kWh' }], + 'sensor.home': ['18.4', { friendly_name: 'Used at home', unit_of_measurement: 'kWh' }], + 'sensor.battery': ['7.2', { friendly_name: 'Through battery', unit_of_measurement: 'kWh' }], + 'sensor.dark': ['unavailable', { friendly_name: 'After dark' }], + }); +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'funnel', + entities: ['sensor.solar', 'sensor.home', 'sensor.battery'], + ...config, + } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element) => el.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('funnel maths', () => { + it('sizes stages against the largest, with nothing for missing or non-positive values', () => { + expect(stageWidths([20, 10, null, -5, 5])).toEqual([100, 50, 0, 0, 25]); + expect(stageWidths([null, 0])).toEqual([0, 0]); + }); + + it('gives percentages of the first or the previous stage', () => { + expect(stagePercents([200, 150, 30], 'first')).toEqual([100, 75, 15]); + expect(stagePercents([200, 150, 30], 'previous')).toEqual([100, 75, 20]); + expect(stagePercents([0, 5, null], 'first')).toEqual([null, null, null]); + expect(stagePercents([10, null, 5], 'previous')).toEqual([100, null, null]); + expect(stagePercents([10, 5], 'none')).toEqual([null, null]); + }); + + it('joins each funnel stage to the next, tapering the last and around gaps', () => { + expect(stageEdges([100, 50, 0, 20], 'funnel')).toEqual([ + { top: 100, bottom: 50 }, + { top: 50, bottom: 40 }, + { top: 0, bottom: 0 }, + { top: 20, bottom: 16 }, + ]); + }); + + it('builds a pyramid up from the first stage to a point', () => { + expect(stageEdges([100, 50, 25], 'pyramid')).toEqual([ + { top: 50, bottom: 100 }, + { top: 25, bottom: 50 }, + { top: 0, bottom: 25 }, + ]); + expect(stageEdges([100, 0, 25], 'pyramid')[0]).toEqual({ top: 80, bottom: 100 }); + }); + + it('centres a trapezoid in a 100 × 10 box', () => { + expect(trapezoid(100, 50)).toBe('0,0 100,0 75,10 25,10'); + expect(trapezoid(0, 25)).toBe('50,0 50,0 62.5,10 37.5,10'); + }); +}); + +describe('funnel chart', () => { + it('draws one stage per entity, top down, with percentages of the first and labels', async () => { + const card = await mount({}); + const stages = qa(card, '.stage.entity'); + expect(stages.map((s) => s.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(qa(card, '.percent').map(text)).toEqual(['100%', '75%', '29%']); + expect(qa(card, '.label').map(text)).toEqual([ + 'Solar produced 24.6 kWh', + 'Used at home 18.4 kWh', + 'Through battery 7.2 kWh', + ]); + const first = qa(card, 'polygon')[0]; + expect(first.getAttribute('points')).toBe(trapezoid(100, (18.4 / 24.6) * 100)); + expect(first.getAttribute('fill')).toContain('--primary-color'); + expect(qa(card, 'polygon')[1].getAttribute('fill')).toContain('--warning-color'); + // Amber takes dark text. + expect(qa(card, '.percent')[1].classList.contains('dark')).toBe(true); + expect(stages[0].getAttribute('aria-label')).toBe('Solar produced, 24.6 kWh, 100%'); + expect(card.shadowRoot!.querySelector('.funnel')!.getAttribute('aria-label')).toBe( + 'Funnel of Solar produced, Used at home, Through battery', + ); + }); + + it('stacks a pyramid from the bottom, and shows shares of the stage before', async () => { + const card = await mount({ shape: 'pyramid', percent_of: 'previous' } as Partial); + const stages = qa(card, '.stage.entity'); + expect(stages.map((s) => s.getAttribute('data-index'))).toEqual(['2', '1', '0']); + expect(qa(card, '.percent').map(text)).toEqual(['39%', '75%', '100%']); + expect(qa(card, 'polygon')[0].getAttribute('points')!.startsWith('50,0 50,0')).toBe(true); + expect(card.shadowRoot!.querySelector('.funnel')!.getAttribute('aria-label')).toMatch(/^Pyramid of/); + }); + + it('hides percentages and labels when asked', async () => { + const card = await mount({ percent_of: 'none', show: { name: false, value: false } } as Partial); + expect(qa(card, '.percent')).toHaveLength(0); + expect(qa(card, '.label')).toHaveLength(0); + expect(card.shadowRoot!.querySelector('.funnel.no-labels')).not.toBeNull(); + }); + + it('colours by band and by an entity’s own colour', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'red' }, + { from: 10, color: 'green' }, + ], + } as Partial); + const fills = qa(card, 'polygon').map((p) => p.getAttribute('fill')); + expect(fills[0]).toContain('--success-color'); + expect(fills[2]).toContain('--error-color'); + document.body.innerHTML = ''; + const own = await mount({ + entities: [{ entity: 'sensor.solar', color: '#ffeb3b' }, 'sensor.home'], + } as Partial); + expect(qa(own, 'polygon')[0].getAttribute('fill')).toBe('#ffeb3b'); + expect(qa(own, '.percent')[0].classList.contains('dark')).toBe(true); + }); + + it('draws an unavailable or missing stage as an empty outline with its state', async () => { + const card = await mount({ entities: ['sensor.solar', 'sensor.dark', 'sensor.missing'] }); + expect(qa(card, 'polygon.empty')).toHaveLength(2); + expect(qa(card, '.percent').map(text)).toEqual(['100%']); + expect(qa(card, '.label').map(text)).toEqual([ + 'Solar produced 24.6 kWh', + 'After dark Unavailable', + 'sensor.missing Entity not found', + ]); + expect(qa(card, '.stage.unavailable')).toHaveLength(2); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'funnel', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ shape: 'cone' })).toThrow(/"shape" must be one of: funnel, pyramid/); + expect(set({ percent_of: 'last' })).toThrow(/percent_of/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ shape: 'pyramid', percent_of: 'none', color_by: 'bands' })).not.toThrow(); + }); + + it('opens more-info from a stage', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const stage = qa(card, '.stage.entity')[1] as HTMLElement; + stage.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + stage.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.home']); + }); +}); + +describe('funnel editor', () => { + it('shows the Chart section and writes shape, percentages and colour', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'funnel', entities: ['sensor.solar'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ funnel_shape: 'funnel', funnel_percent_of: 'first', funnel_color_by: 'series' }); + const labels = (form.computeLabel as (s: { name: string }) => string)({ name: 'funnel_percent_of' }); + expect(labels).toBe('Percentages'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), funnel_shape: 'pyramid', funnel_color_by: 'bands' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shape: 'pyramid', color_by: 'bands' }); + expect( + ed.shadowRoot!.querySelector('.styles[data-group="totals"] .style[data-style="funnel"] svg polygon'), + ).not.toBeNull(); + }); +}); From 4a7f17ca0dde57c16d9769824cbd40c3f99dd606 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:35:14 +0000 Subject: [PATCH 03/41] feat: compare profiles of several values on a radar chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new radar style draws the card's entities as spokes round a circle, the first straight up, with a shape joining their values. It is in the style picker under Comparisons. Each axis runs from its entity's own minimum to maximum (scale: limits), so readings in different units, such as CO2 and temperature, still make a fair shape; scale: shared puts every axis on one scale from 0 to the largest value instead. Shapes are filled at a fifth of their colour, or drawn as outlines with fill: false. Several shapes come from grouping: group_by reads each entity's floor, area, device or label from Home Assistant's registries, and an entity's own group text wins ("Ground floor / Kitchen" for two levels). Each group is one shape in the next series colour, named in a legend under the chart. Entities then line up on an axis by their own axis name, else their device class (shown as "CO₂", "Humidity"...), else their name. Without groups every entity is an axis and its value shows under the axis label. An unavailable or missing entity keeps its axis, with its status shown muted, but gets no point; the shape skips it. Each point is a tappable entity, so tap, hold and double-tap actions open more-info as usual. Shapes grow out from the centre on first draw, and not at all with reduced motion or animate: false. Everything is in the editor's Chart section: scale, fill, group by, and a text box per entity for its own group and its axis name. Shared helpers added for the charts that follow: polar maths (src/chart/polar.ts), grouping and trees (src/chart/hierarchy.ts), series colours (src/chart/colors.ts) and the chart editor forms (src/editor/chart-editors.ts, entity-options.ts, group-fields.ts). Closes #32 --- src/chart/hierarchy.ts | 186 +++++++++++++++++++ src/chart/polar.ts | 58 ++++++ src/editor/entity-options.ts | 64 +++++++ src/editor/group-fields.ts | 49 ++++++ src/styles/index.ts | 2 + src/styles/radar.ts | 333 +++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/hierarchy.test.ts | 134 ++++++++++++++ test/polar.test.ts | 43 +++++ test/radar.test.ts | 229 ++++++++++++++++++++++++ 10 files changed, 1100 insertions(+), 2 deletions(-) create mode 100644 src/chart/hierarchy.ts create mode 100644 src/chart/polar.ts create mode 100644 src/editor/entity-options.ts create mode 100644 src/editor/group-fields.ts create mode 100644 src/styles/radar.ts create mode 100644 test/hierarchy.test.ts create mode 100644 test/polar.test.ts create mode 100644 test/radar.test.ts diff --git a/src/chart/hierarchy.ts b/src/chart/hierarchy.ts new file mode 100644 index 0000000..19ddf37 --- /dev/null +++ b/src/chart/hierarchy.ts @@ -0,0 +1,186 @@ +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** What `group_by` can read from Home Assistant's registries, outermost first. */ +export const GROUP_LEVELS = ['floor', 'area', 'device', 'label'] as const; +export type GroupLevel = (typeof GROUP_LEVELS)[number]; + +/** `group_by` from YAML: one level or a list of them. Kept in house order (floor before area…). */ +export function parseGroupBy(raw: unknown): GroupLevel[] { + if (raw === undefined || raw === null || raw === '') return []; + const list = Array.isArray(raw) ? raw : [raw]; + if (list.some((l) => !GROUP_LEVELS.includes(l as GroupLevel))) { + throw new Error(`"group_by" must be one or more of: ${GROUP_LEVELS.join(', ')}`); + } + return GROUP_LEVELS.filter((l) => list.includes(l)); +} + +/** Checks for `group_by` and each entity's own `group`. */ +export function validateGroups(raw: CardConfig): void { + parseGroupBy(raw.group_by); + (raw.entities ?? []).forEach((e, i) => { + const group = typeof e === 'object' && e ? (e as { group?: unknown }).group : undefined; + if (group !== undefined && group !== null && typeof group !== 'string') { + throw new Error(`Entity ${i + 1}: "group" must be text, such as "Kitchen" or "Ground floor / Kitchen"`); + } + }); +} + +/** An entity's own `group`, split at each "/" into levels: "Ground floor / Kitchen". */ +export function splitGroup(text: unknown): string[] { + if (typeof text !== 'string') return []; + return text + .split('/') + .map((s) => s.trim()) + .filter(Boolean); +} + +/** The registry fields grouping reads, which the card's own HomeAssistant type leaves out. */ +interface Registries { + entities?: Record; + devices?: Record< + string, + { name?: string | null; name_by_user?: string | null; area_id?: string | null; labels?: string[] } + >; + areas?: Record; + floors?: Record; + labels?: Record; +} + +/** "living_room" → "Living room": a readable stand-in when only an id is known. */ +function fromId(id: string): string { + const text = id.replace(/_/g, ' ').trim(); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** The name of `entityId`'s floor, area, device or first label, from the registries. */ +export function levelName(entityId: string, level: GroupLevel, hass: HomeAssistant): string | undefined { + const reg = hass as unknown as Registries; + const entry = reg.entities?.[entityId]; + const device = entry?.device_id ? reg.devices?.[entry.device_id] : undefined; + const areaId = entry?.area_id ?? device?.area_id ?? undefined; + switch (level) { + case 'area': + return areaId ? (reg.areas?.[areaId]?.name ?? fromId(areaId)) : undefined; + case 'floor': { + const floorId = areaId ? reg.areas?.[areaId]?.floor_id : undefined; + return floorId ? (reg.floors?.[floorId]?.name ?? fromId(floorId)) : undefined; + } + case 'device': + return device ? (device.name_by_user ?? device.name ?? undefined) : undefined; + case 'label': { + const label = [...(entry?.labels ?? []), ...(device?.labels ?? [])][0]; + return label ? (reg.labels?.[label]?.name ?? fromId(label)) : undefined; + } + } +} + +/** + * Which groups an entity sits in, outermost first. Its own `group` wins; otherwise each + * `group_by` level is read from the registries, and a level it has none of is `other`. + */ +export function groupPath(e: ResolvedEntity, levels: GroupLevel[], hass: HomeAssistant, other: string): string[] { + const own = splitGroup((e.config as { group?: unknown }).group); + if (own.length) return own; + return levels.map((level) => levelName(e.entityId, level, hass) ?? other); +} + +export interface TreeNode { + name: string; + /** Group names from the top down to this node; empty for the root. A leaf has its group's. */ + path: string[]; + /** The sum of the positive values below (a leaf: its own value, or 0 without one). */ + value: number; + depth: number; + children: TreeNode[]; + /** Leaves only: the entity and its index into the chart's items. */ + entity?: ResolvedEntity; + index?: number; +} + +/** A leaf's size: its value when that is a positive number, else nothing. */ +export function leafValue(e: ResolvedEntity): number { + return e.available && e.value !== null && e.value > 0 ? e.value : 0; +} + +/** + * The entities as a tree: a root holding the groups, each holding its subgroups and then its + * entities. Every level is ordered largest first; equal values keep the order they came in. + */ +export function buildTree(items: ResolvedEntity[], paths: string[][], rootName: string): TreeNode { + const root: TreeNode = { name: rootName, path: [], value: 0, depth: 0, children: [] }; + items.forEach((entity, index) => { + let node = root; + for (const name of paths[index] ?? []) { + let child = node.children.find((c) => c.entity === undefined && c.name === name); + if (!child) { + child = { name, path: [...node.path, name], value: 0, depth: node.depth + 1, children: [] }; + node.children.push(child); + } + node = child; + } + node.children.push({ + name: entity.name, + path: node.path, + value: 0, + depth: node.depth + 1, + children: [], + entity, + index, + }); + }); + const total = (node: TreeNode): number => { + node.value = node.entity ? leafValue(node.entity) : node.children.reduce((sum, c) => sum + total(c), 0); + const order = new Map(node.children.map((c, i) => [c, i])); + node.children.sort((a, b) => b.value - a.value || order.get(a)! - order.get(b)!); + return node.value; + }; + total(root); + return root; +} + +/** The group at `path` below `root`, or undefined when there is no such group (any more). */ +export function findNode(root: TreeNode, path: string[]): TreeNode | undefined { + let node: TreeNode | undefined = root; + for (const name of path) { + node = node.children.find((c) => c.entity === undefined && c.name === name); + if (!node) return undefined; + } + return node; +} + +/** How many levels lie below `node` (0 for a leaf). */ +export function treeHeight(node: TreeNode): number { + return node.children.length ? 1 + Math.max(...node.children.map(treeHeight)) : 0; +} + +export interface Span { + node: TreeNode; + /** Levels below the partition's root: 0 is the root itself. */ + level: number; + /** Where the node starts and ends, as fractions of the root's value (0–1). */ + x0: number; + x1: number; +} + +/** + * Every node under `root` with the share of the whole it covers, for sunbursts and icicles: + * each child gets a slice of its parent's span in proportion to its value. Nodes with nothing to + * show (a zero value) are left out. + */ +export function partition(root: TreeNode): Span[] { + const out: Span[] = []; + const walk = (node: TreeNode, level: number, x0: number, x1: number) => { + out.push({ node, level, x0, x1 }); + if (node.value <= 0) return; + let x = x0; + for (const child of node.children) { + if (child.value <= 0) continue; + const w = ((x1 - x0) * child.value) / node.value; + walk(child, level + 1, x, x + w); + x += w; + } + }; + walk(root, 0, 0, 1); + return out; +} diff --git a/src/chart/polar.ts b/src/chart/polar.ts new file mode 100644 index 0000000..bd3e91a --- /dev/null +++ b/src/chart/polar.ts @@ -0,0 +1,58 @@ +/** Angles are in radians, measured clockwise from 12 o'clock, as a compass reads. */ + +const TAU = Math.PI * 2; + +/** The point at radius `r` and `angle` from the centre (0, 0), in SVG coordinates (y down). */ +export function polar(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +function round(n: number): number { + return Math.round(n * 100) / 100 || 0; +} + +/** + * The SVG path of a ring sector from `a0` to `a1` between radii `r0` (inner) and `r1` (outer). + * An inner radius of 0 makes a pie wedge; a span of a full turn makes a whole ring (or disc). + */ +export function sectorPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= 0 || a1 <= a0) return ''; + const inner = Math.max(0, r0); + if (a1 - a0 >= TAU - 1e-6) { + // A full turn: two half arcs each way, since one arc can't start and end on the same point. + const ring = (r: number, sweep: 0 | 1) => { + const [x0, y0] = polar(r, 0); + const [x1, y1] = polar(r, Math.PI); + return `M${x0} ${y0}A${r} ${r} 0 1 ${sweep} ${x1} ${y1}A${r} ${r} 0 1 ${sweep} ${x0} ${y0}Z`; + }; + return inner > 0 ? `${ring(r1, 1)}${ring(inner, 0)}` : ring(r1, 1); + } + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polar(r1, a0); + const [ox1, oy1] = polar(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (inner === 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polar(inner, a1); + const [ix0, iy0] = polar(inner, a0); + return `${outer}L${ix1} ${iy1}A${inner} ${inner} 0 ${large} 0 ${ix0} ${iy0}Z`; +} + +/** The angle of spoke `i` of `n` evenly spaced round a circle, the first straight up. */ +export function axisAngle(i: number, n: number): number { + return n > 0 ? (i * TAU) / n : 0; +} + +/** `points` as an SVG polygon `points` attribute. */ +export function polygonPoints(points: [number, number][]): string { + return points.map(([x, y]) => `${x},${y}`).join(' '); +} + +/** + * Where a label at `angle` should hang from its anchor point: text left of the centre line ends + * at the point, right of it starts there, and on the line itself it is centred. + */ +export function labelAnchor(angle: number): 'start' | 'middle' | 'end' { + const x = Math.sin(angle); + if (Math.abs(x) < 0.2) return 'middle'; + return x > 0 ? 'start' : 'end'; +} diff --git a/src/editor/entity-options.ts b/src/editor/entity-options.ts new file mode 100644 index 0000000..96c25f0 --- /dev/null +++ b/src/editor/entity-options.ts @@ -0,0 +1,64 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import { cachedSchema } from './chart-editors'; +import { entityId, setEntityOption } from './edit'; + +registerStrings('en', { + editor_entity_options_none: 'Add an entity above first.', +}); + +/** + * One field per listed entity, for an option a chart reads from each of them (its group, its + * axis, whether it counts down). Entities matched by `include` have no row of their own, so they + * aren't listed: they follow the card-wide setting. + */ +export function entityOptionsForm( + ctx: ChartEditorContext, + section: string, + key: string, + selector: Record, + fallback: unknown = undefined, +): TemplateResult { + const entities = ctx.config.entities ?? []; + const n = entities.length; + const schema = cachedSchema(`${section}-${n}`, ctx.hass, () => entities.map((_, i) => ({ name: `e${i}`, selector }))); + const own = (i: number) => { + const item = entities[i]; + return typeof item === 'object' ? (item as unknown as Record)[key] : undefined; + }; + const data: Record = {}; + for (let i = 0; i < n; i++) data[`e${i}`] = own(i) ?? fallback; + const label = (s: { name: string }) => { + const id = entityId(entities[Number(s.name.slice(1))]); + const name = ctx.hass?.states[id]?.attributes?.friendly_name; + return typeof name === 'string' && name ? name : id; + }; + return html`
+
${localize(ctx.hass, `editor_${section}`)}
+ ${ + n === 0 + ? html`
+ ${localize(ctx.hass, 'editor_entity_options_none')} +
` + : html` { + ev.stopPropagation(); + const value = (ev.detail?.value ?? {}) as Record; + let next = ctx.config; + for (let i = 0; i < n; i++) { + const v = value[`e${i}`]; + const wanted = v === fallback || v === '' ? undefined : v; + if (wanted !== own(i)) next = setEntityOption(next, i, key, wanted); + } + if (next !== ctx.config) ctx.commit(next); + }} + >` + } +
`; +} diff --git a/src/editor/group-fields.ts b/src/editor/group-fields.ts new file mode 100644 index 0000000..0d3ae44 --- /dev/null +++ b/src/editor/group-fields.ts @@ -0,0 +1,49 @@ +import { GROUP_LEVELS, parseGroupBy } from '../chart/hierarchy'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { entityOptionsForm } from './entity-options'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_group_by: 'Group by', + editor_group_by_floor: 'Floor', + editor_group_by_area: 'Area', + editor_group_by_device: 'Device', + editor_group_by_label: 'Label', + editor_entity_groups: 'Own group (wins over "Group by"; "Ground floor / Kitchen" for two levels)', +}); + +export const GROUP_FIELDS: FieldBinding[] = [{ name: 'group_by', path: ['group_by'], default: [] }]; + +/** Pick any of floor, area, device and label; they nest in that order. */ +export function groupBySchema(hass?: HomeAssistant) { + return { + name: 'group_by', + selector: { + select: { + multiple: true, + mode: 'list', + options: GROUP_LEVELS.map((value) => ({ value, label: localize(hass, `editor_group_by_${value}`) })), + }, + }, + }; +} + +/** The editor always works on a list, so `group_by: area` in YAML shows as ticked. */ +export function withGroupList(ctx: ChartEditorContext): ChartEditorContext { + const raw = ctx.config.group_by; + if (raw === undefined || Array.isArray(raw)) return ctx; + let list: string[]; + try { + list = parseGroupBy(raw); + } catch { + return ctx; + } + return { ...ctx, config: { ...ctx.config, group_by: list } as CardConfig }; +} + +/** A text box per listed entity for its own group. */ +export function entityGroupsForm(ctx: ChartEditorContext) { + return entityOptionsForm(ctx, 'entity_groups', 'group', { text: {} }); +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 1d877bc..769cfc9 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -22,6 +22,7 @@ import { tank } from './tank'; import { thin } from './thin'; import { waffle } from './waffle'; import { funnel } from './funnel'; +import { radar } from './radar'; export interface RenderContext { show: ShowOptions; @@ -132,4 +133,5 @@ export const STYLES: Record = { heatmap, box, funnel, + radar, }; diff --git a/src/styles/radar.ts b/src/styles/radar.ts new file mode 100644 index 0000000..87b6f97 --- /dev/null +++ b/src/styles/radar.ts @@ -0,0 +1,333 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { seriesColor } from '../chart/colors'; +import { groupPath, parseGroupBy, splitGroup, validateGroups } from '../chart/hierarchy'; +import { axisAngle, labelAnchor, polar, polygonPoints } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { entityOptionsForm } from '../editor/entity-options'; +import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_radar: 'Radar', + editor_radar_scale: 'Scale', + editor_radar_scale_limits: "Each axis from its entity's minimum to maximum", + editor_radar_scale_shared: 'One scale for all, from 0 to the largest value', + editor_radar_fill: 'Fill the shapes', + editor_radar_axis: 'Axis name (entities with the same axis name line up across groups)', + radar_aria: 'Radar chart of {names}', + radar_other: 'Other', +}); + +export const RADAR_SCALES = ['limits', 'shared'] as const; +export type RadarScale = (typeof RADAR_SCALES)[number]; + +/** Half the drawing's width and height, and the radius of the outer ring, in SVG units (≈ px). */ +const HALF_W = 170; +const HALF_H = 120; +const RADIUS = 84; +const RINGS = 4; + +/** Short names for device classes whose readable form is an abbreviation. */ +const DEVICE_CLASS_NAMES: Record = { + aqi: 'AQI', + carbon_dioxide: 'CO₂', + carbon_monoxide: 'CO', + pm1: 'PM1', + pm10: 'PM10', + pm25: 'PM2.5', + volatile_organic_compounds: 'VOC', + volatile_organic_compounds_parts: 'VOC', +}; + +/** "carbon_dioxide" → "CO₂", "humidity" → "Humidity". */ +export function deviceClassName(deviceClass: string): string { + if (DEVICE_CLASS_NAMES[deviceClass]) return DEVICE_CLASS_NAMES[deviceClass]; + const text = deviceClass.replace(/_/g, ' '); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +export interface RadarPoint { + /** Index into the chart's items. */ + index: number; + axis: number; + /** Distance from the centre, 0–1; null when there is no value to plot. */ + r: number | null; +} + +export interface RadarSeries { + name: string; + color: string; + points: RadarPoint[]; +} + +export interface RadarModel { + axes: { name: string; entity?: ResolvedEntity }[]; + series: RadarSeries[]; +} + +/** How far out a value sits: its place between the entity's limits, or its share of the largest. */ +export function radius(e: ResolvedEntity, scale: RadarScale, largest: number): number | null { + if (!e.available || e.value === null) return null; + if (scale === 'limits') return e.position; + return largest > 0 ? Math.min(1, Math.max(0, e.value / largest)) : 0; +} + +/** + * Sort the entities into axes and series. Without groups there is one series and every entity is + * an axis. With groups, each group is a series and entities line up on an axis by their `axis` + * name, else their device class, else their own name. + */ +export function radarModel(items: ResolvedEntity[], raw: CardConfig, hass: HomeAssistant): RadarModel { + const levels = parseGroupBy(raw.group_by); + const grouped = levels.length > 0 || items.some((e) => splitGroup((e.config as { group?: unknown }).group).length); + const scale: RadarScale = (raw.scale as RadarScale | undefined) ?? 'limits'; + const values = items.filter((e) => e.available && e.value !== null).map((e) => e.value as number); + const largest = values.length ? Math.max(...values) : 0; + const ownAxis = (e: ResolvedEntity) => { + const axis = (e.config as { axis?: unknown }).axis; + return typeof axis === 'string' && axis.trim() ? axis.trim() : undefined; + }; + if (!grouped) { + return { + axes: items.map((e) => ({ name: ownAxis(e) ?? e.name, entity: e })), + series: [ + { + name: '', + color: seriesColor(0), + points: items.map((e, index) => ({ index, axis: index, r: radius(e, scale, largest) })), + }, + ], + }; + } + const other = localize(hass, 'radar_other'); + const axes: { name: string }[] = []; + const series: RadarSeries[] = []; + items.forEach((e, index) => { + const deviceClass = e.stateObj?.attributes?.device_class; + const axisName = ownAxis(e) ?? (typeof deviceClass === 'string' ? deviceClassName(deviceClass) : e.name); + let axis = axes.findIndex((a) => a.name === axisName); + if (axis === -1) axis = axes.push({ name: axisName }) - 1; + const name = groupPath(e, levels, hass, other).join(' / ') || other; + let s = series.find((x) => x.name === name); + if (!s) { + s = { name, color: seriesColor(series.length), points: [] }; + series.push(s); + } + s.points.push({ index, axis, r: radius(e, scale, largest) }); + }); + return { axes, series }; +} + +function grid(n: number): SVGTemplateResult { + const rings = Array.from({ length: RINGS }, (_, i) => (RADIUS * (i + 1)) / RINGS); + const ring = (r: number) => + n >= 3 + ? svg` polar(r, axisAngle(i, n))))}>` + : svg``; + return svg`${rings.map(ring)} + ${Array.from({ length: n }, (_, i) => { + const [x, y] = polar(RADIUS, axisAngle(i, n)); + return svg``; + })}`; +} + +function axisLabels(model: RadarModel, ctx: ChartContext, single: boolean): SVGTemplateResult { + const n = model.axes.length; + const showValue = single && ctx.config.show.value; + return svg`${model.axes.map((axis, i) => { + const angle = axisAngle(i, n); + const [x, y] = polar(RADIUS + 12, angle); + const anchor = labelAnchor(angle); + // Labels below the centre hang down from their point; above it they sit on it. + const below = Math.cos(angle) < -0.2; + const lines = showValue && axis.entity ? 2 : 1; + const dy = below ? 10 : Math.abs(Math.cos(angle)) <= 0.2 ? 4 - (lines - 1) * 6 : -(lines - 1) * 12; + return svg` + ${ctx.config.show.name || !showValue ? svg`${axis.name}` : nothing} + ${ + showValue && axis.entity + ? svg`${axis.entity.display}` + : nothing + } + `; + })}`; +} + +function shape(s: RadarSeries, n: number, items: ResolvedEntity[], fill: boolean): SVGTemplateResult { + const plotted = s.points + .filter((p) => p.r !== null) + .sort((a, b) => a.axis - b.axis) + .map((p) => ({ p, xy: polar(RADIUS * (p.r as number), axisAngle(p.axis, n)) })); + return svg` + ${ + plotted.length > 1 + ? svg` x.xy))} + stroke=${s.color} fill=${fill ? s.color : 'none'}>` + : nothing + } + ${plotted.map( + ({ p, xy }) => svg` + + + `, + )} + `; +} + +const FIELDS: FieldBinding[] = [ + { name: 'radar_scale', path: ['scale'], default: 'limits' }, + { name: 'radar_fill', path: ['fill'], default: true }, + ...GROUP_FIELDS, +]; + +export function radarEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('radar', ctx.hass, () => [ + { name: 'radar_scale', selector: choice(ctx.hass, 'radar_scale', RADAR_SCALES) }, + { name: 'radar_fill', selector: { boolean: {} } }, + groupBySchema(ctx.hass), + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)} + ${entityOptionsForm(ctx, 'radar_axis', 'axis', { text: {} })}`; +} + +export const radar: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 280, + styles: css` + .radar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .radar-plot .grid { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .radar-plot .axis-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .radar-plot .axis-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .radar-plot .axis-value.muted { + fill: var(--secondary-text-color); + font-weight: 400; + } + .radar-plot .area { + stroke-width: 2; + stroke-linejoin: round; + fill-opacity: 0; + } + .radar-plot .area.filled { + fill-opacity: 0.2; + } + .radar-plot .dot { + fill: var(--card-background-color, #fff); + stroke-width: 2; + } + .radar-plot .hit { + fill: transparent; + } + .radar-plot .marker { + cursor: pointer; + outline: none; + } + .radar-plot .marker:focus-visible .dot { + stroke-width: 4; + } + .radar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .radar-grow { + transform: scale(1); + } + :host([static]) .radar-grow { + transition: none; + } + .radar-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px 16px; + padding-top: 4px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .radar-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + .radar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `, + thumbnail: () => svg` + + `, + validate(raw: CardConfig) { + validateGroups(raw); + if (raw.scale !== undefined && !RADAR_SCALES.includes(raw.scale as RadarScale)) { + throw new Error(`"scale" must be one of: ${RADAR_SCALES.join(', ')}`); + } + if (raw.fill !== undefined && typeof raw.fill !== 'boolean') throw new Error('"fill" must be true or false'); + (raw.entities ?? []).forEach((e, i) => { + const axis = typeof e === 'object' && e ? (e as { axis?: unknown }).axis : undefined; + if (axis !== undefined && axis !== null && typeof axis !== 'string') { + throw new Error(`Entity ${i + 1}: "axis" must be text`); + } + }); + }, + editor: radarEditor, + render(ctx) { + const model = radarModel(ctx.items, ctx.raw, ctx.hass); + const n = model.axes.length; + const single = model.series.length === 1 && model.series[0].name === ''; + const fill = ctx.raw.fill !== false; + const label = localize(ctx.hass, 'radar_aria', { + names: (single ? ctx.items.map((e) => e.name) : model.series.map((s) => s.name)).join(', '), + }); + return html` + ${grid(n)} ${axisLabels(model, ctx, single)} + + + ${model.series.map((s) => shape(s, n, ctx.items, fill))} + + + ${ + single + ? nothing + : html`
+ ${model.series.map( + (s) => + html`${s.name}`, + )} +
` + }`; + }, +}; diff --git a/src/types.ts b/src/types.ts index 99c393c..957906c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/hierarchy.test.ts b/test/hierarchy.test.ts new file mode 100644 index 0000000..5db1641 --- /dev/null +++ b/test/hierarchy.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { normalizeConfig } from '../src/config'; +import { + buildTree, + findNode, + groupPath, + levelName, + parseGroupBy, + partition, + splitGroup, + treeHeight, + validateGroups, +} from '../src/chart/hierarchy'; +import { resolveEntities } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +function registries(): HomeAssistant { + const hass = makeHass({ + 'sensor.oven': ['1.2', { friendly_name: 'Oven' }], + 'sensor.kettle': ['0.6', { friendly_name: 'Kettle' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV' }], + 'sensor.ev': ['3.6', { friendly_name: 'EV' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + Object.assign(hass, { + entities: { + 'sensor.oven': { entity_id: 'sensor.oven', device_id: 'd1', labels: ['cooking'] }, + 'sensor.kettle': { entity_id: 'sensor.kettle', area_id: 'kitchen' }, + 'sensor.tv': { entity_id: 'sensor.tv', area_id: 'living_room' }, + 'sensor.ev': { entity_id: 'sensor.ev' }, + }, + devices: { d1: { id: 'd1', name: 'Oven', name_by_user: 'Big oven', area_id: 'kitchen', labels: ['kitchen_kit'] } }, + areas: { + kitchen: { area_id: 'kitchen', name: 'Kitchen', floor_id: 'ground' }, + living_room: { area_id: 'living_room', floor_id: 'first' }, + }, + floors: { ground: { name: 'Ground floor' } }, + labels: { cooking: { name: 'Cooking' } }, + }); + return hass; +} + +function items(config: Partial, hass = registries()) { + return resolveEntities(normalizeConfig({ type: 'x', ...config } as CardConfig), hass); +} + +describe('grouping', () => { + it('reads group_by as a list in house order, and rejects unknown levels', () => { + expect(parseGroupBy(undefined)).toEqual([]); + expect(parseGroupBy('area')).toEqual(['area']); + expect(parseGroupBy(['area', 'floor', 'area'])).toEqual(['floor', 'area']); + expect(() => parseGroupBy(['room'])).toThrow(/group_by.*floor, area, device, label/); + expect(() => validateGroups({ type: 'x', entities: [{ entity: 'a', group: 3 }] } as unknown as CardConfig)).toThrow( + /Entity 1: "group" must be text/, + ); + expect(() => + validateGroups({ type: 'x', entities: ['a', { entity: 'b', group: 'X' }] } as CardConfig), + ).not.toThrow(); + }); + + it('splits an own group at slashes', () => { + expect(splitGroup(' Ground / Kitchen /')).toEqual(['Ground', 'Kitchen']); + expect(splitGroup(undefined)).toEqual([]); + }); + + it('names levels from the entity, its device, area and floor', () => { + const hass = registries(); + expect(levelName('sensor.oven', 'area', hass)).toBe('Kitchen'); + expect(levelName('sensor.oven', 'floor', hass)).toBe('Ground floor'); + expect(levelName('sensor.oven', 'device', hass)).toBe('Big oven'); + expect(levelName('sensor.oven', 'label', hass)).toBe('Cooking'); + expect(levelName('sensor.tv', 'area', hass)).toBe('Living room'); + expect(levelName('sensor.tv', 'floor', hass)).toBe('First'); + expect(levelName('sensor.kettle', 'device', hass)).toBeUndefined(); + expect(levelName('sensor.ev', 'area', hass)).toBeUndefined(); + expect(levelName('sensor.ev', 'label', hass)).toBeUndefined(); + expect(levelName('sensor.nope', 'floor', hass)).toBeUndefined(); + }); + + it('puts an entity in its own group, else its registry groups, else "Other"', () => { + const hass = registries(); + const list = items({ entities: ['sensor.oven', 'sensor.ev', { entity: 'sensor.tv', group: 'Up / Lounge' }] }, hass); + const paths = list.map((e) => groupPath(e, ['floor', 'area'], hass, 'Other')); + expect(paths).toEqual([ + ['Ground floor', 'Kitchen'], + ['Other', 'Other'], + ['Up', 'Lounge'], + ]); + expect(groupPath(list[0], [], hass, 'Other')).toEqual([]); + }); +}); + +describe('tree', () => { + it('sums groups, orders largest first and keeps item indexes', () => { + const list = items({ entities: ['sensor.tv', 'sensor.oven', 'sensor.kettle', 'sensor.ev', 'sensor.off'] }); + const paths = [['Lounge'], ['Kitchen'], ['Kitchen'], [], ['Kitchen']]; + const root = buildTree(list, paths, 'All'); + expect(root.value).toBeCloseTo(5.9); + expect(root.children.map((c) => c.name)).toEqual(['EV', 'Kitchen', 'Lounge']); + const kitchen = findNode(root, ['Kitchen'])!; + expect(kitchen.value).toBeCloseTo(1.8); + expect(kitchen.children.map((c) => [c.name, c.index])).toEqual([ + ['Oven', 1], + ['Kettle', 2], + ['Off', 4], + ]); + expect(kitchen.children[2].value).toBe(0); + expect(kitchen.path).toEqual(['Kitchen']); + expect(findNode(root, ['Nowhere'])).toBeUndefined(); + expect(findNode(root, [])).toBe(root); + expect(treeHeight(root)).toBe(2); + }); + + it('partitions each level in proportion, leaving out empty nodes', () => { + const list = items({ entities: ['sensor.tv', 'sensor.oven', 'sensor.kettle', 'sensor.off'] }); + const root = buildTree(list, [['A'], ['B'], ['B'], ['B']], 'All'); + const spans = partition(root); + expect(spans.map((s) => [s.node.name, s.level])).toEqual([ + ['All', 0], + ['B', 1], + ['Oven', 2], + ['Kettle', 2], + ['A', 1], + ['TV', 2], + ]); + const b = spans[1]; + expect(b.x0).toBe(0); + expect(b.x1).toBeCloseTo(1.8 / 2.3); + expect(spans.at(-1)!.x1).toBeCloseTo(1); + const empty = buildTree([], [], 'None'); + expect(partition(empty)).toHaveLength(1); + }); +}); diff --git a/test/polar.test.ts b/test/polar.test.ts new file mode 100644 index 0000000..f7795bb --- /dev/null +++ b/test/polar.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { axisAngle, labelAnchor, polar, polygonPoints, sectorPath } from '../src/chart/polar'; + +describe('polar maths', () => { + it('measures angles clockwise from 12 o’clock', () => { + expect(polar(10, 0)).toEqual([0, -10]); + expect(polar(10, Math.PI / 2)).toEqual([10, 0]); + expect(polar(10, Math.PI)).toEqual([0, 10]); + expect(polar(10, (3 * Math.PI) / 2)).toEqual([-10, 0]); + }); + + it('spaces spokes evenly, the first straight up', () => { + expect(axisAngle(0, 4)).toBe(0); + expect(axisAngle(1, 4)).toBeCloseTo(Math.PI / 2); + expect(axisAngle(3, 6)).toBeCloseTo(Math.PI); + expect(axisAngle(0, 0)).toBe(0); + }); + + it('draws wedges, ring sectors and whole rings', () => { + expect(sectorPath(0, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L0 0Z'); + expect(sectorPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(sectorPath(0, 10, 0, (3 * Math.PI) / 2)).toContain('A10 10 0 1 1'); + const disc = sectorPath(0, 10, 0, Math.PI * 2); + expect(disc.match(/A/g)).toHaveLength(2); + const ring = sectorPath(4, 10, 0, Math.PI * 2); + expect(ring.match(/A/g)).toHaveLength(4); + expect(sectorPath(0, 0, 0, 1)).toBe(''); + expect(sectorPath(0, 10, 1, 1)).toBe(''); + }); + + it('writes polygon points and anchors labels by side', () => { + expect( + polygonPoints([ + [0, -1], + [1, 0], + ]), + ).toBe('0,-1 1,0'); + expect(labelAnchor(0)).toBe('middle'); + expect(labelAnchor(Math.PI / 2)).toBe('start'); + expect(labelAnchor((3 * Math.PI) / 2)).toBe('end'); + expect(labelAnchor(Math.PI)).toBe('middle'); + }); +}); diff --git a/test/radar.test.ts b/test/radar.test.ts new file mode 100644 index 0000000..cce5931 --- /dev/null +++ b/test/radar.test.ts @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { deviceClassName, radarModel, radius } from '../src/styles/radar'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function air(): HomeAssistant { + const hass = makeHass({ + 'sensor.lounge_temp': [ + '21', + { friendly_name: 'Lounge temp', device_class: 'temperature', unit_of_measurement: '°C' }, + ], + 'sensor.lounge_co2': ['640', { friendly_name: 'Lounge CO2', device_class: 'carbon_dioxide' }], + 'sensor.lounge_hum': ['48', { friendly_name: 'Lounge humidity', device_class: 'humidity' }], + 'sensor.bed_temp': ['19', { friendly_name: 'Bedroom temp', device_class: 'temperature' }], + 'sensor.bed_co2': ['1120', { friendly_name: 'Bedroom CO2', device_class: 'carbon_dioxide' }], + 'sensor.bed_hum': ['unavailable', { friendly_name: 'Bedroom humidity', device_class: 'humidity' }], + }); + Object.assign(hass, { + entities: Object.fromEntries( + ['lounge_temp', 'lounge_co2', 'lounge_hum', 'bed_temp', 'bed_co2', 'bed_hum'].map((id) => [ + `sensor.${id}`, + { entity_id: `sensor.${id}`, area_id: id.startsWith('lounge') ? 'lounge' : 'bedroom' }, + ]), + ), + areas: { lounge: { area_id: 'lounge', name: 'Lounge' }, bedroom: { area_id: 'bedroom', name: 'Bedroom' } }, + }); + return hass; +} + +const ALL = [ + 'sensor.lounge_temp', + 'sensor.lounge_co2', + 'sensor.lounge_hum', + 'sensor.bed_temp', + 'sensor.bed_co2', + 'sensor.bed_hum', +]; + +async function mount(config: Partial, hass = air()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'radar', entities: ALL.slice(0, 3), ...config } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function items(config: Partial, hass = air()) { + return resolveEntities(normalizeConfig({ type: 'x', ...config } as CardConfig), hass); +} + +describe('radar maths', () => { + it('names device classes readably', () => { + expect(deviceClassName('carbon_dioxide')).toBe('CO₂'); + expect(deviceClassName('pm25')).toBe('PM2.5'); + expect(deviceClassName('atmospheric_pressure')).toBe('Atmospheric pressure'); + }); + + it('places a value between its limits, or as a share of the largest', () => { + const [temp, co2, , , , gone] = items({ entities: ALL, max: 2000 }); + expect(radius(temp, 'limits', 1120)).toBeCloseTo(21 / 2000); + expect(radius(co2, 'shared', 1120)).toBeCloseTo(640 / 1120); + expect(radius(co2, 'shared', 0)).toBe(0); + expect(radius(gone, 'limits', 1)).toBeNull(); + }); + + it('makes one series of every entity without groups', () => { + const hass = air(); + const model = radarModel(items({ entities: ALL.slice(0, 3) }, hass), { type: 'x' }, hass); + expect(model.axes.map((a) => a.name)).toEqual(['Lounge temp', 'Lounge CO2', 'Lounge humidity']); + expect(model.series).toHaveLength(1); + }); + + it('lines groups up on axes by axis name, device class, then name', () => { + const hass = air(); + const list = items( + { entities: [...ALL, { entity: 'sensor.lounge_temp', axis: 'Warmth', group: 'Spare' }, 'sensor.missing'] }, + hass, + ); + const model = radarModel(list, { type: 'x', group_by: 'area' }, hass); + expect(model.axes.map((a) => a.name)).toEqual(['Temperature', 'CO₂', 'Humidity', 'Warmth', 'sensor.missing']); + expect(model.series.map((s) => s.name)).toEqual(['Lounge', 'Bedroom', 'Spare', 'Other']); + expect(model.series[1].points.map((p) => p.axis)).toEqual([0, 1, 2]); + expect(model.series[1].points[2].r).toBeNull(); + expect(model.series[1].color).toContain('--warning-color'); + }); +}); + +describe('radar chart', () => { + it('draws a filled shape with a marker per value, round labelled axes', async () => { + const card = await mount({ max: 2000 }); + expect(qa(card, 'polygon.grid')).toHaveLength(4); + expect(qa(card, 'line.spoke')).toHaveLength(3); + const area = q(card, 'polygon.area')!; + expect(area.classList.contains('filled')).toBe(true); + expect(area.getAttribute('stroke')).toContain('--primary-color'); + expect(qa(card, '.marker.entity').map((m) => m.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(qa(card, '.axis-label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + 'Lounge temp 21.0 °C', + 'Lounge CO2 640.0', + 'Lounge humidity 48.0', + ]); + expect(q(card, 'svg.radar-plot')!.getAttribute('aria-label')).toBe( + 'Radar chart of Lounge temp, Lounge CO2, Lounge humidity', + ); + expect(q(card, '.radar-legend')).toBeNull(); + }); + + it('draws outlines only when fill is off, and hides names when asked', async () => { + const card = await mount({ fill: false, show: { name: false } }); + expect(q(card, 'polygon.area')!.classList.contains('filled')).toBe(false); + expect(q(card, 'polygon.area')!.getAttribute('fill')).toBe('none'); + expect(q(card, '.axis-label')!.textContent!.trim()).toBe('21.0 °C'); + }); + + it('draws one shape per area with a legend, skipping the missing value', async () => { + const card = await mount({ entities: ALL, group_by: ['area'], scale: 'shared' }); + expect(qa(card, 'polygon.area')).toHaveLength(2); + expect(qa(card, '.marker')).toHaveLength(5); + expect(qa(card, '.radar-legend-item').map((e) => e.textContent!.trim())).toEqual(['Lounge', 'Bedroom']); + expect(qa(card, '.axis-label').map((e) => e.textContent!.trim())).toEqual(['Temperature', 'CO₂', 'Humidity']); + }); + + it('keeps going with two axes, unavailable and missing entities', async () => { + const card = await mount({ entities: ['sensor.bed_hum', 'sensor.missing'] }); + expect(qa(card, 'circle.grid')).toHaveLength(4); + expect(q(card, 'polygon.area')).toBeNull(); + expect(qa(card, '.marker')).toHaveLength(0); + expect(qa(card, '.axis-value.muted')).toHaveLength(2); + }); + + it('opens more-info from a marker', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const marker = qa(card, '.marker')[1] as HTMLElement; + marker.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + marker.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.lounge_co2']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'radar', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ scale: 'log' })).toThrow(/"scale" must be one of: limits, shared/); + expect(set({ fill: 'yes' })).toThrow(/fill/); + expect(set({ group_by: 'room' })).toThrow(/group_by/); + expect(set({ entities: [{ entity: 'sensor.a', axis: 3 }] })).toThrow(/axis/); + expect(set({ scale: 'shared', fill: false, group_by: ['area'] })).not.toThrow(); + }); +}); + +describe('radar editor', () => { + it('writes scale, fill, grouping, and each entity’s group and axis', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = air(); + const raw = { + type: 'custom:chartdeck-card', + style: 'radar', + group_by: 'area', + entities: ALL.slice(0, 2), + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const [form, groups, axes] = [...ed.shadowRoot!.querySelectorAll('.section-chart ha-form')] as Stub[]; + expect(form.data).toEqual({ radar_scale: 'limits', radar_fill: true, group_by: ['area'] }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), radar_scale: 'shared', radar_fill: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, group_by: ['area'], scale: 'shared', fill: false }); + expect(groups.data).toEqual({ e0: undefined, e1: undefined }); + expect((groups.computeLabel as (s: { name: string }) => string)({ name: 'e1' })).toBe('Lounge CO2'); + groups.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: 'Spare', e1: '' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.lounge_temp', group: 'Spare' }, 'sensor.lounge_co2']); + axes.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e1: 'Air' } } })); + expect(events.at(-1)!.entities).toEqual(['sensor.lounge_temp', { entity: 'sensor.lounge_co2', axis: 'Air' }]); + }); + + it('says to add an entity first when there are none', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = air(); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'radar' } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.entity-options')!.textContent).toContain('Add an entity above first.'); + }); +}); From 48776b37293e4ee8f54cd7d5942cf77a0d9d4712 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:40:47 +0000 Subject: [PATCH 04/41] feat: show values as slice lengths on a polar area chart The new polar area style is like a pie where every slice gets the same angle and its length shows the value, which suits comparing like things: power by circuit, or how often the wind blows from each direction. It is in the style picker under Comparisons. Slices go round from straight up, so a wind rose's north sits at the top, with a thin gap between them. By default each slice's length is its share of the largest value (scale: shared), with three dashed rings labelled in the first entity's unit; scale: limits measures each slice between its own entity's minimum and maximum instead, and leaves the ring values off since they would differ per slice. Colour comes from color_by: one series colour per entity (the default), shades of a single colour that get stronger as the slice gets longer (color_by: shades, with color naming a palette colour or any CSS colour), or each value's colour band. Shades are mixed with the card's background, so they work in light and dark themes. Names and values are written round the outside and follow the Display switches. An unavailable or missing entity gets no slice and a greyed label. Slices are tappable entities, so tap, hold and double-tap actions work as on any other style. Slices grow out from the centre on first draw, and not at all with reduced motion or animate: false. The editor's Chart section sets the scale, the colouring and the colour for shades. Closes #33 --- src/styles/index.ts | 2 + src/styles/polar-area.ts | 244 +++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/polar-area.test.ts | 187 ++++++++++++++++++++++++++++++ 4 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 src/styles/polar-area.ts create mode 100644 test/polar-area.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 769cfc9..4d9c05b 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -23,6 +23,7 @@ import { thin } from './thin'; import { waffle } from './waffle'; import { funnel } from './funnel'; import { radar } from './radar'; +import { polarArea } from './polar-area'; export interface RenderContext { show: ShowOptions; @@ -134,4 +135,5 @@ export const STYLES: Record = { box, funnel, radar, + polar_area: polarArea, }; diff --git a/src/styles/polar-area.ts b/src/styles/polar-area.ts new file mode 100644 index 0000000..2917ef5 --- /dev/null +++ b/src/styles/polar-area.ts @@ -0,0 +1,244 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { PALETTE, baseColor } from '../bands'; +import { seriesColor, shade } from '../chart/colors'; +import { axisAngle, labelAnchor, polar, sectorPath } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_polar_area: 'Polar area', + editor_polar_scale: 'Scale', + editor_polar_scale_shared: 'One scale for all, from 0 to the largest value', + editor_polar_scale_limits: "Each slice from its entity's minimum to maximum", + editor_polar_color_by: 'Colour', + editor_polar_color_by_series: 'One colour per entity', + editor_polar_color_by_shades: 'Shades of one colour, darker for more', + editor_polar_color_by_bands: 'By colour band', + editor_polar_color: 'Colour for shades', + polar_area_aria: 'Polar area chart of {names}', +}); + +export const POLAR_SCALES = ['shared', 'limits'] as const; +export type PolarScale = (typeof POLAR_SCALES)[number]; +export const POLAR_COLOR_BY = ['series', 'shades', 'bands'] as const; +export type PolarColorBy = (typeof POLAR_COLOR_BY)[number]; + +const HALF_W = 170; +const HALF_H = 120; +const RADIUS = 92; +/** A small hole in the middle, so thin slices don't meet in a point. */ +const HOLE = 6; +/** Angle left empty either side of each slice (radians). */ +const GAP = 0.02; +const RINGS = 3; + +/** How far out a slice reaches, 0–1: its share of the largest value, or its place between its limits. */ +export function sliceLength(e: ResolvedEntity, scale: PolarScale, largest: number): number | null { + if (!e.available || e.value === null) return null; + if (scale === 'limits') return e.position; + return largest > 0 ? Math.min(1, Math.max(0, e.value / largest)) : 0; +} + +/** Slice `i` of `n`, centred on its spoke, with a thin gap either side; one slice is a whole disc. */ +export function sliceAngles(i: number, n: number): [number, number] { + if (n <= 1) return [0, Math.PI * 2]; + const half = Math.PI / n; + const mid = axisAngle(i, n); + return [mid - half + GAP, mid + half - GAP]; +} + +/** The slice's fill: its series colour, a shade of one colour by length, or its band colour. */ +export function sliceColor(e: ResolvedEntity, i: number, length: number, colorBy: PolarColorBy, base: string): string { + if (colorBy === 'bands') return baseColor(e.band.color); + if (colorBy === 'shades') return shade(baseColor(base), 30 + 70 * length); + return seriesColor(i); +} + +/** Dashed rings at even steps, and (separately, drawn over the slices) their values. */ +function rings(): SVGTemplateResult { + return svg`${Array.from({ length: RINGS }, (_, k) => svg``)}`; +} + +function ringLabels(ctx: ChartContext, scale: PolarScale, largest: number): SVGTemplateResult { + const first = ctx.items.find((e) => e.available); + // Values only make sense on a shared scale; with per-entity limits each slice has its own. + if (scale !== 'shared' || !first || largest <= 0) return svg``; + // Along the gap between the first two slices, where no slice covers it. + const angle = ctx.items.length > 1 ? Math.PI / ctx.items.length : 0; + return svg`${Array.from({ length: RINGS }, (_, k) => { + const r = (RADIUS * (k + 1)) / RINGS; + const [x, y] = polar(r, angle); + const text = withUnit(formatNumber((largest * (k + 1)) / RINGS, first.decimals, ctx.hass), first.unit); + return svg`${text}`; + })}`; +} + +function labels(ctx: ChartContext): SVGTemplateResult { + const n = ctx.items.length; + const { show } = ctx.config; + if (!show.name && !show.value) return svg``; + return svg`${ctx.items.map((e, i) => { + const angle = axisAngle(i, n); + const [x, y] = polar(RADIUS + 12, angle); + const below = Math.cos(angle) < -0.2; + const lines = show.name && show.value ? 2 : 1; + const dy = below ? 10 : Math.abs(Math.cos(angle)) <= 0.2 ? 4 - (lines - 1) * 6 : -(lines - 1) * 13; + return svg` + ${show.name ? svg`${e.name}` : nothing} + ${show.value ? svg`${e.display}` : nothing} + `; + })}`; +} + +const FIELDS: FieldBinding[] = [ + { name: 'polar_scale', path: ['scale'], default: 'shared' }, + { name: 'polar_color_by', path: ['color_by'], default: 'series' }, + { name: 'polar_color', path: ['color'], default: 'primary' }, +]; + +const PALETTE_NAMES = Object.keys(PALETTE); + +export function polarAreaEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('polar_area', ctx.hass, () => [ + { name: 'polar_scale', selector: choice(ctx.hass, 'polar_scale', POLAR_SCALES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'polar_color_by', selector: choice(ctx.hass, 'polar_color_by', POLAR_COLOR_BY) }, + { + name: 'polar_color', + selector: { + select: { + mode: 'dropdown', + custom_value: true, + options: PALETTE_NAMES.map((value) => ({ value, label: localize(ctx.hass, `editor_color_${value}`) })), + }, + }, + }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +export const polarArea: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 260, + styles: css` + .polar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .polar-plot .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-dasharray: 3 3; + } + .polar-plot .ring:last-of-type { + stroke-dasharray: none; + } + .polar-plot .ring-label { + fill: var(--secondary-text-color); + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .polar-plot .slice-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .polar-plot .slice-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .polar-plot .slice-label.muted .slice-value { + fill: var(--secondary-text-color); + font-weight: 400; + } + .polar-plot .slice { + cursor: pointer; + outline: none; + stroke: var(--card-background-color, #fff); + stroke-width: 0.5; + } + .polar-plot .slice:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2; + } + .polar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .polar-grow { + transform: scale(1); + } + :host([static]) .polar-grow { + transition: none; + } + `, + thumbnail: () => svg` + + + + + + + `, + validate(raw: CardConfig) { + if (raw.scale !== undefined && !POLAR_SCALES.includes(raw.scale as PolarScale)) { + throw new Error(`"scale" must be one of: ${POLAR_SCALES.join(', ')}`); + } + if (raw.color_by !== undefined && !POLAR_COLOR_BY.includes(raw.color_by as PolarColorBy)) { + throw new Error(`"color_by" must be one of: ${POLAR_COLOR_BY.join(', ')}`); + } + if (raw.color !== undefined && (typeof raw.color !== 'string' || !raw.color)) { + throw new Error('"color" must be a colour name, such as "primary", or any CSS colour'); + } + }, + editor: polarAreaEditor, + render(ctx) { + const scale: PolarScale = (ctx.raw.scale as PolarScale | undefined) ?? 'shared'; + const colorBy: PolarColorBy = (ctx.raw.color_by as PolarColorBy | undefined) ?? 'series'; + const base = (ctx.raw.color as string | undefined) ?? 'primary'; + const n = ctx.items.length; + const values = ctx.items.filter((e) => e.available && e.value !== null).map((e) => e.value as number); + const largest = values.length ? Math.max(0, ...values) : 0; + const slices = ctx.items.map((e, i) => { + const length = sliceLength(e, scale, largest); + if (length === null || length <= 0) return nothing; + const [a0, a1] = sliceAngles(i, n); + const r = HOLE + (RADIUS - HOLE) * length; + return svg``; + }); + const label = localize(ctx.hass, 'polar_area_aria', { names: ctx.items.map((e) => e.name).join(', ') }); + return html` + ${rings()} + + + ${slices} + + ${ringLabels(ctx, scale, largest)} ${labels(ctx)} + `; + }, +}; diff --git a/src/types.ts b/src/types.ts index 957906c..a2c4890 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/polar-area.test.ts b/test/polar-area.test.ts new file mode 100644 index 0000000..0b4b47c --- /dev/null +++ b/test/polar-area.test.ts @@ -0,0 +1,187 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { sliceAngles, sliceColor, sliceLength } from '../src/styles/polar-area'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const hass = () => + makeHass({ + 'sensor.oven': ['1200', { friendly_name: 'Oven', unit_of_measurement: 'W' }], + 'sensor.ev': ['3600', { friendly_name: 'EV', unit_of_measurement: 'W' }], + 'sensor.tv': ['400', { friendly_name: 'TV', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + +const ENTITIES = ['sensor.oven', 'sensor.ev', 'sensor.tv', 'sensor.off', 'sensor.missing']; + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'polar_area', entities: ENTITIES, ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('polar area maths', () => { + const items = resolveEntities(normalizeConfig({ type: 'x', entities: ENTITIES, max: 4000 } as CardConfig), hass()); + + it('measures slice length against the largest value or the entity limits', () => { + expect(sliceLength(items[0], 'shared', 3600)).toBeCloseTo(1 / 3); + expect(sliceLength(items[0], 'limits', 3600)).toBeCloseTo(0.3); + expect(sliceLength(items[0], 'shared', 0)).toBe(0); + expect(sliceLength(items[3], 'shared', 3600)).toBeNull(); + }); + + it('centres each slice on its spoke with a gap, and fills the circle with one', () => { + const [a0, a1] = sliceAngles(0, 4); + expect(a0).toBeCloseTo(-Math.PI / 4 + 0.02); + expect(a1).toBeCloseTo(Math.PI / 4 - 0.02); + expect(sliceAngles(0, 1)).toEqual([0, Math.PI * 2]); + }); + + it('colours by series, shade or band', () => { + expect(sliceColor(items[1], 1, 1, 'series', 'primary')).toContain('--warning-color'); + expect(sliceColor(items[1], 1, 1, 'shades', 'red')).toBe('var(--error-color, #ef3b5a)'); + expect(sliceColor(items[1], 1, 0.5, 'shades', '#123456')).toBe( + 'color-mix(in srgb, #123456 65%, var(--card-background-color, #fff))', + ); + expect(sliceColor(items[1], 1, 1, 'bands', 'primary')).toContain('--primary-color'); + }); +}); + +describe('polar area chart', () => { + it('draws a slice per value, sized against the largest, with names, values and ring values', async () => { + const card = await mount({}); + const slices = qa(card, 'path.slice.entity'); + expect(slices.map((s) => s.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(slices[1].getAttribute('aria-label')).toBe('EV, 3,600.0 W'); + expect(slices[0].getAttribute('fill')).toContain('--primary-color'); + const labels = qa(card, '.slice-label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim()); + expect(labels).toEqual([ + 'Oven 1,200.0 W', + 'EV 3,600.0 W', + 'TV 400.0 W', + 'Off Unavailable', + 'sensor.missing Entity not found', + ]); + expect(qa(card, '.slice-label.muted')).toHaveLength(2); + expect(qa(card, 'circle.ring')).toHaveLength(3); + expect(qa(card, '.ring-label').map((t) => t.textContent)).toEqual(['1,200.0 W', '2,400.0 W', '3,600.0 W']); + expect(q(card, 'svg.polar-plot')!.getAttribute('aria-label')).toBe( + 'Polar area chart of Oven, EV, TV, Off, sensor.missing', + ); + }); + + it('shades one colour, uses limits without ring values, and hides labels when asked', async () => { + const card = await mount({ + color_by: 'shades', + color: 'green', + scale: 'limits', + max: 4000, + show: { name: false, value: false }, + }); + expect(qa(card, 'path.slice')[1].getAttribute('fill')).toContain('color-mix(in srgb, var(--success-color'); + expect(qa(card, '.ring-label')).toHaveLength(0); + expect(qa(card, '.slice-label')).toHaveLength(0); + }); + + it('colours by band and shows values alone', async () => { + const card = await mount({ + color_by: 'bands', + show: { name: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 1000, color: 'red' }, + ], + }); + const fills = qa(card, 'path.slice').map((s) => s.getAttribute('fill')); + expect(fills[2]).toContain('--success-color'); + expect(fills[0]).toContain('--error-color'); + expect(q(card, '.slice-label')!.textContent!.trim()).toBe('1,200.0 W'); + }); + + it('draws one entity as a disc, and nothing for a zero', async () => { + const card = await mount({ entities: ['sensor.ev'] }); + expect(q(card, 'path.slice')!.getAttribute('d')!.match(/A/g)).toHaveLength(4); + document.body.innerHTML = ''; + const none = await mount({ entities: ['sensor.off'] }); + expect(qa(none, 'path.slice')).toHaveLength(0); + expect(qa(none, '.ring-label')).toHaveLength(0); + }); + + it('opens more-info from a slice', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const slice = qa(card, 'path.slice')[2] as HTMLElement; + slice.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + slice.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.tv']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'polar_area', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ scale: 'log' })).toThrow(/"scale" must be one of: shared, limits/); + expect(set({ color_by: 'mood' })).toThrow(/"color_by" must be one of: series, shades, bands/); + expect(set({ color: 4 })).toThrow(/color/); + expect(set({ color: '#fa0', color_by: 'shades', scale: 'limits' })).not.toThrow(); + }); +}); + +describe('polar area editor', () => { + it('writes scale, colouring and the shade colour', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = hass(); + const raw = { type: 'custom:chartdeck-card', style: 'polar_area', entities: ['sensor.ev'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ polar_scale: 'shared', polar_color_by: 'series', polar_color: 'primary' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { polar_scale: 'limits', polar_color_by: 'shades', polar_color: 'purple' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, scale: 'limits', color_by: 'shades', color: 'purple' }); + const button = ed.shadowRoot!.querySelector('button[data-style="polar_area"]')!; + expect(button.closest('.styles')!.getAttribute('data-group')).toBe('compare'); + expect(button.textContent).toContain('Polar area'); + }); +}); From 2729f34658127db08c37607d90f6c96341305393 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:48:16 +0000 Subject: [PATCH 05/41] feat: show many parts of a whole as a treemap, grouped by area or floor The new treemap style draws each entity as a rectangle sized by its value, packed so the rectangles stay close to square (the squarified layout). It sits in the style picker under "Parts of a total". Entities can be grouped. group_by picks any of floor, area, device and label from Home Assistant's registries, nested in that order, so "floor, area" gives a box per floor holding a box per room. An entity's own group text wins over group_by, and a slash makes two levels: "Ground floor / Kitchen". An entity with no area (or floor...) goes under "Other". Each group gets a tinted box in its own colour with its name and total along the top when there is room, and its tiles share that colour, the smaller ones a little fainter. color_by: bands colours every tile by its current colour band instead. The drawing is laid out for the card's real width and again whenever the card is resized. A total sits above the map ("5.9 kW in all"), and on each group, when the entities share a unit. Entities with no reading (unavailable or not found) are listed under the map, still tappable; zero and negative values get no tile. Tiles are tappable entities, so tap, hold and double-tap actions work as on the other styles. They fade in on first draw, and not with reduced motion or animate: false. Everything is in the editor's Chart section: colour, group by, and a text box per listed entity for its own group. Shared pieces added for the hierarchy charts: grouping and trees (src/chart/hierarchy.ts), the squarified layout (src/chart/treemap-layout.ts), series colours, and the chart editor forms. The card now redraws on a bare requestUpdate(), as on the line chart branch, so a resize lays the map out again. Tapping a group to drill into it comes with the drill-down change (#44). Closes #34 --- src/chart/treemap-layout.ts | 84 ++++++++ src/styles/index.ts | 2 + src/styles/treemap.ts | 388 ++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/treemap-layout.test.ts | 50 +++++ test/treemap.test.ts | 279 ++++++++++++++++++++++++++ 6 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 src/chart/treemap-layout.ts create mode 100644 src/styles/treemap.ts create mode 100644 test/treemap-layout.test.ts create mode 100644 test/treemap.test.ts diff --git a/src/chart/treemap-layout.ts b/src/chart/treemap-layout.ts new file mode 100644 index 0000000..9c28dee --- /dev/null +++ b/src/chart/treemap-layout.ts @@ -0,0 +1,84 @@ +/** A rectangle in whatever units the caller lays out in. */ +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** The worst (largest) aspect ratio in a row of `areas` laid along a side of length `side`. */ +function worst(areas: number[], side: number): number { + const sum = areas.reduce((a, b) => a + b, 0); + if (sum <= 0 || side <= 0) return Infinity; + const max = Math.max(...areas); + const min = Math.min(...areas); + const s2 = side * side; + return Math.max((s2 * max) / (sum * sum), (sum * sum) / (s2 * min)); +} + +/** + * Squarified treemap (Bruls, Huizing and van Wijk): split `rect` into one rectangle per value, + * each with an area in proportion to its value, kept as close to square as the values allow. + * Rectangles come back in the order of `values`; zero, negative and non-finite values get an + * empty rectangle at the corner. + */ +export function squarify(values: number[], rect: Rect): Rect[] { + const out: Rect[] = values.map(() => ({ x: rect.x, y: rect.y, w: 0, h: 0 })); + const items = values + .map((v, i) => ({ v: Number.isFinite(v) && v > 0 ? v : 0, i })) + .filter((it) => it.v > 0) + .sort((a, b) => b.v - a.v || a.i - b.i); + const total = items.reduce((a, it) => a + it.v, 0); + if (total <= 0 || rect.w <= 0 || rect.h <= 0) return out; + const scale = (rect.w * rect.h) / total; + let { x, y, w, h } = rect; + let row: { area: number; i: number }[] = []; + + const place = () => { + const sum = row.reduce((a, r) => a + r.area, 0); + if (w >= h) { + // Lay the row as a column down the left side. + const cw = sum / h; + let cy = y; + for (const r of row) { + const rh = r.area / cw; + out[r.i] = { x, y: cy, w: cw, h: rh }; + cy += rh; + } + x += cw; + w -= cw; + } else { + // Lay the row along the top. + const rh = sum / w; + let cx = x; + for (const r of row) { + const rw = r.area / rh; + out[r.i] = { x: cx, y, w: rw, h: rh }; + cx += rw; + } + y += rh; + h -= rh; + } + row = []; + }; + + for (const it of items) { + const area = it.v * scale; + const side = Math.min(w, h); + const current = row.map((r) => r.area); + if (row.length === 0 || worst([...current, area], side) <= worst(current, side)) { + row.push({ area, i: it.i }); + } else { + place(); + row.push({ area, i: it.i }); + } + } + if (row.length) place(); + return out; +} + +/** `rect` shrunk by `pad` on every side, never below zero size. */ +export function inset(rect: Rect, pad: number): Rect { + const p = Math.min(pad, rect.w / 2, rect.h / 2); + return { x: rect.x + p, y: rect.y + p, w: rect.w - 2 * p, h: rect.h - 2 * p }; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 4d9c05b..2f327ab 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -24,6 +24,7 @@ import { waffle } from './waffle'; import { funnel } from './funnel'; import { radar } from './radar'; import { polarArea } from './polar-area'; +import { treemap } from './treemap'; export interface RenderContext { show: ShowOptions; @@ -136,4 +137,5 @@ export const STYLES: Record = { funnel, radar, polar_area: polarArea, + treemap, }; diff --git a/src/styles/treemap.ts b/src/styles/treemap.ts new file mode 100644 index 0000000..9daee5a --- /dev/null +++ b/src/styles/treemap.ts @@ -0,0 +1,388 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS, shade } from '../chart/colors'; +import { buildTree, groupPath, parseGroupBy, validateGroups } from '../chart/hierarchy'; +import type { TreeNode } from '../chart/hierarchy'; +import { inset, squarify } from '../chart/treemap-layout'; +import type { Rect } from '../chart/treemap-layout'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_treemap: 'Treemap', + editor_treemap_color_by: 'Colour', + editor_treemap_color_by_group: 'One colour per group', + editor_treemap_color_by_bands: 'By colour band', + treemap_aria: 'Treemap of {names}', + treemap_total: '{value} in all', + treemap_empty: 'Nothing to show: no entity has a value above zero.', + treemap_no_reading: 'No reading:', + treemap_other: 'Other', +}); + +export const TREEMAP_COLOR_BY = ['group', 'bands'] as const; +export type TreemapColorBy = (typeof TREEMAP_COLOR_BY)[number]; + +/** The drawing's height in px; its width follows the card. */ +const HEIGHT = 240; +/** Width to lay out for until the card has been measured. */ +const DEFAULT_WIDTH = 400; +/** A group's name strip, and the padding round its contents, in px. */ +const HEAD = 22; +const PAD = 3; +/** Half the gap between neighbouring tiles. */ +const GAP = 1.5; + +export interface TreemapBox { + node: TreeNode; + rect: Rect; + /** Colour key: the top-level group (or entity) it belongs to, as a series index. */ + series: number; + /** Groups only: whether there was room for the name strip. */ + head?: boolean; + /** Leaves only: its place among its group's leaves, largest first. */ + rank?: number; +} + +/** + * Lay out a tree in a `width` × `height` box: each group gets a rectangle sized by its total + * (with a name strip on top when there is room), and its entities share what is left of it. + * Nodes with nothing to show are left out. + */ +export function treemapLayout( + root: TreeNode, + width: number, + height: number, +): { groups: TreemapBox[]; leaves: TreemapBox[] } { + const groups: TreemapBox[] = []; + const leaves: TreemapBox[] = []; + const place = (node: TreeNode, rect: Rect, series: number, top: boolean) => { + const shown = node.children.filter((c) => c.value > 0); + const rects = squarify( + shown.map((c) => c.value), + rect, + ); + shown.forEach((child, i) => { + const r = rects[i]; + const s = top ? i : series; + if (child.entity) { + leaves.push({ node: child, rect: inset(r, GAP), series: s, rank: i }); + return; + } + const box = inset(r, GAP); + const head = box.h >= HEAD * 2 && box.w >= 56; + groups.push({ node: child, rect: box, series: s, head }); + const inner = inset(head ? { ...box, y: box.y + HEAD - PAD, h: box.h - HEAD + PAD } : box, PAD); + place(child, inner, s, false); + }); + }; + place(root, { x: 0, y: 0, w: width, h: height }, 0, true); + return { groups, leaves }; +} + +/** The unit every item with a value shares, or undefined when they differ (a total would mean nothing). */ +export function sharedUnit(items: ResolvedEntity[]): string | undefined { + const units = new Set(items.filter((e) => e.available).map((e) => e.unit)); + return units.size === 1 ? [...units][0] : undefined; +} + +/** A total in the unit the items share, with the decimals of the first one that has a value. */ +export function totalText(value: number, ctx: ChartContext, unit: string): string { + const decimals = ctx.items.find((e) => e.available)?.decimals ?? 1; + return withUnit(formatNumber(value, decimals, ctx.hass), unit); +} + +/** Each card's last measured drawing width, and the observers that keep it current. */ +const widths = new WeakMap(); +const observers = new WeakMap(); + +/** The width to lay out for, watching the card so a resize lays it out again. */ +function measure(ctx: ChartContext): number { + const host = ctx.host; + if (!observers.has(host) && typeof ResizeObserver === 'function') { + const observer = new ResizeObserver(() => { + const el = host.shadowRoot?.querySelector('.treemap'); + const w = el?.clientWidth ?? 0; + if (w > 0 && Math.abs(w - (widths.get(host) ?? 0)) >= 1) { + widths.set(host, w); + ctx.requestUpdate(); + } + }); + observer.observe(host); + observers.set(host, observer); + } + return widths.get(host) ?? DEFAULT_WIDTH; +} + +function forget(host: HTMLElement): void { + observers.get(host)?.disconnect(); + observers.delete(host); +} + +/** A colour name as given (palette name or CSS), for picking readable text on top. */ +function leafColor(box: TreemapBox, colorBy: TreemapColorBy): string { + const e = box.node.entity!; + if (colorBy === 'bands') return e.band.color; + const own = (e.config as { color?: unknown }).color; + return typeof own === 'string' && own ? own : SERIES_COLORS[box.series % SERIES_COLORS.length]; +} + +const pct = (v: number, of: number) => `${(v / of) * 100}%`; + +function position(rect: Rect, width: number): string { + return `left:${pct(rect.x, width)};top:${pct(rect.y, HEIGHT)};width:${pct(rect.w, width)};height:${pct(rect.h, HEIGHT)}`; +} + +function leaf( + box: TreemapBox, + ctx: ChartContext, + width: number, + colorBy: TreemapColorBy, + total: number, +): TemplateResult { + const e = box.node.entity!; + const name = leafColor(box, colorBy); + // Later tiles in a group fade a little, so neighbours read apart; never so far that the + // text colour chosen for the full colour stops being readable. + const fill = + colorBy === 'group' && box.node.path.length + ? shade(baseColor(name), Math.max(70, 100 - 12 * (box.rank ?? 0))) + : baseColor(name); + const text = onFillText(name); + const { w, h } = box.rect; + const { show } = ctx.config; + const big = w >= 120 && h >= 70 && e.value !== null && e.value >= total * 0.25; + const labels = h >= 20 && w >= 44; + const value = show.value && h >= 36; + return html``; +} + +function group(box: TreemapBox, ctx: ChartContext, width: number, colorBy: TreemapColorBy, unit: string | undefined) { + const color = baseColor(SERIES_COLORS[box.series % SERIES_COLORS.length]); + const total = unit === undefined ? '' : totalText(box.node.value, ctx, unit); + return html`
+ ${ + box.head + ? html`
+ ${box.node.name} + ${ctx.config.show.value && total && box.rect.w >= 110 ? html`${total}` : nothing} +
` + : nothing + } +
`; +} + +const FIELDS: FieldBinding[] = [{ name: 'treemap_color_by', path: ['color_by'], default: 'group' }, ...GROUP_FIELDS]; + +export function treemapEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('treemap', ctx.hass, () => [ + { name: 'treemap_color_by', selector: choice(ctx.hass, 'treemap_color_by', TREEMAP_COLOR_BY) }, + groupBySchema(ctx.hass), + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; +} + +/** The tree the card's entities make, with groups from `group_by` and their own `group`. */ +export function treeFor(ctx: ChartContext): TreeNode { + const levels = parseGroupBy(ctx.raw.group_by); + const other = localize(ctx.hass, 'treemap_other'); + const paths = ctx.items.map((e) => groupPath(e, levels, ctx.hass, other)); + return buildTree(ctx.items, paths, ''); +} + +export const treemap: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 280, + styles: css` + .treemap-head { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; + min-height: 20px; + padding-bottom: 6px; + color: var(--secondary-text-color); + font-size: 0.875em; + } + .treemap { + position: relative; + height: ${HEIGHT}px; + } + .treemap .group, + .treemap .tile { + position: absolute; + box-sizing: border-box; + border-radius: 6px; + overflow: hidden; + } + .treemap .group-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 6px; + height: ${HEAD}px; + padding: 3px 8px 0; + box-sizing: border-box; + font-size: 12px; + white-space: nowrap; + } + .treemap .group-name { + font-weight: 500; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + } + .treemap .group-total { + color: var(--secondary-text-color); + } + .treemap .tile { + display: flex; + flex-direction: column; + padding: 5px 8px; + gap: 1px; + cursor: pointer; + outline: none; + opacity: 0; + transform: scale(0.96); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .treemap .tile { + opacity: 1; + transform: none; + } + :host([static]) .treemap .tile { + transition: none; + } + .treemap .tile:focus-visible { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + .treemap .tile.text-light { + color: #fff; + } + .treemap .tile.text-dark { + color: rgba(0, 0, 0, 0.87); + } + .treemap .tile.text-theme { + color: var(--primary-text-color); + } + .treemap .tile-name, + .treemap .tile-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; + } + .treemap .tile-name { + font-size: 13px; + font-weight: 500; + } + .treemap .tile-value { + font-size: 12px; + opacity: 0.9; + } + .treemap .tile.big .tile-value { + font-size: 22px; + font-weight: 500; + opacity: 1; + } + .treemap-message { + display: flex; + align-items: center; + justify-content: center; + height: ${HEIGHT}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .treemap-missing { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .treemap-missing .entity { + cursor: pointer; + border-radius: 4px; + } + .treemap-missing .entity:focus-visible { + outline: 2px solid var(--primary-color); + } + `, + thumbnail: () => svg` + + + + `, + validate(raw: CardConfig) { + validateGroups(raw); + if (raw.color_by !== undefined && !TREEMAP_COLOR_BY.includes(raw.color_by as TreemapColorBy)) { + throw new Error(`"color_by" must be one of: ${TREEMAP_COLOR_BY.join(', ')}`); + } + }, + editor: treemapEditor, + disconnect: forget, + render(ctx) { + const width = measure(ctx); + const root = treeFor(ctx); + const colorBy = (ctx.raw.color_by as TreemapColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const { groups, leaves } = treemapLayout(root, width, HEIGHT); + const noReading = ctx.items.map((e, index) => ({ e, index })).filter(({ e }) => !e.available); + const total = + unit !== undefined && ctx.config.show.value && root.value > 0 + ? localize(ctx.hass, 'treemap_total', { + value: totalText(root.value, ctx, unit), + }) + : ''; + const label = localize(ctx.hass, 'treemap_aria', { + names: (root.children.length ? root.children : []).map((c) => c.name).join(', '), + }); + const body = leaves.length + ? html`` + : html`
${localize(ctx.hass, 'treemap_empty')}
`; + return html`${total ? html`
${total}
` : nothing} + ${body} + ${ + noReading.length + ? html`
+ ${localize(ctx.hass, 'treemap_no_reading')} + ${noReading.map( + ({ e, index }) => + html`${e.name} (${e.display})`, + )} +
` + : nothing + }`; + }, +}; diff --git a/src/types.ts b/src/types.ts index a2c4890..4f932e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/treemap-layout.test.ts b/test/treemap-layout.test.ts new file mode 100644 index 0000000..033df43 --- /dev/null +++ b/test/treemap-layout.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { inset, squarify } from '../src/chart/treemap-layout'; +import type { Rect } from '../src/chart/treemap-layout'; + +const area = (r: Rect) => r.w * r.h; + +describe('squarify', () => { + it('gives each value an area in proportion, filling the rectangle', () => { + const values = [6, 6, 4, 3, 2, 2, 1]; + const rect = { x: 0, y: 0, w: 600, h: 400 }; + const out = squarify(values, rect); + const total = values.reduce((a, b) => a + b, 0); + out.forEach((r, i) => expect(area(r)).toBeCloseTo((values[i] / total) * 600 * 400, 6)); + for (const r of out) { + expect(r.x).toBeGreaterThanOrEqual(-1e-9); + expect(r.y).toBeGreaterThanOrEqual(-1e-9); + expect(r.x + r.w).toBeLessThanOrEqual(600 + 1e-9); + expect(r.y + r.h).toBeLessThanOrEqual(400 + 1e-9); + } + // The classic example's first two land side by side as squares-ish columns. + expect(Math.max(out[0].w / out[0].h, out[0].h / out[0].w)).toBeLessThan(2); + }); + + it('keeps the input order and gives nothing to zero, negative and missing values', () => { + const out = squarify([0, 5, -2, Number.NaN, 5], { x: 10, y: 20, w: 100, h: 50 }); + expect(out).toHaveLength(5); + expect(area(out[0])).toBe(0); + expect(area(out[2])).toBe(0); + expect(area(out[3])).toBe(0); + expect(area(out[1])).toBeCloseTo(2500); + expect(area(out[4])).toBeCloseTo(2500); + expect(out[1].x).toBe(10); + }); + + it('lays rows along the top of a tall rectangle', () => { + const out = squarify([1, 1], { x: 0, y: 0, w: 10, h: 40 }); + expect(out[0]).toEqual({ x: 0, y: 0, w: 10, h: 20 }); + expect(out[1]).toEqual({ x: 0, y: 20, w: 10, h: 20 }); + }); + + it('returns empty rectangles when there is nothing to lay out', () => { + expect(squarify([], { x: 0, y: 0, w: 1, h: 1 })).toEqual([]); + expect(squarify([1], { x: 0, y: 0, w: 0, h: 1 })[0].w).toBe(0); + }); + + it('insets without going negative', () => { + expect(inset({ x: 0, y: 0, w: 10, h: 10 }, 2)).toEqual({ x: 2, y: 2, w: 6, h: 6 }); + expect(inset({ x: 0, y: 0, w: 2, h: 10 }, 4)).toEqual({ x: 1, y: 1, w: 0, h: 8 }); + }); +}); diff --git a/test/treemap.test.ts b/test/treemap.test.ts new file mode 100644 index 0000000..b04d629 --- /dev/null +++ b/test/treemap.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { buildTree } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { sharedUnit, treemapLayout } from '../src/styles/treemap'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(): HomeAssistant { + const h = makeHass({ + 'sensor.ev': ['3.6', { friendly_name: 'EV charger', unit_of_measurement: 'kW' }], + 'sensor.oven': ['1.2', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.kettle': ['0.6', { friendly_name: 'Kettle', unit_of_measurement: 'kW' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV', unit_of_measurement: 'kW' }], + 'sensor.idle': ['0', { friendly_name: 'Idle', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Fridge', unit_of_measurement: 'kW' }], + }); + Object.assign(h, { + entities: { + 'sensor.ev': { entity_id: 'sensor.ev', area_id: 'garage' }, + 'sensor.oven': { entity_id: 'sensor.oven', area_id: 'kitchen' }, + 'sensor.kettle': { entity_id: 'sensor.kettle', area_id: 'kitchen' }, + }, + areas: { garage: { area_id: 'garage', name: 'Garage' }, kitchen: { area_id: 'kitchen', name: 'Kitchen' } }, + }); + return h; +} + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'treemap', + entities: ['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.tv'], + ...config, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); +}); + +describe('treemap layout', () => { + const items = (entities: CardConfig['entities']) => + resolveEntities(normalizeConfig({ type: 'x', entities } as CardConfig), hass()); + + it('sizes groups by their totals and gives groups with room a name strip', () => { + const list = items(['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.idle']); + const root = buildTree(list, [['Garage'], ['Kitchen'], ['Kitchen'], ['Kitchen']], ''); + const { groups, leaves } = treemapLayout(root, 400, 240); + expect(groups.map((g) => [g.node.name, g.series, g.head])).toEqual([ + ['Garage', 0, true], + ['Kitchen', 1, true], + ]); + // The idle entity has nothing to show, so it gets no tile. + expect(leaves.map((l) => [l.node.name, l.series, l.rank])).toEqual([ + ['EV charger', 0, 0], + ['Oven', 1, 0], + ['Kettle', 1, 1], + ]); + const area = (r: { w: number; h: number }) => r.w * r.h; + expect(area(groups[0].rect) / area(groups[1].rect)).toBeCloseTo(2, 0); + // A leaf sits inside its group, below the name strip. + expect(leaves[1].rect.y).toBeGreaterThan(groups[1].rect.y + 18); + }); + + it('lays flat entities straight into the box, one colour each', () => { + const list = items(['sensor.ev', 'sensor.oven']); + const { groups, leaves } = treemapLayout(buildTree(list, [[], []], ''), 300, 100); + expect(groups).toEqual([]); + expect(leaves.map((l) => l.series)).toEqual([0, 1]); + }); + + it('skips the name strip on a group too small for it', () => { + const list = items(['sensor.ev', 'sensor.tv']); + const { groups } = treemapLayout(buildTree(list, [['A'], ['B']], ''), 300, 30); + expect(groups.every((g) => !g.head)).toBe(true); + }); + + it('finds the unit the items with a value share', () => { + expect(sharedUnit(items(['sensor.ev', 'sensor.off']))).toBe('kW'); + expect(sharedUnit(items(['sensor.ev', { entity: 'sensor.tv', unit: 'W' }]))).toBeUndefined(); + }); +}); + +describe('treemap chart', () => { + it('draws one tile per entity with a value, a total, and opens more-info from a tile', async () => { + const card = await mount({}); + const tiles = qa(card, '.treemap .tile.entity'); + expect(tiles.map((t) => t.dataset.index)).toEqual(['0', '1', '2', '3']); + expect(tiles[0].style.background).toContain('--primary-color'); + expect(tiles[1].style.background).toContain('--warning-color'); + expect(tiles[0].getAttribute('aria-label')).toBe('EV charger, 3.6 kW'); + expect(text(q(card, '.treemap-total'))).toBe('5.9 kW in all'); + expect(q(card, '.treemap')!.getAttribute('role')).toBe('img'); + expect(q(card, '.treemap')!.getAttribute('aria-label')).toBe('Treemap of EV charger, Oven, Kettle, TV'); + expect(q(card, '.group')).toBeNull(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + tiles[2].dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + tiles[2].dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.kettle']); + }); + + it('groups by area from the registries, with an own group winning and the rest in Other', async () => { + const card = await mount({ + group_by: 'area', + entities: ['sensor.ev', 'sensor.oven', 'sensor.kettle', 'sensor.tv', { entity: 'sensor.idle', group: 'Den' }], + } as Partial); + const names = qa(card, '.group-name').map((g) => g.textContent); + expect(names).toEqual(['Garage', 'Kitchen', 'Other']); + expect(qa(card, '.group-total').map((g) => g.textContent)).toContain('1.8 kW'); + // Kitchen's second tile is a fainter shade of the group's colour. + const kettle = qa(card, '.tile').find((t) => t.dataset.index === '2')!; + expect(kettle.style.background).toContain('color-mix'); + }); + + it('nests two levels from an own group with a slash', async () => { + const card = await mount({ + entities: [ + { entity: 'sensor.oven', group: 'Ground / Kitchen' }, + { entity: 'sensor.kettle', group: 'Ground / Kitchen' }, + { entity: 'sensor.ev', group: 'Outside' }, + ], + } as Partial); + expect(qa(card, '.group-name').map((g) => g.textContent)).toEqual(['Outside', 'Ground', 'Kitchen']); + }); + + it('colours by band, and lists entities with no reading under the map', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'green' }, + { from: 1, color: 'red' }, + ], + entities: ['sensor.ev', 'sensor.tv', 'sensor.off', 'sensor.gone'], + } as Partial); + const tiles = qa(card, '.tile'); + expect(tiles[0].style.background).toContain('--error-color'); + expect(tiles[1].style.background).toContain('--success-color'); + const missing = qa(card, '.treemap-missing .entity'); + expect(missing.map((m) => [m.dataset.index, text(m)])).toEqual([ + ['2', 'Fridge (Unavailable)'], + ['3', 'sensor.gone (Entity not found)'], + ]); + }); + + it('says so when nothing has a value, and leaves out the total for mixed units', async () => { + const empty = await mount({ entities: ['sensor.idle', 'sensor.off'] }); + expect(text(q(empty, '.treemap-message'))).toMatch(/Nothing to show/); + const mixed = await mount({ entities: ['sensor.ev', { entity: 'sensor.tv', unit: 'W' }] } as Partial); + expect(q(mixed, '.treemap-total')).toBeNull(); + const quiet = await mount({ show: { value: false, name: false } } as Partial); + expect(q(quiet, '.treemap-total')).toBeNull(); + expect(q(quiet, '.tile-name')).toBeNull(); + expect(q(quiet, '.tile-value')).toBeNull(); + }); + + it('lays out again for the measured width, and stops watching when it leaves', async () => { + let callback: () => void = () => {}; + const disconnect = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(cb: () => void) { + callback = cb; + } + observe() {} + disconnect = disconnect; + }, + ); + const card = await mount({}); + const map = q(card, '.treemap') as HTMLElement; + Object.defineProperty(map, 'clientWidth', { value: 800, configurable: true }); + const layout = () => + qa(card, '.tile') + .map((t) => t.getAttribute('style')) + .join('|'); + const before = layout(); + callback(); + await card.updateComplete; + expect(layout()).not.toBe(before); + card.remove(); + expect(disconnect).toHaveBeenCalled(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'treemap', entities: ['sensor.ev'], ...extra } as CardConfig); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ group_by: 'room' })).toThrow(/group_by/); + expect(set({ entities: [{ entity: 'sensor.ev', group: 4 }] })).toThrow(/group/); + }); +}); + +describe('treemap editor', () => { + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = hass(); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes the colour and the group levels', async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'treemap', + group_by: 'area', + entities: ['sensor.ev'], + } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + expect(form.data).toEqual({ treemap_color_by: 'group', group_by: ['area'] }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { treemap_color_by: 'bands', group_by: ['area', 'floor'] } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, color_by: 'bands', group_by: ['area', 'floor'] }); + }); + + it('sets and clears each listed entity’s own group', async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'treemap', + entities: ['sensor.ev', { entity: 'sensor.oven', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('.entity-options[data-section="entity_groups"] ha-form') as Stub & { + computeLabel(s: { name: string }): string; + }; + expect(list.data).toEqual({ e0: undefined, e1: 'Kitchen' }); + expect(list.computeLabel({ name: 'e0' })).toBe('EV charger'); + list.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: 'Garage', e1: '' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.ev', group: 'Garage' }, 'sensor.oven']); + }); + + it('asks for an entity before offering groups', async () => { + const { ed } = await editor({ type: 'custom:chartdeck-card', style: 'treemap' } as CardConfig); + expect(ed.shadowRoot!.querySelector('.entity-options')!.textContent).toMatch(/Add an entity above first/); + }); +}); From 9a60bfc4ca103b5886afa536a84872be1694cf87 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:53:39 +0000 Subject: [PATCH 06/41] feat: show a hierarchy as a sunburst, half sunburst or icicle chart Two new styles under "Parts of a total" draw the card's entities as a hierarchy, such as house, floor, room and device power. The sunburst draws a ring per level round the total: the first ring is the top-level groups, each sized by its share, and every ring out splits its parent into what makes it up, ending with the entities. shape: half draws the top half only, with the total underneath, for a lower card. A legend under it names the top level with each total. The icicle draws the same hierarchy as columns: the total on the left, then each level to the right, stacked top to bottom by share, with names and values where there is room. The levels come from grouping. group_by picks any of floor, area, device and label from Home Assistant's registries, nested in that order. An entity's own group text wins, with a slash for each level: "Ground floor / Kitchen". An entity without an area (or floor...) goes under "Other". Each top-level group takes the next series colour and its children fade from it level by level, mixed with the card's background so it works in light and dark themes. color_by: bands colours the entities by their current colour band instead. Totals show only when the entities share a unit. Entities with no reading are listed under the chart, still tappable; zero and negative values take no space. Every entity's arc or cell is tappable, so tap, hold and double-tap actions work as on the other styles. The chart draws in on first show, and not with reduced motion or animate: false. Everything is in the editor's Chart section: shape, colour, group by, and a text box per listed entity for its own group. The grouping, tree and polar helpers are the same files as on the treemap and radar branches. Closes #35 --- src/styles/index.ts | 3 + src/styles/sunburst.ts | 521 +++++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/sunburst.test.ts | 273 +++++++++++++++++++++ 4 files changed, 799 insertions(+), 2 deletions(-) create mode 100644 src/styles/sunburst.ts create mode 100644 test/sunburst.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 2f327ab..499d9f2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -25,6 +25,7 @@ import { funnel } from './funnel'; import { radar } from './radar'; import { polarArea } from './polar-area'; import { treemap } from './treemap'; +import { icicle, sunburst } from './sunburst'; export interface RenderContext { show: ShowOptions; @@ -138,4 +139,6 @@ export const STYLES: Record = { radar, polar_area: polarArea, treemap, + sunburst, + icicle, }; diff --git a/src/styles/sunburst.ts b/src/styles/sunburst.ts new file mode 100644 index 0000000..02a67a1 --- /dev/null +++ b/src/styles/sunburst.ts @@ -0,0 +1,521 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor, onFillText } from '../bands'; +import { SERIES_COLORS, shade } from '../chart/colors'; +import { buildTree, groupPath, parseGroupBy, partition, treeHeight, validateGroups } from '../chart/hierarchy'; +import type { Span, TreeNode } from '../chart/hierarchy'; +import { sectorPath } from '../chart/polar'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_sunburst: 'Sunburst', + editor_style_icicle: 'Icicle', + editor_sunburst_shape: 'Shape', + editor_sunburst_shape_full: 'Full circle', + editor_sunburst_shape_half: 'Half circle', + editor_hierarchy_color_by: 'Colour', + editor_hierarchy_color_by_group: 'One colour per group', + editor_hierarchy_color_by_bands: 'By colour band', + hierarchy_total: 'Total', + hierarchy_other: 'Other', + hierarchy_empty: 'Nothing to show: no entity has a value above zero.', + hierarchy_no_reading: 'No reading:', + sunburst_aria: 'Sunburst of {names}', + icicle_aria: 'Icicle chart of {names}', +}); + +export const SUNBURST_SHAPES = ['full', 'half'] as const; +export type SunburstShape = (typeof SUNBURST_SHAPES)[number]; +export const HIERARCHY_COLOR_BY = ['group', 'bands'] as const; +export type HierarchyColorBy = (typeof HIERARCHY_COLOR_BY)[number]; + +/** Drawing height in px; the sunburst's radius and hole, in SVG units (≈ px). */ +const HEIGHT = 240; +const RADIUS = 112; +const HOLE = 40; + +/** How strong each level's colour is: the first ring full, deeper ones fading. */ +export function levelStrength(level: number): number { + return Math.max(45, 100 - (level - 1) * 20); +} + +/** The tree the card's entities make, with groups from `group_by` and their own `group`. */ +export function hierarchyTree(ctx: ChartContext): TreeNode { + const levels = parseGroupBy(ctx.raw.group_by); + const other = localize(ctx.hass, 'hierarchy_other'); + const paths = ctx.items.map((e) => groupPath(e, levels, ctx.hass, other)); + return buildTree(ctx.items, paths, localize(ctx.hass, 'hierarchy_total')); +} + +/** The unit every item with a value shares, or undefined when they differ. */ +export function sharedUnit(items: ResolvedEntity[]): string | undefined { + const units = new Set(items.filter((e) => e.available).map((e) => e.unit)); + return units.size === 1 ? [...units][0] : undefined; +} + +export interface Painted { + /** A colour ready for CSS. */ + fill: string; + /** Text on top: from the colour as given, or the theme's own when the fill is faint. */ + text: 'light' | 'dark' | 'theme'; +} + +/** + * Each node's colour. A top-level node takes the next series colour (an entity's own `color` + * wins) and passes it down, fading with depth; by bands, entities take their band's colour and + * groups a neutral grey. + */ +export function paintSpans(spans: Span[], colorBy: HierarchyColorBy): Map { + const painted = new Map(); + const family = new Map(); + let next = 0; + for (const { node, level } of spans) { + if (level === 0) continue; + const own = node.entity ? (node.entity.config as { color?: unknown }).color : undefined; + let name: string; + if (level === 1) name = typeof own === 'string' && own ? own : SERIES_COLORS[next++ % SERIES_COLORS.length]; + else name = family.get(node) ?? 'grey'; + for (const child of node.children) family.set(child, name); + if (colorBy === 'bands') { + painted.set( + node, + node.entity + ? { fill: baseColor(node.entity.band.color), text: onFillText(node.entity.band.color) } + : { fill: 'var(--cd-track)', text: 'theme' }, + ); + continue; + } + const strength = levelStrength(level); + painted.set(node, { + fill: shade(baseColor(name), strength), + text: strength >= 100 ? onFillText(name) : 'theme', + }); + } + return painted; +} + +function totalText(value: number, ctx: ChartContext, unit: string): string { + const decimals = ctx.items.find((e) => e.available)?.decimals ?? 1; + return withUnit(formatNumber(value, decimals, ctx.hass), unit); +} + +/** A node's text for screen readers and tooltips: "Kitchen, 1.8 kW" or the entity's own. */ +function nodeLabel(node: TreeNode, ctx: ChartContext, unit: string | undefined): string { + if (node.entity) return node.entity.label; + return unit === undefined ? node.name : `${node.name}, ${totalText(node.value, ctx, unit)}`; +} + +/** Entities with no number, listed under the chart so they can still be tapped. */ +function noReading(ctx: ChartContext): TemplateResult | typeof nothing { + const list = ctx.items.map((e, index) => ({ e, index })).filter(({ e }) => !e.available); + if (!list.length) return nothing; + return html`
+ ${localize(ctx.hass, 'hierarchy_no_reading')} + ${list.map( + ({ e, index }) => + html`${e.name} (${e.display})`, + )} +
`; +} + +const FIELDS: FieldBinding[] = [{ name: 'hierarchy_color_by', path: ['color_by'], default: 'group' }, ...GROUP_FIELDS]; +const SUNBURST_FIELDS: FieldBinding[] = [{ name: 'sunburst_shape', path: ['shape'], default: 'full' }, ...FIELDS]; + +export function sunburstEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('sunburst', ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'sunburst_shape', selector: choice(ctx.hass, 'sunburst_shape', SUNBURST_SHAPES) }, + { name: 'hierarchy_color_by', selector: choice(ctx.hass, 'hierarchy_color_by', HIERARCHY_COLOR_BY) }, + ], + }, + groupBySchema(ctx.hass), + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, SUNBURST_FIELDS)} ${entityGroupsForm(ctx)}`; +} + +export function icicleEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('icicle', ctx.hass, () => [ + { name: 'hierarchy_color_by', selector: choice(ctx.hass, 'hierarchy_color_by', HIERARCHY_COLOR_BY) }, + groupBySchema(ctx.hass), + ]); + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; +} + +function validate(raw: CardConfig, shapes: boolean): void { + validateGroups(raw); + if (raw.color_by !== undefined && !HIERARCHY_COLOR_BY.includes(raw.color_by as HierarchyColorBy)) { + throw new Error(`"color_by" must be one of: ${HIERARCHY_COLOR_BY.join(', ')}`); + } + if (shapes && raw.shape !== undefined && !SUNBURST_SHAPES.includes(raw.shape as SunburstShape)) { + throw new Error(`"shape" must be one of: ${SUNBURST_SHAPES.join(', ')}`); + } +} + +/** The pieces of one sunburst: where each ring starts and how far round it may go. */ +export function sunburstGeometry(shape: SunburstShape, levels: number) { + const ring = (RADIUS - HOLE) / Math.max(1, levels); + return { + start: shape === 'half' ? -Math.PI / 2 : 0, + sweep: shape === 'half' ? Math.PI : Math.PI * 2, + radii: (level: number): [number, number] => [HOLE + (level - 1) * ring, HOLE + level * ring], + viewBox: + shape === 'half' + ? `${-RADIUS - 4} ${-RADIUS - 4} ${RADIUS * 2 + 8} ${RADIUS + 12}` + : `${-RADIUS - 4} ${-RADIUS - 4} ${RADIUS * 2 + 8} ${RADIUS * 2 + 8}`, + }; +} + +/** The drawing for one tree root: a sunburst with the total in its middle, or a message. */ +export function drawSunburst(ctx: ChartContext, root: TreeNode, centre: TemplateResult | typeof nothing = nothing) { + if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; + const shape = (ctx.raw.shape as SunburstShape | undefined) ?? 'full'; + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const spans = partition(root); + const painted = paintSpans(spans, colorBy); + const geo = sunburstGeometry(shape, treeHeight(root)); + const arcs = spans + .filter((s) => s.level > 0) + .map((s) => { + const [r0, r1] = geo.radii(s.level); + const d = sectorPath(r0, r1, geo.start + s.x0 * geo.sweep, geo.start + s.x1 * geo.sweep); + const label = nodeLabel(s.node, ctx, unit); + const fill = painted.get(s.node)!.fill; + return s.node.entity + ? svg`${label}` + : svg`${label}`; + }); + const total = unit !== undefined && ctx.config.show.value ? totalText(root.value, ctx, unit) : ''; + const names = root.children.filter((c) => c.value > 0).map((c) => c.name); + return html`
+ + ${arcs} + +
+ ${centre} ${total ? html`${total}` : nothing} + ${root.path.length ? html`${root.name}` : nothing} +
+
`; +} + +/** Top-level names and totals under a sunburst, so the colours mean something. */ +export function sunburstLegend(ctx: ChartContext, root: TreeNode): TemplateResult | typeof nothing { + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const painted = paintSpans(partition(root), colorBy); + const top = root.children.filter((c) => c.value > 0); + return html`
+ ${top.map((node) => { + const swatch = html``; + const value = + ctx.config.show.value && unit !== undefined + ? html`${totalText(node.value, ctx, unit)}` + : nothing; + return node.entity + ? html`${swatch}${node.name} ${value}` + : html`${swatch}${node.name} ${value}`; + })} +
`; +} + +/** The drawing for one tree root as an icicle: a column per level, the root on the left. */ +export function drawIcicle(ctx: ChartContext, root: TreeNode) { + if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; + const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; + const unit = sharedUnit(ctx.items); + const spans = partition(root); + const painted = paintSpans(spans, colorBy); + const columns = treeHeight(root) + 1; + const { show } = ctx.config; + const names = root.children.filter((c) => c.value > 0).map((c) => c.name); + return html``; +} + +const sharedStyles = css` + .hierarchy-message { + display: flex; + align-items: center; + justify-content: center; + height: ${HEIGHT}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .hierarchy-missing, + .hierarchy-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .hierarchy-legend { + justify-content: center; + } + .hierarchy-legend .legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .hierarchy-legend .legend-value { + color: var(--primary-text-color); + font-weight: 500; + } + .hierarchy-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + .hierarchy-missing .entity, + .hierarchy-legend .entity { + cursor: pointer; + border-radius: 4px; + } + .hierarchy-missing .entity:focus-visible, + .hierarchy-legend .entity:focus-visible { + outline: 2px solid var(--primary-color); + } +`; + +export const sunburst: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 290, + styles: css` + ${sharedStyles} + .sunburst-wrap { + position: relative; + height: ${HEIGHT}px; + } + .sunburst-wrap.half { + height: ${HEIGHT * 0.6}px; + } + .sunburst { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .sunburst .arc { + stroke: var(--card-background-color, #fff); + stroke-width: 1.5; + stroke-linejoin: round; + } + .sunburst .arc.leaf { + cursor: pointer; + outline: none; + } + .sunburst .arc.leaf:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2.5; + } + .sunburst-grow { + transform: scale(0.6) rotate(-30deg); + opacity: 0; + transition: + transform var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .sunburst-grow { + transform: none; + opacity: 1; + } + :host([static]) .sunburst-grow { + transition: none; + } + .sunburst-centre { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + max-width: 72px; + text-align: center; + line-height: 1.2; + pointer-events: none; + } + .sunburst-wrap.half .sunburst-centre { + top: auto; + bottom: 4px; + transform: translateX(-50%); + } + .sunburst-centre > * { + pointer-events: auto; + } + .centre-value { + font-size: 1.15em; + font-weight: 500; + white-space: nowrap; + } + .centre-name { + font-size: 0.75em; + color: var(--secondary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 72px; + } + `, + thumbnail: () => svg` + + + + + + `, + validate: (raw) => validate(raw, true), + editor: sunburstEditor, + render(ctx) { + const root = hierarchyTree(ctx); + return html`${drawSunburst(ctx, root)} ${root.value > 0 ? sunburstLegend(ctx, root) : nothing} ${noReading(ctx)}`; + }, +}; + +export const icicle: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 280, + styles: css` + ${sharedStyles} + .icicle { + position: relative; + height: ${HEIGHT}px; + } + .icicle .cell { + position: absolute; + box-sizing: border-box; + padding: 1.5px; + } + .icicle .cell-fill { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 5px; + padding: 3px 8px; + overflow: hidden; + display: flex; + flex-direction: column; + line-height: 1.25; + opacity: 0; + transform: translateX(-8px); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .icicle .cell-fill { + opacity: 1; + transform: none; + } + :host([static]) .icicle .cell-fill { + transition: none; + } + .icicle .text-root .cell-fill { + background: color-mix(in srgb, var(--primary-text-color, #212121) 78%, var(--card-background-color, #fff)); + color: var(--card-background-color, #fff); + } + .icicle .text-light { + color: #fff; + } + .icicle .text-dark { + color: rgba(0, 0, 0, 0.87); + } + .icicle .text-theme { + color: var(--primary-text-color); + } + .icicle .cell-name, + .icicle .cell-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .icicle .cell-name { + font-size: 13px; + font-weight: 500; + } + .icicle .cell-value { + font-size: 12px; + opacity: 0.9; + } + .icicle .leaf { + cursor: pointer; + outline: none; + } + .icicle .leaf:focus-visible .cell-fill { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + `, + thumbnail: () => svg` + + + + + + `, + validate: (raw) => validate(raw, false), + editor: icicleEditor, + render(ctx) { + const root = hierarchyTree(ctx); + return html`${drawIcicle(ctx, root)} ${noReading(ctx)}`; + }, +}; diff --git a/src/types.ts b/src/types.ts index 4f932e5..60f44f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/sunburst.test.ts b/test/sunburst.test.ts new file mode 100644 index 0000000..712196a --- /dev/null +++ b/test/sunburst.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { buildTree, partition } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import { levelStrength, paintSpans, sharedUnit, sunburstGeometry } from '../src/styles/sunburst'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(): HomeAssistant { + const h = makeHass({ + 'sensor.ev': ['1.6', { friendly_name: 'EV', unit_of_measurement: 'kW' }], + 'sensor.oven': ['1', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.hob': ['0.8', { friendly_name: 'Hob', unit_of_measurement: 'kW' }], + 'sensor.desk': ['0.4', { friendly_name: 'Desk', unit_of_measurement: 'kW' }], + 'sensor.idle': ['0', { friendly_name: 'Idle', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Fridge', unit_of_measurement: 'kW' }], + }); + Object.assign(h, { + entities: { + 'sensor.oven': { entity_id: 'sensor.oven', area_id: 'kitchen' }, + 'sensor.hob': { entity_id: 'sensor.hob', area_id: 'kitchen' }, + 'sensor.desk': { entity_id: 'sensor.desk', area_id: 'office' }, + }, + areas: { + kitchen: { area_id: 'kitchen', name: 'Kitchen', floor_id: 'ground' }, + office: { area_id: 'office', name: 'Office', floor_id: 'first' }, + }, + floors: { ground: { name: 'Ground' }, first: { name: 'First' } }, + }); + return h; +} + +const ENTITIES = ['sensor.ev', 'sensor.oven', 'sensor.hob', 'sensor.desk']; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'sunburst', entities: ENTITIES, ...config } as CardConfig); + card.hass = h; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('sunburst maths', () => { + const items = (entities: CardConfig['entities']) => + resolveEntities(normalizeConfig({ type: 'x', entities } as CardConfig), hass()); + + it('fades deeper levels, never below 45 %', () => { + expect([1, 2, 3, 4, 9].map(levelStrength)).toEqual([100, 80, 60, 45, 45]); + }); + + it('gives each top-level node a series colour that its children fade from', () => { + const list = items(['sensor.ev', 'sensor.oven', { entity: 'sensor.hob', color: 'red' }]); + const root = buildTree(list, [['Out'], ['In'], []], 'All'); + const painted = paintSpans(partition(root), 'group'); + const byName = (name: string) => [...painted.entries()].find(([n]) => n.name === name)![1]; + expect(byName('Out').fill).toBe('var(--primary-color, #2f6fe4)'); + expect(byName('Out').text).toBe('light'); + expect(byName('In').fill).toContain('--warning-color'); + expect(byName('EV').fill).toMatch(/color-mix\(in srgb, var\(--primary-color, #2f6fe4\) 80%/); + expect(byName('EV').text).toBe('theme'); + // An entity at the top level keeps its own colour. + expect(byName('Hob').fill).toContain('--error-color'); + }); + + it('colours entities by band and groups neutral when asked', () => { + const list = items(['sensor.ev', 'sensor.oven']); + const root = buildTree(list, [['G'], ['G']], 'All'); + const painted = paintSpans(partition(root), 'bands'); + const fills = [...painted.entries()].map(([n, p]) => [n.name, p.fill]); + expect(fills).toEqual([ + ['G', 'var(--cd-track)'], + ['EV', 'var(--primary-color, #2f6fe4)'], + ['Oven', 'var(--primary-color, #2f6fe4)'], + ]); + }); + + it('sweeps a full turn or the top half, with rings sharing the radius', () => { + const full = sunburstGeometry('full', 2); + const half = sunburstGeometry('half', 3); + expect([full.start, full.sweep]).toEqual([0, Math.PI * 2]); + expect([half.start, half.sweep]).toEqual([-Math.PI / 2, Math.PI]); + expect(full.radii(1)).toEqual([40, 76]); + expect(full.radii(2)).toEqual([76, 112]); + expect(sunburstGeometry('full', 0).radii(1)).toEqual([40, 112]); + expect(half.viewBox.split(' ').map(Number)[3]).toBeLessThan(Number(full.viewBox.split(' ')[3])); + }); + + it('finds the unit the items with a value share', () => { + expect(sharedUnit(items(['sensor.ev', 'sensor.off']))).toBe('kW'); + expect(sharedUnit(items(['sensor.ev', { entity: 'sensor.oven', unit: 'W' }]))).toBeUndefined(); + }); +}); + +describe('sunburst chart', () => { + it('draws a ring per level, the total in the middle and a legend of the top level', async () => { + const card = await mount({ group_by: ['floor', 'area'] } as Partial); + const groups = qa(card, 'path.arc.group').map((p) => p.dataset.path); + expect(groups).toEqual(['Ground', 'Ground/Kitchen', 'Other', 'Other/Other', 'First', 'First/Office']); + const leaves = qa(card, 'path.arc.leaf'); + expect(leaves.map((p) => p.dataset.index)).toEqual(['1', '2', '0', '3']); + expect(leaves[0].getAttribute('aria-label')).toBe('Oven, 1.0 kW'); + expect(leaves.every((p) => p.classList.contains('entity'))).toBe(true); + expect(text(q(card, '.centre-value'))).toBe('3.8 kW'); + expect(q(card, 'svg.sunburst')!.getAttribute('aria-label')).toBe('Sunburst of Ground, Other, First'); + expect(qa(card, '.hierarchy-legend .legend-item').map(text)).toEqual([ + 'Ground 1.8 kW', + 'Other 1.6 kW', + 'First 0.4 kW', + ]); + expect(q(card, 'path.arc.group title')!.textContent).toBe('Ground, 1.8 kW'); + }); + + it('opens more-info from an arc', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const arc = qa(card, 'path.arc.leaf').find((p) => p.dataset.index === '3')!; + arc.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + arc.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.desk']); + }); + + it('draws a half circle, and flat entities straight round the middle with tappable legend items', async () => { + const card = await mount({ shape: 'half' } as Partial); + expect(q(card, '.sunburst-wrap.half')).not.toBeNull(); + expect(qa(card, 'path.arc.group')).toHaveLength(0); + const legend = qa(card, '.hierarchy-legend .entity'); + expect(legend.map((l) => l.dataset.index)).toEqual(['0', '1', '2', '3']); + }); + + it('lists entities with no reading and says so when nothing has a value', async () => { + const card = await mount({ entities: ['sensor.ev', 'sensor.off', 'sensor.gone', 'sensor.idle'] }); + expect(qa(card, '.hierarchy-missing .entity').map(text)).toEqual([ + 'Fridge (Unavailable)', + 'sensor.gone (Entity not found)', + ]); + const empty = await mount({ entities: ['sensor.idle'] }); + expect(text(q(empty, '.hierarchy-message'))).toMatch(/Nothing to show/); + expect(q(empty, '.hierarchy-legend')).toBeNull(); + }); + + it('leaves totals out for mixed units or when values are hidden', async () => { + const mixed = await mount({ entities: ['sensor.ev', { entity: 'sensor.oven', unit: 'W' }] } as Partial); + expect(q(mixed, '.centre-value')).toBeNull(); + expect(qa(mixed, '.legend-value')).toHaveLength(0); + const quiet = await mount({ show: { value: false } } as Partial); + expect(q(quiet, '.centre-value')).toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'sunburst', entities: ['sensor.ev'], ...extra } as CardConfig); + expect(set({ shape: 'square' })).toThrow(/shape/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ group_by: ['room'] })).toThrow(/group_by/); + const icicle = () => + card.setConfig({ type: 'x', style: 'icicle', entities: ['sensor.ev'], shape: 'square' } as CardConfig); + expect(icicle).not.toThrow(); + }); +}); + +describe('icicle chart', () => { + it('draws a column per level with the total on the left and labels where they fit', async () => { + const card = await mount({ + style: 'icicle', + group_by: 'area', + entities: [...ENTITIES, 'sensor.off'], + } as Partial); + const cells = qa(card, '.icicle .cell'); + expect(cells[0].classList.contains('text-root')).toBe(true); + expect(text(cells[0])).toBe('Total 3.8 kW'); + expect(cells[0].style.left).toBe('0%'); + const groups = qa(card, '.icicle .cell.group').map((c) => c.dataset.path); + expect(groups).toEqual(['', 'Kitchen', 'Other', 'Office']); + const leaves = qa(card, '.icicle .cell.leaf'); + expect(leaves.map((c) => c.dataset.index)).toEqual(['1', '2', '0', '3']); + expect(leaves[0].style.left).toBe(`${(2 / 3) * 100}%`); + expect(leaves[0].getAttribute('aria-label')).toBe('Oven, 1.0 kW'); + expect(text(leaves[0])).toBe('Oven 1.0 kW'); + expect(q(card, '.icicle')!.getAttribute('aria-label')).toBe('Icicle chart of Kitchen, Other, Office'); + expect(text(q(card, '.hierarchy-missing'))).toBe('No reading: Fridge (Unavailable)'); + }); + + it('colours by band and hides names when asked', async () => { + const card = await mount({ + style: 'icicle', + color_by: 'bands', + show: { name: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 1, color: 'red' }, + ], + } as Partial); + const leaves = qa(card, '.icicle .cell.leaf .cell-fill'); + expect(leaves[0].style.background).toContain('--error-color'); + expect(leaves.at(-1)!.style.background).toContain('--success-color'); + expect(qa(card, '.icicle .cell.leaf .cell-name')).toHaveLength(0); + // The total keeps its name: without it the left column says nothing. + expect(qa(card, '.icicle .text-root .cell-name')).toHaveLength(1); + const empty = await mount({ style: 'icicle', entities: ['sensor.idle'] } as Partial); + expect(q(empty, '.hierarchy-message')).not.toBeNull(); + }); +}); + +describe('sunburst and icicle editors', () => { + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = hass(); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + return { ed, events, form }; + } + + it('writes the shape, colour and group levels for a sunburst', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'sunburst', entities: ['sensor.ev'] } as CardConfig; + const { events, form, ed } = await editor(raw); + expect(form.data).toEqual({ sunburst_shape: 'full', hierarchy_color_by: 'group', group_by: [] }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { sunburst_shape: 'half', hierarchy_color_by: 'bands', group_by: ['floor', 'area'] } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shape: 'half', color_by: 'bands', group_by: ['floor', 'area'] }); + expect(ed.shadowRoot!.querySelector('.entity-options[data-section="entity_groups"]')).not.toBeNull(); + }); + + it('writes the colour and group levels for an icicle, without a shape', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'icicle', entities: ['sensor.ev'] } as CardConfig; + const { events, form } = await editor(raw); + expect(form.data).toEqual({ hierarchy_color_by: 'group', group_by: [] }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { hierarchy_color_by: 'group', group_by: ['label'] } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, group_by: ['label'] }); + }); +}); From 4a3c5d2ac209b5dcc56364fbf5c4d53c72b615b7 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:59:42 +0000 Subject: [PATCH 07/41] feat: tap a group in a treemap, sunburst or icicle to see what makes it up Tapping a group now opens it: the chart redraws with that group as the whole, so a treemap of the house becomes a treemap of the kitchen, and a sunburst's "Ground floor" segment becomes a sunburst of its rooms. A row above the chart gives a back button named after the level above ("All", "Ground floor") and the group being shown with its total. Back goes up one level at a time. What can be tapped: a treemap's group boxes (their name strip and border), a sunburst's group segments and its legend items, and an icicle's group cells. Entities themselves keep their tap, hold and double-tap actions. Groups work from the keyboard too (Enter or Space), and screen readers hear "Kitchen, 1.8 kW. Show what makes it up". Colours stay put while drilling: the parts of a group keep the colour that group has on the whole chart. Where a card has been drilled to is kept while its entities update; if that group disappears (its entities were removed, or the grouping changed), the card goes back to the deepest group that still exists. drill_down: false turns it off. It is a switch in the Chart section of all three charts, on by default. The drilling itself lives in src/chart/drill.ts, so other charts that group their entities can use it. This branch includes the treemap and sunburst/icicle branches, which it builds on. Closes #44 --- src/chart/drill.ts | 178 ++++++++++++++++++++++++++++++++++++++ src/styles/sunburst.ts | 123 +++++++++++++++++++------- src/styles/treemap.ts | 49 +++++++---- test/drill.test.ts | 190 +++++++++++++++++++++++++++++++++++++++++ test/sunburst.test.ts | 14 +-- test/treemap.test.ts | 6 +- 6 files changed, 508 insertions(+), 52 deletions(-) create mode 100644 src/chart/drill.ts create mode 100644 test/drill.test.ts diff --git a/src/chart/drill.ts b/src/chart/drill.ts new file mode 100644 index 0000000..c19a00a --- /dev/null +++ b/src/chart/drill.ts @@ -0,0 +1,178 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { findNode } from './hierarchy'; +import type { TreeNode } from './hierarchy'; + +registerStrings('en', { + editor_drill_down: 'Tap a group to show what makes it up', + drill_into: '{label}. Show what makes it up', + drill_back: 'Back to {name}', + drill_top: 'All', +}); + +/** + * Drilling down: tapping a group in a hierarchy chart (a treemap box, a sunburst ring, an icicle + * cell) redraws the chart with that group as the whole, and a back button returns to the level + * above. Where each card is stays with the card element, so a redraw or a new state keeps it. + */ +const paths = new WeakMap(); + +/** Whether the card lets groups be tapped into (`drill_down`, on unless set to false). */ +export function drillEnabled(raw: CardConfig): boolean { + return raw.drill_down !== false; +} + +/** Checks for `drill_down`. */ +export function validateDrill(raw: CardConfig): void { + if (raw.drill_down !== undefined && typeof raw.drill_down !== 'boolean') { + throw new Error('"drill_down" must be true or false'); + } +} + +/** + * The group the card is showing and the path to it. A path that no longer leads anywhere (the + * group emptied, or the grouping changed) stops at the deepest group that still exists. + */ +export function drilledRoot(tree: TreeNode, path: string[]): { node: TreeNode; path: string[] } { + let node = tree; + const kept: string[] = []; + for (const name of path) { + const next = findNode(node, [name]); + if (!next || next.value <= 0) break; + node = next; + kept.push(name); + } + return { node, path: kept }; +} + +/** The card's current drill path (empty: the whole). */ +export function drillPath(host: HTMLElement): string[] { + return paths.get(host) ?? []; +} + +/** Show the group at `path` (empty: the whole) and redraw. */ +export function drillTo(ctx: ChartContext, path: string[]): void { + paths.set(ctx.host, path); + ctx.requestUpdate(); +} + +/** What a card draws: the group it is drilled into, or the whole tree when drilling is off. */ +export function drillView(ctx: ChartContext, tree: TreeNode): { node: TreeNode; path: string[] } { + if (!drillEnabled(ctx.raw)) return { node: tree, path: [] }; + return drilledRoot(tree, drillPath(ctx.host)); +} + +/** + * Attributes and handlers that make a group tappable: a click or Enter/Space drills into it. + * Undefined when drilling is off, so the group stays a plain shape. + */ +export function drillTarget(ctx: ChartContext, node: TreeNode, label: string) { + if (!drillEnabled(ctx.raw) || node.entity || node.value <= 0) return undefined; + const go = () => drillTo(ctx, node.path); + return { + label: localize(ctx.hass, 'drill_into', { label }), + click: (ev: Event) => { + ev.stopPropagation(); + go(); + }, + keydown: (ev: KeyboardEvent) => { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + go(); + }, + }; +} + +/** + * The row above a drilled-in chart: a back button named after the level above, and the group + * being shown with its total. Nothing at the top level. + */ +export function drillBar( + ctx: ChartContext, + tree: TreeNode, + view: { node: TreeNode; path: string[] }, + total: string, +): TemplateResult | typeof nothing { + if (!view.path.length) return nothing; + const up = view.path.slice(0, -1); + const parent = (up.length && findNode(tree, up)?.name) || localize(ctx.hass, 'drill_top'); + return html`
+ +
+ ${view.node.name} + ${total ? html`${total}` : nothing} +
+
`; +} + +/** Look of the back row and of tappable groups, shared by every chart that drills. */ +export const drillStyles = css` + .drill-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + min-width: 0; + } + .drill-back { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + max-width: 55%; + padding: 4px 12px 4px 6px; + border: none; + border-radius: 16px; + background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, transparent); + color: var(--primary-color, #03a9f4); + font: inherit; + font-size: 0.875em; + font-weight: 500; + cursor: pointer; + } + .drill-back span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .drill-back:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + .drill-current { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + line-height: 1.2; + } + .drill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .drill-total { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .drillable { + cursor: pointer; + outline: none; + } +`; diff --git a/src/styles/sunburst.ts b/src/styles/sunburst.ts index 02a67a1..4c272c2 100644 --- a/src/styles/sunburst.ts +++ b/src/styles/sunburst.ts @@ -4,6 +4,7 @@ import { baseColor, onFillText } from '../bands'; import { SERIES_COLORS, shade } from '../chart/colors'; import { buildTree, groupPath, parseGroupBy, partition, treeHeight, validateGroups } from '../chart/hierarchy'; import type { Span, TreeNode } from '../chart/hierarchy'; +import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '../chart/drill'; import { sectorPath } from '../chart/polar'; import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; @@ -127,7 +128,11 @@ function noReading(ctx: ChartContext): TemplateResult | typeof nothing {
`; } -const FIELDS: FieldBinding[] = [{ name: 'hierarchy_color_by', path: ['color_by'], default: 'group' }, ...GROUP_FIELDS]; +const FIELDS: FieldBinding[] = [ + { name: 'hierarchy_color_by', path: ['color_by'], default: 'group' }, + ...GROUP_FIELDS, + { name: 'drill_down', path: ['drill_down'], default: true }, +]; const SUNBURST_FIELDS: FieldBinding[] = [{ name: 'sunburst_shape', path: ['shape'], default: 'full' }, ...FIELDS]; export function sunburstEditor(ctx: ChartEditorContext): TemplateResult { @@ -141,6 +146,7 @@ export function sunburstEditor(ctx: ChartEditorContext): TemplateResult { ], }, groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, ]); return html`${chartForm(withGroupList(ctx), 'chart', schema, SUNBURST_FIELDS)} ${entityGroupsForm(ctx)}`; } @@ -149,12 +155,14 @@ export function icicleEditor(ctx: ChartEditorContext): TemplateResult { const schema = cachedSchema('icicle', ctx.hass, () => [ { name: 'hierarchy_color_by', selector: choice(ctx.hass, 'hierarchy_color_by', HIERARCHY_COLOR_BY) }, groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, ]); return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; } function validate(raw: CardConfig, shapes: boolean): void { validateGroups(raw); + validateDrill(raw); if (raw.color_by !== undefined && !HIERARCHY_COLOR_BY.includes(raw.color_by as HierarchyColorBy)) { throw new Error(`"color_by" must be one of: ${HIERARCHY_COLOR_BY.join(', ')}`); } @@ -177,14 +185,31 @@ export function sunburstGeometry(shape: SunburstShape, levels: number) { }; } +/** A group's ring segment: tappable to drill into it when drilling is on. */ +function groupArc(ctx: ChartContext, node: TreeNode, label: string, d: string, fill: string) { + const target = drillTarget(ctx, node, label); + return svg`${label}`; +} + /** The drawing for one tree root: a sunburst with the total in its middle, or a message. */ -export function drawSunburst(ctx: ChartContext, root: TreeNode, centre: TemplateResult | typeof nothing = nothing) { +export function drawSunburst(ctx: ChartContext, root: TreeNode, tree: TreeNode = root) { if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; const shape = (ctx.raw.shape as SunburstShape | undefined) ?? 'full'; const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; const unit = sharedUnit(ctx.items); const spans = partition(root); - const painted = paintSpans(spans, colorBy); + // Colours come from the whole tree, so a group keeps its colour when drilled into. + const painted = paintSpans(partition(tree), colorBy); const geo = sunburstGeometry(shape, treeHeight(root)); const arcs = spans .filter((s) => s.level > 0) @@ -196,8 +221,7 @@ export function drawSunburst(ctx: ChartContext, root: TreeNode, centre: Template return s.node.entity ? svg`${label}` - : svg`${label}`; + : groupArc(ctx, s.node, label, d, fill); }); const total = unit !== undefined && ctx.config.show.value ? totalText(root.value, ctx, unit) : ''; const names = root.children.filter((c) => c.value > 0).map((c) => c.name); @@ -211,18 +235,19 @@ export function drawSunburst(ctx: ChartContext, root: TreeNode, centre: Template > ${arcs} -
- ${centre} ${total ? html`${total}` : nothing} - ${root.path.length ? html`${root.name}` : nothing} -
+
${total ? html`${total}` : nothing}
`; } /** Top-level names and totals under a sunburst, so the colours mean something. */ -export function sunburstLegend(ctx: ChartContext, root: TreeNode): TemplateResult | typeof nothing { +export function sunburstLegend( + ctx: ChartContext, + root: TreeNode, + tree: TreeNode = root, +): TemplateResult | typeof nothing { const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; const unit = sharedUnit(ctx.items); - const painted = paintSpans(partition(root), colorBy); + const painted = paintSpans(partition(tree), colorBy); const top = root.children.filter((c) => c.value > 0); return html`
${top.map((node) => { @@ -235,18 +260,39 @@ export function sunburstLegend(ctx: ChartContext, root: TreeNode): TemplateResul ? html`${swatch}${node.name} ${value}` - : html`${swatch}${node.name} ${value}`; + : legendGroup(ctx, node, swatch, value, unit); })}
`; } +/** A group in the legend: a button that drills into it, or plain text when drilling is off. */ +function legendGroup( + ctx: ChartContext, + node: TreeNode, + swatch: TemplateResult, + value: TemplateResult | typeof nothing, + unit: string | undefined, +) { + const target = drillTarget(ctx, node, nodeLabel(node, ctx, unit)); + return html`${swatch}${node.name} ${value}`; +} + /** The drawing for one tree root as an icicle: a column per level, the root on the left. */ -export function drawIcicle(ctx: ChartContext, root: TreeNode) { +export function drawIcicle(ctx: ChartContext, root: TreeNode, tree: TreeNode = root) { if (root.value <= 0) return html`
${localize(ctx.hass, 'hierarchy_empty')}
`; const colorBy = (ctx.raw.color_by as HierarchyColorBy | undefined) ?? 'group'; const unit = sharedUnit(ctx.items); const spans = partition(root); - const painted = paintSpans(spans, colorBy); + const painted = paintSpans(partition(tree), colorBy); const columns = treeHeight(root) + 1; const { show } = ctx.config; const names = root.children.filter((c) => c.value > 0).map((c) => c.name); @@ -282,14 +328,38 @@ export function drawIcicle(ctx: ChartContext, root: TreeNode) { > ${inner}
` - : html`
- ${inner} -
`; + : icicleGroup(ctx, s.node, cls, label, style, inner); })} `; } +/** A group's cell: tappable to drill into it (not the left-hand total, which is where you are). */ +function icicleGroup( + ctx: ChartContext, + node: TreeNode, + cls: string, + label: string, + style: string, + inner: TemplateResult, +) { + const target = cls.includes('text-root') ? undefined : drillTarget(ctx, node, label); + return html`
+ ${inner} +
`; +} + const sharedStyles = css` + ${drillStyles} .hierarchy-message { display: flex; align-items: center; @@ -408,14 +478,6 @@ export const sunburst: ChartStyleModule = { font-weight: 500; white-space: nowrap; } - .centre-name { - font-size: 0.75em; - color: var(--secondary-text-color); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 72px; - } `, thumbnail: () => svg` @@ -427,8 +489,10 @@ export const sunburst: ChartStyleModule = { validate: (raw) => validate(raw, true), editor: sunburstEditor, render(ctx) { - const root = hierarchyTree(ctx); - return html`${drawSunburst(ctx, root)} ${root.value > 0 ? sunburstLegend(ctx, root) : nothing} ${noReading(ctx)}`; + const tree = hierarchyTree(ctx); + const view = drillView(ctx, tree); + return html`${drillBar(ctx, tree, view, '')} ${drawSunburst(ctx, view.node, tree)} + ${view.node.value > 0 ? sunburstLegend(ctx, view.node, tree) : nothing} ${noReading(ctx)}`; }, }; @@ -515,7 +579,8 @@ export const icicle: ChartStyleModule = { validate: (raw) => validate(raw, false), editor: icicleEditor, render(ctx) { - const root = hierarchyTree(ctx); - return html`${drawIcicle(ctx, root)} ${noReading(ctx)}`; + const tree = hierarchyTree(ctx); + const view = drillView(ctx, tree); + return html`${drillBar(ctx, tree, view, '')} ${drawIcicle(ctx, view.node, tree)} ${noReading(ctx)}`; }, }; diff --git a/src/styles/treemap.ts b/src/styles/treemap.ts index 9daee5a..b99ed03 100644 --- a/src/styles/treemap.ts +++ b/src/styles/treemap.ts @@ -4,6 +4,7 @@ import { baseColor, onFillText } from '../bands'; import { SERIES_COLORS, shade } from '../chart/colors'; import { buildTree, groupPath, parseGroupBy, validateGroups } from '../chart/hierarchy'; import type { TreeNode } from '../chart/hierarchy'; +import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '../chart/drill'; import { inset, squarify } from '../chart/treemap-layout'; import type { Rect } from '../chart/treemap-layout'; import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; @@ -54,12 +55,14 @@ export interface TreemapBox { /** * Lay out a tree in a `width` × `height` box: each group gets a rectangle sized by its total * (with a name strip on top when there is room), and its entities share what is left of it. - * Nodes with nothing to show are left out. + * Nodes with nothing to show are left out. Each top-level node takes the next series colour, or + * all of them `family` (drilled into a group, its parts keep its colour). */ export function treemapLayout( root: TreeNode, width: number, height: number, + family?: number, ): { groups: TreemapBox[]; leaves: TreemapBox[] } { const groups: TreemapBox[] = []; const leaves: TreemapBox[] = []; @@ -71,7 +74,7 @@ export function treemapLayout( ); shown.forEach((child, i) => { const r = rects[i]; - const s = top ? i : series; + const s = top && family === undefined ? i : series; if (child.entity) { leaves.push({ node: child, rect: inset(r, GAP), series: s, rank: i }); return; @@ -83,7 +86,7 @@ export function treemapLayout( place(child, inner, s, false); }); }; - place(root, { x: 0, y: 0, w: width, h: height }, 0, true); + place(root, { x: 0, y: 0, w: width, h: height }, family ?? 0, true); return { groups, leaves }; } @@ -178,9 +181,16 @@ function leaf( function group(box: TreemapBox, ctx: ChartContext, width: number, colorBy: TreemapColorBy, unit: string | undefined) { const color = baseColor(SERIES_COLORS[box.series % SERIES_COLORS.length]); const total = unit === undefined ? '' : totalText(box.node.value, ctx, unit); + const target = drillTarget(ctx, box.node, total ? `${box.node.name}, ${total}` : box.node.name); return html`
${ box.head @@ -193,12 +203,17 @@ function group(box: TreemapBox, ctx: ChartContext, width: number, colorBy: Treem
`; } -const FIELDS: FieldBinding[] = [{ name: 'treemap_color_by', path: ['color_by'], default: 'group' }, ...GROUP_FIELDS]; +const FIELDS: FieldBinding[] = [ + { name: 'treemap_color_by', path: ['color_by'], default: 'group' }, + ...GROUP_FIELDS, + { name: 'drill_down', path: ['drill_down'], default: true }, +]; export function treemapEditor(ctx: ChartEditorContext): TemplateResult { const schema = cachedSchema('treemap', ctx.hass, () => [ { name: 'treemap_color_by', selector: choice(ctx.hass, 'treemap_color_by', TREEMAP_COLOR_BY) }, groupBySchema(ctx.hass), + { name: 'drill_down', selector: { boolean: {} } }, ]); return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; } @@ -216,6 +231,7 @@ export const treemap: ChartStyleModule = { group: 'totals', height: 280, styles: css` + ${drillStyles} .treemap-head { display: flex; justify-content: flex-end; @@ -341,6 +357,7 @@ export const treemap: ChartStyleModule = { `, validate(raw: CardConfig) { validateGroups(raw); + validateDrill(raw); if (raw.color_by !== undefined && !TREEMAP_COLOR_BY.includes(raw.color_by as TreemapColorBy)) { throw new Error(`"color_by" must be one of: ${TREEMAP_COLOR_BY.join(', ')}`); } @@ -349,17 +366,19 @@ export const treemap: ChartStyleModule = { disconnect: forget, render(ctx) { const width = measure(ctx); - const root = treeFor(ctx); + const tree = treeFor(ctx); + const view = drillView(ctx, tree); + const root = view.node; const colorBy = (ctx.raw.color_by as TreemapColorBy | undefined) ?? 'group'; const unit = sharedUnit(ctx.items); - const { groups, leaves } = treemapLayout(root, width, HEIGHT); + // Drilled in, the parts keep the colour their top-level group has on the whole map. + const family = view.path.length + ? tree.children.filter((c) => c.value > 0).findIndex((c) => c.name === view.path[0]) + : undefined; + const { groups, leaves } = treemapLayout(root, width, HEIGHT, family); const noReading = ctx.items.map((e, index) => ({ e, index })).filter(({ e }) => !e.available); - const total = - unit !== undefined && ctx.config.show.value && root.value > 0 - ? localize(ctx.hass, 'treemap_total', { - value: totalText(root.value, ctx, unit), - }) - : ''; + const sum = unit !== undefined && ctx.config.show.value && root.value > 0 ? totalText(root.value, ctx, unit) : ''; + const total = sum && !view.path.length ? localize(ctx.hass, 'treemap_total', { value: sum }) : ''; const label = localize(ctx.hass, 'treemap_aria', { names: (root.children.length ? root.children : []).map((c) => c.name).join(', '), }); @@ -369,8 +388,8 @@ export const treemap: ChartStyleModule = { ${leaves.map((l) => leaf(l, ctx, width, colorBy, root.value))} ` : html`
${localize(ctx.hass, 'treemap_empty')}
`; - return html`${total ? html`
${total}
` : nothing} - ${body} + return html`${drillBar(ctx, tree, view, sum)} + ${total ? html`
${total}
` : nothing} ${body} ${ noReading.length ? html`
diff --git a/test/drill.test.ts b/test/drill.test.ts new file mode 100644 index 0000000..bfe04ca --- /dev/null +++ b/test/drill.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { drillEnabled, drilledRoot, validateDrill } from '../src/chart/drill'; +import { buildTree } from '../src/chart/hierarchy'; +import { normalizeConfig } from '../src/config'; +import { resolveEntities } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(): HomeAssistant { + return makeHass({ + 'sensor.oven': ['1', { friendly_name: 'Oven', unit_of_measurement: 'kW' }], + 'sensor.hob': ['0.8', { friendly_name: 'Hob', unit_of_measurement: 'kW' }], + 'sensor.tv': ['0.5', { friendly_name: 'TV', unit_of_measurement: 'kW' }], + 'sensor.desk': ['0.4', { friendly_name: 'Desk', unit_of_measurement: 'kW' }], + 'sensor.ev': ['3', { friendly_name: 'EV', unit_of_measurement: 'kW' }], + }); +} + +const ENTITIES = [ + { entity: 'sensor.oven', group: 'Ground / Kitchen' }, + { entity: 'sensor.hob', group: 'Ground / Kitchen' }, + { entity: 'sensor.tv', group: 'Ground / Lounge' }, + { entity: 'sensor.desk', group: 'First / Office' }, + { entity: 'sensor.ev', group: 'Outside' }, +]; + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', entities: ENTITIES, ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +async function click(card: Card, sel: string) { + q(card, sel)!.dispatchEvent(new MouseEvent('click', { bubbles: true, composed: true })); + await card.updateComplete; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('drill path', () => { + const tree = () => { + const items = resolveEntities(normalizeConfig({ type: 'x', entities: ENTITIES } as CardConfig), hass()); + return buildTree( + items, + items.map((e) => String((e.config as { group?: string }).group).split(' / ')), + 'All', + ); + }; + + it('follows a path down, and stops at the deepest group that still exists', () => { + const t = tree(); + expect(drilledRoot(t, []).node).toBe(t); + const kitchen = drilledRoot(t, ['Ground', 'Kitchen']); + expect(kitchen.node.name).toBe('Kitchen'); + expect(kitchen.path).toEqual(['Ground', 'Kitchen']); + expect(drilledRoot(t, ['Ground', 'Attic'])).toMatchObject({ path: ['Ground'] }); + expect(drilledRoot(t, ['Nowhere', 'Kitchen'])).toMatchObject({ path: [] }); + }); + + it('is on unless turned off, and checks the option', () => { + expect(drillEnabled({ type: 'x' })).toBe(true); + expect(drillEnabled({ type: 'x', drill_down: false })).toBe(false); + expect(() => validateDrill({ type: 'x', drill_down: 'yes' } as unknown as CardConfig)).toThrow(/drill_down/); + expect(() => validateDrill({ type: 'x', drill_down: true })).not.toThrow(); + }); +}); + +describe('treemap drill-down', () => { + it('shows a tapped group as the whole, keeps its colour, and goes back', async () => { + const card = await mount({ style: 'treemap' }); + expect(q(card, '.drill-bar')).toBeNull(); + const ground = q(card, '.group[data-path="Ground"]')!; + expect(ground.getAttribute('role')).toBe('button'); + expect(ground.getAttribute('aria-label')).toBe('Ground, 2.3 kW. Show what makes it up'); + // Ground is the second top-level group (EV is bigger), so its colour is the second one. + expect(q(card, '.tile[data-index="0"]')!.style.background).toContain('--warning-color'); + await click(card, '.group[data-path="Ground"]'); + expect(qa(card, '.tile').map((t) => t.dataset.index)).toEqual(['0', '1', '2']); + expect(q(card, '.tile[data-index="0"]')!.style.background).toContain('--warning-color'); + expect(qa(card, '.group-name').map((g) => g.textContent)).toEqual(['Kitchen', 'Lounge']); + expect(text(q(card, '.drill-current'))).toBe('Ground 2.3 kW'); + expect(q(card, '.treemap-total')).toBeNull(); + const back = q(card, '.drill-back')!; + expect(back.getAttribute('aria-label')).toBe('Back to All'); + await click(card, '.group[data-path="Ground/Kitchen"]'); + expect(qa(card, '.tile').map((t) => t.dataset.index)).toEqual(['0', '1']); + expect(q(card, '.drill-back')!.getAttribute('aria-label')).toBe('Back to Ground'); + await click(card, '.drill-back'); + expect(text(q(card, '.drill-current'))).toBe('Ground 2.3 kW'); + await click(card, '.drill-back'); + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, '.tile')).toHaveLength(5); + }); + + it('drills with the keyboard, and keeps tile taps for the entity', async () => { + const card = await mount({ style: 'treemap' }); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + q(card, '.group[data-path="First"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })); + await card.updateComplete; + expect(q(card, '.drill-bar')).toBeNull(); + q(card, '.group[data-path="First"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await card.updateComplete; + expect(text(q(card, '.drill-current'))).toBe('First 0.4 kW'); + const tile = q(card, '.tile[data-index="3"]')!; + tile.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + tile.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.desk']); + expect(text(q(card, '.drill-current'))).toBe('First 0.4 kW'); + }); + + it('falls back to the top when the group it was in goes away', async () => { + const card = await mount({ style: 'treemap' }); + await click(card, '.group[data-path="First"]'); + card.setConfig({ type: 'custom:chartdeck-card', style: 'treemap', entities: ENTITIES.slice(0, 3) } as CardConfig); + await card.updateComplete; + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, '.tile')).toHaveLength(3); + }); + + it('leaves groups plain when drilling is off', async () => { + const card = await mount({ style: 'treemap', drill_down: false } as Partial); + const ground = q(card, '.group[data-path="Ground"]')!; + expect(ground.getAttribute('role')).toBeNull(); + expect(ground.classList.contains('drillable')).toBe(false); + await click(card, '.group[data-path="Ground"]'); + expect(q(card, '.drill-bar')).toBeNull(); + }); +}); + +describe('sunburst and icicle drill-down', () => { + it('drills into a ring segment or a legend item, keeping colours, and goes back', async () => { + const card = await mount({ style: 'sunburst' }); + const before = q(card, 'path.leaf[data-index="0"]')!.getAttribute('fill'); + expect(q(card, 'path.group[data-path="Ground"]')!.getAttribute('aria-label')).toBe( + 'Ground, 2.3 kW. Show what makes it up', + ); + await click(card, 'path.group[data-path="Ground"]'); + expect(qa(card, 'path.leaf').map((p) => p.dataset.index)).toEqual(['0', '1', '2']); + expect(q(card, 'path.leaf[data-index="0"]')!.getAttribute('fill')).toBe(before); + expect(text(q(card, '.centre-value'))).toBe('2.3 kW'); + expect(text(q(card, '.drill-current'))).toBe('Ground'); + expect(qa(card, '.hierarchy-legend .legend-item').map(text)).toEqual(['Kitchen 1.8 kW', 'Lounge 0.5 kW']); + await click(card, '.hierarchy-legend .legend-item[data-path="Ground/Kitchen"]'); + expect(qa(card, 'path.leaf').map((p) => p.dataset.index)).toEqual(['0', '1']); + await click(card, '.drill-back'); + await click(card, '.drill-back'); + expect(q(card, '.drill-bar')).toBeNull(); + expect(qa(card, 'path.leaf')).toHaveLength(5); + }); + + it('drills into an icicle cell, but not the total on the left', async () => { + const card = await mount({ style: 'icicle' }); + expect(q(card, '.cell.text-root')!.getAttribute('role')).toBeNull(); + await click(card, '.cell.text-root'); + expect(q(card, '.drill-bar')).toBeNull(); + await click(card, '.cell.group[data-path="Ground"]'); + expect(text(q(card, '.cell.text-root'))).toBe('Ground 2.3 kW'); + expect(qa(card, '.cell.leaf').map((c) => c.dataset.index)).toEqual(['0', '1', '2']); + }); + + it('leaves arcs and cells plain when drilling is off', async () => { + const card = await mount({ style: 'sunburst', drill_down: false } as Partial); + expect(q(card, 'path.group')!.getAttribute('role')).toBeNull(); + expect(q(card, '.hierarchy-legend .legend-item')!.getAttribute('role')).toBeNull(); + const icicle = await mount({ style: 'icicle', drill_down: false } as Partial); + expect(q(icicle, '.cell.group[data-path="Ground"]')!.getAttribute('role')).toBeNull(); + }); + + it('checks the option', () => { + const card = document.createElement('chartdeck-card') as Card; + for (const style of ['treemap', 'sunburst', 'icicle']) { + expect(() => card.setConfig({ type: 'x', style, drill_down: 'no' } as unknown as CardConfig)).toThrow( + /drill_down/, + ); + } + }); +}); diff --git a/test/sunburst.test.ts b/test/sunburst.test.ts index 712196a..0746935 100644 --- a/test/sunburst.test.ts +++ b/test/sunburst.test.ts @@ -251,10 +251,12 @@ describe('sunburst and icicle editors', () => { it('writes the shape, colour and group levels for a sunburst', async () => { const raw = { type: 'custom:chartdeck-card', style: 'sunburst', entities: ['sensor.ev'] } as CardConfig; const { events, form, ed } = await editor(raw); - expect(form.data).toEqual({ sunburst_shape: 'full', hierarchy_color_by: 'group', group_by: [] }); + expect(form.data).toEqual({ sunburst_shape: 'full', hierarchy_color_by: 'group', group_by: [], drill_down: true }); form.dispatchEvent( new CustomEvent('value-changed', { - detail: { value: { sunburst_shape: 'half', hierarchy_color_by: 'bands', group_by: ['floor', 'area'] } }, + detail: { + value: { sunburst_shape: 'half', hierarchy_color_by: 'bands', group_by: ['floor', 'area'], drill_down: true }, + }, }), ); expect(events.at(-1)).toEqual({ ...raw, shape: 'half', color_by: 'bands', group_by: ['floor', 'area'] }); @@ -264,10 +266,12 @@ describe('sunburst and icicle editors', () => { it('writes the colour and group levels for an icicle, without a shape', async () => { const raw = { type: 'custom:chartdeck-card', style: 'icicle', entities: ['sensor.ev'] } as CardConfig; const { events, form } = await editor(raw); - expect(form.data).toEqual({ hierarchy_color_by: 'group', group_by: [] }); + expect(form.data).toEqual({ hierarchy_color_by: 'group', group_by: [], drill_down: true }); form.dispatchEvent( - new CustomEvent('value-changed', { detail: { value: { hierarchy_color_by: 'group', group_by: ['label'] } } }), + new CustomEvent('value-changed', { + detail: { value: { hierarchy_color_by: 'group', group_by: ['label'], drill_down: false } }, + }), ); - expect(events.at(-1)).toEqual({ ...raw, group_by: ['label'] }); + expect(events.at(-1)).toEqual({ ...raw, group_by: ['label'], drill_down: false }); }); }); diff --git a/test/treemap.test.ts b/test/treemap.test.ts index b04d629..63002d8 100644 --- a/test/treemap.test.ts +++ b/test/treemap.test.ts @@ -247,13 +247,13 @@ describe('treemap editor', () => { } as CardConfig; const { ed, events } = await editor(raw); const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; - expect(form.data).toEqual({ treemap_color_by: 'group', group_by: ['area'] }); + expect(form.data).toEqual({ treemap_color_by: 'group', group_by: ['area'], drill_down: true }); form.dispatchEvent( new CustomEvent('value-changed', { - detail: { value: { treemap_color_by: 'bands', group_by: ['area', 'floor'] } }, + detail: { value: { treemap_color_by: 'bands', group_by: ['area', 'floor'], drill_down: false } }, }), ); - expect(events.at(-1)).toEqual({ ...raw, color_by: 'bands', group_by: ['area', 'floor'] }); + expect(events.at(-1)).toEqual({ ...raw, color_by: 'bands', group_by: ['area', 'floor'], drill_down: false }); }); it('sets and clears each listed entity’s own group', async () => { From dc516a6b85953667e78ed97cb6db9519e2c9bb9c Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 14:54:08 +0000 Subject: [PATCH 08/41] fix: rings and pie accept their own center option again with the gauge options in The gauge options (start_angle, end_angle, center, center_text, tick_labels) were checked on every card, so center: total on a rings or pie card was rejected as not a gauge centre mode. They are now only read for the ring, arc and needle styles. --- src/config.ts | 6 +++--- test/rings.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index 417037f..5442177 100644 --- a/src/config.ts +++ b/src/config.ts @@ -123,9 +123,9 @@ export function mergeEntity(raw: string | EntityConfig, card: EntityOptions, ind if (target !== undefined) merged.target = target; if (decimals !== undefined) merged.decimals = decimals; if (!own.attribute) delete merged.attribute; - // Gauge options are the card's alone: every gauge on a card is drawn the same way. - const gauge = parseGaugeOptions(card as CardConfig); - if (DEFAULT_ANGLES[(card as CardConfig).style ?? 'bar']) merged.gauge = gauge; + // Gauge options are the card's alone: every gauge on a card is drawn the same way. Only the + // gauge styles read them: other styles (rings, pie…) give center and the angles their own meaning. + if (DEFAULT_ANGLES[(card as CardConfig).style ?? 'bar']) merged.gauge = parseGaugeOptions(card as CardConfig); return merged; } diff --git a/test/rings.test.ts b/test/rings.test.ts index f58f096..1f962e2 100644 --- a/test/rings.test.ts +++ b/test/rings.test.ts @@ -193,3 +193,17 @@ describe('rings editor', () => { expect((changed.mock.calls[0][0] as CustomEvent).detail.config.end_angle).toBe(360); }); }); + +describe('rings and pie keep their own center option', () => { + it('is not checked against the gauge centre modes', async () => { + const { normalizeConfig } = await import('../src/config'); + for (const style of ['rings', 'pie'] as const) { + expect(() => + normalizeConfig({ type: 'x', style, center: 'total', entities: ['sensor.a'] } as never), + ).not.toThrow(); + } + expect(() => + normalizeConfig({ type: 'x', style: 'ring', center: 'total', entities: ['sensor.a'] } as never), + ).toThrow(/center/); + }); +}); From 7fb044bad19ba0703b872ce307f9b3bcfbd73ce2 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:33:49 +0000 Subject: [PATCH 09/41] feat: compare values in grouped, stacked and 100 % stacked bars A new chart style, "Grouped bars" (`style: grouped_bars`, under Comparisons in the style picker), draws horizontal bars for comparing categories with several values each, such as energy per room split by device, or today against yesterday per sensor. Each entity is one bar. An entity's "Row" (`group`) puts it in a row with the other entities of the same row; one without a row gets its own, under its name. Its "Series" (`series`) names what it is (Today, Heating...): series share a colour across rows and are listed in a legend under the chart. "Bar colour" (`color`) overrides the series colour. These three are in each entity's own options in the editor, through a new registry hook, `entityFields`, that lets a chart style add per-entity fields there. The Chart section sets `mode`: side by side (default), stacked, or stacked to 100 % of the row, and `color_by`: one colour per series, or each bar in its current colour band. All rows share one scale, taken from the data and always including zero. Negative values run left of a zero line; in a stack, positives run right and negatives left. Stacked rows show their total, 100 % rows show each segment's share inside it when there is room, grouped rows label each bar. The card's sort orders rows by their total and rows slide to their new place as values change (no motion with reduced motion or animate: false). Bars keep their colours and order within a row whatever the sort. Unavailable and missing entities draw no bar but keep their row and say why. Every bar is tappable with the entity's actions. Shared pieces for the comparison charts live in src/chart/compare.ts (scale maths) and src/chart/compare-form.ts (the editor helpers the line chart keeps in chart-editors.ts, which this branch does not have yet). Closes #24 --- src/chart/compare-form.ts | 57 +++++ src/chart/compare.ts | 94 +++++++ src/styles/grouped-bars.ts | 508 +++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/compare.test.ts | 76 ++++++ test/grouped-bars.test.ts | 288 +++++++++++++++++++++ 7 files changed, 1027 insertions(+), 2 deletions(-) create mode 100644 src/chart/compare-form.ts create mode 100644 src/chart/compare.ts create mode 100644 src/styles/grouped-bars.ts create mode 100644 test/compare.test.ts create mode 100644 test/grouped-bars.test.ts diff --git a/src/chart/compare-form.ts b/src/chart/compare-form.ts new file mode 100644 index 0000000..9c17968 --- /dev/null +++ b/src/chart/compare-form.ts @@ -0,0 +1,57 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { applyFormData, formData } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { languageOf, localize } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { HomeAssistant } from '../types'; + +/* + * Editor helpers for the comparison charts. They match `choice`, `cachedSchema` and `chartForm` + * in the line chart's `src/editor/chart-editors.ts`, which these branches don't have yet; once both + * are on main, imports can move there and this file can go. + */ + +/** A dropdown of fixed values, labelled from `editor__`. */ +export function choice(hass: HomeAssistant | undefined, prefix: string, values: readonly string[]) { + return { + select: { + mode: 'dropdown', + options: values.map((value) => ({ value, label: localize(hass, `editor_${prefix}_${value}`) })), + }, + }; +} + +const cache = new Map(); + +/** Build a schema once per language (and per id), so ha-form isn't handed a new one each render. */ +export function cachedSchema(id: string, hass: HomeAssistant | undefined, build: () => unknown[]): unknown[] { + const k = `${id}|${languageOf(hass)}`; + let schema = cache.get(k); + if (!schema) { + schema = build(); + cache.set(k, schema); + } + return schema; +} + +/** An ha-form bound to card-config paths; a value set back to its default removes the key. */ +export function chartForm( + ctx: ChartEditorContext, + section: string, + schema: unknown[], + fields: FieldBinding[], +): TemplateResult { + return html` localize(ctx.hass, `editor_${s.name}`)} + @value-changed=${(ev: CustomEvent) => { + ev.stopPropagation(); + const next = applyFormData(ctx.config, fields, (ev.detail?.value ?? {}) as Record); + if (next !== ctx.config) ctx.commit(next); + }} + >`; +} diff --git a/src/chart/compare.ts b/src/chart/compare.ts new file mode 100644 index 0000000..72595a6 --- /dev/null +++ b/src/chart/compare.ts @@ -0,0 +1,94 @@ +/** + * Maths shared by the comparison charts (grouped bars, bullet, lollipop, dumbbell, slope). Pure + * functions only, so each chart can carry this file without pulling in the others. + */ + +/** A reading at a time; `v: null` is a gap. Matches the history store's points. */ +export interface TimedValue { + t: number; + v: number | null; + min?: number | null; + max?: number | null; +} + +/** A step of 1, 2, 2.5 or 5 times a power of ten, near `span / count`. */ +export function niceStep(span: number, count = 5): number { + if (!Number.isFinite(span) || span <= 0 || count < 1) return 1; + const raw = span / count; + const power = 10 ** Math.floor(Math.log10(raw)); + const fraction = raw / power; + const nice = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 2.5 ? 2.5 : fraction <= 5 ? 5 : 10; + return nice * power; +} + +/** + * Round tick values covering `lo`..`hi`, about `count` of them, on a 1/2/2.5/5 step. The first and + * last are on or outside the data, so the axis encloses it. Equal ends get a span of 1 around them. + */ +export function niceTicks(lo: number, hi: number, count = 5): number[] { + if (!Number.isFinite(lo) || !Number.isFinite(hi)) return []; + if (lo > hi) [lo, hi] = [hi, lo]; + if (lo === hi) { + lo -= 0.5; + hi += 0.5; + } + const step = niceStep(hi - lo, count); + const first = Math.floor(lo / step + 1e-9) * step; + const last = Math.ceil(hi / step - 1e-9) * step; + const ticks: number[] = []; + for (let i = 0, v = first; v <= last + step / 2 && i < 100; i++, v = first + i * step) { + // Clear floating-point dust (0.30000000000000004) so labels format cleanly. + ticks.push(Number(v.toPrecision(12))); + } + return ticks; +} + +/** Lowest and highest of some numbers, skipping null and non-finite ones; undefined when none. */ +export function extentOf(values: (number | null | undefined)[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const v of values) { + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** A domain that always includes zero, for bars that grow from a zero line. */ +export function withZero([lo, hi]: [number, number]): [number, number] { + return [Math.min(0, lo), Math.max(0, hi)]; +} + +/** + * The reading in force at time `t`: the last non-gap reading at or before it. Before the first + * reading, or when a gap is in force at `t`, null. Points are oldest first. + */ +export function readingAt(points: TimedValue[], t: number): number | null { + let found: number | null = null; + for (const p of points) { + if (p.t > t) break; + found = p.v; + } + return found; +} + +/** The lowest and highest reading in some points (statistics' min and max count too). */ +export function lowHigh(points: TimedValue[]): [number, number] | undefined { + return extentOf(points.flatMap((p) => [p.v, p.min, p.max])); +} + +/** + * Change from `from` to `to` as a fraction of `from` (0.12 is +12 %). Undefined when either is + * missing or `from` is zero, where a percentage means nothing. + */ +export function relativeChange(from: number | null, to: number | null): number | undefined { + if (from === null || to === null || from === 0) return undefined; + return (to - from) / Math.abs(from); +} + +/** Which way a value moved: 'up', 'down' or 'flat' (within `epsilon`). */ +export function direction(from: number, to: number, epsilon = 1e-9): 'up' | 'down' | 'flat' { + if (Math.abs(to - from) <= epsilon) return 'flat'; + return to > from ? 'up' : 'down'; +} diff --git a/src/styles/grouped-bars.ts b/src/styles/grouped-bars.ts new file mode 100644 index 0000000..981d531 --- /dev/null +++ b/src/styles/grouped-bars.ts @@ -0,0 +1,508 @@ +import { css, html, nothing, svg } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { baseColor, onFillText } from '../bands'; +import { extentOf, withZero } from '../chart/compare'; +import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant, SortMode } from '../types'; + +export const MODES = ['grouped', 'stacked', 'percent'] as const; +export type Mode = (typeof MODES)[number]; +const COLOR_BY = ['series', 'bands'] as const; + +/** Series colours in order, for entities without their own `color` (the line chart's order). */ +export const BAR_COLORS = ['primary', 'amber', 'green', 'purple', 'red', '#14b8a6', '#ec4899', 'grey']; + +/** Heights in px: one bar in grouped mode, the gap between a row's bars, a stacked bar, row padding. */ +const BAR_H = 12; +const BAR_GAP = 3; +const STACK_H = 20; +const ROW_PAD = 8; +/** Percent mode labels a segment only when it is at least this share of the width. */ +const MIN_LABEL_SHARE = 0.12; + +registerStrings('en', { + editor_style_grouped_bars: 'Grouped bars', + editor_mode: 'Bars', + editor_mode_grouped: 'Side by side', + editor_mode_stacked: 'Stacked', + editor_mode_percent: 'Stacked to 100 %', + editor_bars_color_by: 'Colour', + editor_bars_color_by_series: 'One colour per series', + editor_bars_color_by_bands: 'By colour band', + editor_bar_group: 'Row', + editor_bar_series: 'Series', + editor_bar_color: 'Bar colour', + grouped_bars_aria: 'Bar chart of {names}', +}); + +/** One entity as the chart sees it, in config order. */ +export interface BarInput { + /** Index into the card's items, for actions. */ + index: number; + group: string; + series: string; + value: number | null; +} + +/** One drawn bar or stack segment, in value units (percent mode: share of the row, -1..1). */ +export interface Segment { + index: number; + series: string; + value: number | null; + from: number; + to: number; + /** Grouped mode: which bar line in the row (0 = top). */ + slot: number; +} + +export interface BarRow { + key: string; + label: string; + segments: Segment[]; + /** Sum of the row's values, ignoring missing ones; null when none has a value. */ + total: number | null; +} + +export interface BarLayout { + rows: BarRow[]; + domain: [number, number]; + /** Series names in order of first appearance. */ + series: string[]; +} + +/** + * Rows from entities: entities sharing a group share a row, in the order they first appear. Bars + * run from zero; stacks put positives right of zero and negatives left, each in config order; + * percent mode divides by the row's absolute total. Rows are then sorted by total, if asked. + */ +export function layoutBars(inputs: BarInput[], mode: Mode, sort: SortMode = 'none'): BarLayout { + const rows = new Map(); + const series: string[] = []; + for (const input of inputs) { + let row = rows.get(input.group); + if (!row) { + row = { key: input.group, label: input.group, segments: [], total: null }; + rows.set(input.group, row); + } + if (!series.includes(input.series)) series.push(input.series); + const slot = row.segments.length; + row.segments.push({ index: input.index, series: input.series, value: input.value, from: 0, to: 0, slot }); + if (input.value !== null) row.total = (row.total ?? 0) + input.value; + } + const list = [...rows.values()]; + for (const row of list) { + const absolute = row.segments.reduce((sum, s) => sum + Math.abs(s.value ?? 0), 0); + let up = 0; + let down = 0; + for (const s of row.segments) { + if (s.value === null) continue; + const v = mode === 'percent' ? (absolute === 0 ? 0 : s.value / absolute) : s.value; + if (mode === 'grouped') { + s.to = v; + } else if (v >= 0) { + s.from = up; + s.to = up += v; + } else { + s.from = down; + s.to = down += v; + } + } + } + const ends = list.flatMap((r) => r.segments.flatMap((s) => (s.value === null ? [] : [s.from, s.to]))); + const domain = withZero(extentOf(ends) ?? [0, 1]); + if (domain[0] === domain[1]) domain[1] = 1; + if (sort !== 'none') { + const dir = sort === 'ascending' ? 1 : -1; + list.sort((a, b) => { + if ((a.total === null) !== (b.total === null)) return a.total === null ? 1 : -1; + return ((a.total ?? 0) - (b.total ?? 0)) * dir; + }); + } + return { rows: list, domain, series }; +} + +/** Height of one row in px: grouped rows grow with their bars. */ +export function rowHeight(row: BarRow, mode: Mode): number { + if (mode !== 'grouped') return STACK_H + 2 * ROW_PAD; + const n = Math.max(1, row.segments.length); + return n * BAR_H + (n - 1) * BAR_GAP + 2 * ROW_PAD; +} + +/** The text an option holds, or undefined when blank or not text. */ +function text(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' + ? value.trim() + : typeof value === 'number' + ? String(value) + : undefined; +} + +/** Items back in the order the card lists them, so rows and series don't move when one sorts. */ +function configOrder(ctx: ChartContext): { e: ResolvedEntity; index: number }[] { + const listed = ctx.config.entities; + return ctx.items + .map((e, index) => ({ e, index, at: listed.indexOf(e.config) })) + .sort((a, b) => (a.at === -1 ? Infinity : a.at) - (b.at === -1 ? Infinity : b.at) || a.index - b.index); +} + +function inputsFor(ctx: ChartContext): BarInput[] { + return configOrder(ctx).map(({ e, index }) => { + const own = e.config as typeof e.config & { group?: unknown; series?: unknown }; + return { + index, + // An entity without a row gets its own, keyed by position so a repeated id stays apart. + group: text(own.group) ?? `\u0000${index}`, + series: text(own.series) ?? '', + value: e.available ? e.value : null, + }; + }); +} + +function percent(share: number, hass: HomeAssistant): string { + return `${formatNumber(Math.abs(share) * 100, 0, hass)} %`; +} + +const pct = (v: number) => `${(v * 100).toFixed(3)}%`; + +export function validateBars(raw: CardConfig): void { + if (raw.mode !== undefined && !MODES.includes(raw.mode as Mode)) { + throw new Error(`"mode" must be one of: ${MODES.join(', ')}`); + } + if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { + throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); + } + (raw.entities ?? []).forEach((item, i) => { + if (typeof item !== 'object' || item === null) return; + for (const key of ['group', 'series'] as const) { + const v = (item as unknown as Record)[key]; + if (v !== undefined && v !== null && typeof v !== 'string' && typeof v !== 'number') { + throw new Error(`Entity ${i + 1}: "${key}" must be text`); + } + } + }); +} + +const BAR_FIELDS: FieldBinding[] = [ + { name: 'mode', path: ['mode'], default: 'grouped' }, + { name: 'bars_color_by', path: ['color_by'], default: 'series' }, +]; + +export function barsEditor(ctx: ChartEditorContext) { + const schema = cachedSchema('grouped_bars', ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'mode', selector: choice(ctx.hass, 'mode', MODES) }, + { name: 'bars_color_by', selector: choice(ctx.hass, 'bars_color_by', COLOR_BY) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, BAR_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'bar_group', path: ['group'] }, + { name: 'bar_series', path: ['series'] }, + { name: 'bar_color', path: ['color'] }, +]; + +function renderBars(ctx: ChartContext) { + const { hass } = ctx; + const mode: Mode = (ctx.raw.mode as Mode | undefined) ?? 'grouped'; + const byBands = ctx.raw.color_by === 'bands'; + const layout = layoutBars(inputsFor(ctx), mode, ctx.config.sort); + const [lo, hi] = layout.domain; + const x = (v: number) => (v - lo) / (hi - lo); + const zero = x(0); + const colorOf = (s: Segment) => { + const e = ctx.items[s.index]; + const own = (e.config as { color?: unknown }).color; + if (byBands) return e.band.color; + if (typeof own === 'string' && own) return own; + return BAR_COLORS[layout.series.indexOf(s.series) % BAR_COLORS.length]; + }; + const { show } = ctx.config; + const showLegend = !byBands && layout.series.some((s) => s !== '') && ctx.raw.show?.legend !== false; + // Room either side of the bars for value labels, as wide as the longest one. + const valueTexts = ctx.items.map((e) => e.display.length); + const valueCh = show.value && mode !== 'percent' ? Math.min(12, Math.max(4, ...valueTexts)) : 0; + // Value labels are 11 px text: about 6.5 px a character, plus their padding. + const valuePx = valueCh ? Math.round(valueCh * 6.5 + 8) : 4; + const nameCh = Math.max( + 0, + ...layout.rows.map((r) => (r.label.startsWith('\u0000') ? ctx.items[r.segments[0].index].name : r.label).length), + ); + let offset = 0; + const placed = layout.rows.map((row) => { + const top = offset; + offset += rowHeight(row, mode); + return { row, top }; + }); + const label = localize(hass, 'grouped_bars_aria', { names: ctx.items.map((e) => e.name).join(', ') }); + + const segment = (s: Segment) => { + const e = ctx.items[s.index]; + const color = colorOf(s); + const left = x(Math.min(s.from, s.to)); + const width = Math.abs(x(s.to) - x(s.from)); + const top = mode === 'grouped' ? ROW_PAD + s.slot * (BAR_H + BAR_GAP) : ROW_PAD; + const height = mode === 'grouped' ? BAR_H : STACK_H; + const negative = s.to < s.from || (mode === 'grouped' && s.to < 0); + const share = mode === 'percent' && e.available ? s.to - s.from : 0; + const inside = mode === 'percent' && show.value && Math.abs(share) >= MIN_LABEL_SHARE; + return html`
+ ${ + inside + ? html`` + : nothing + } +
+ ${ + mode === 'grouped' && show.value + ? html`` + : nothing + }`; + }; + + const rowTotal = (row: BarRow) => { + if (mode !== 'stacked' || !show.value) return nothing; + const first = ctx.items[row.segments[0].index]; + const ends = row.segments.filter((s) => s.value !== null).map((s) => s.to); + const right = Math.max(0, ...ends); + const left = Math.min(0, ...ends); + const onLeft = right === 0 && left < 0; + const textOut = + row.total === null ? first.display : withUnit(formatNumber(row.total, first.decimals, hass), first.unit); + return html``; + }; + + const negatives = lo < 0; + return html``; +} + +/** The first segment of a series, whose colour the legend shows. */ +function placedSegment(layout: BarLayout, series: string): Segment | undefined { + for (const row of layout.rows) for (const s of row.segments) if (s.series === series) return s; + return undefined; +} + +export const groupedBars: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 200, + styles: css` + .bars { + padding: 4px 0; + } + .bar-rows { + position: relative; + transition: height var(--cd-duration) var(--cd-easing); + } + .bar-row { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + align-items: center; + transition: transform var(--cd-duration) var(--cd-easing); + } + .row-name { + flex: none; + width: var(--name-w); + padding-right: 8px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875em; + } + .row-plot { + position: relative; + flex: 1; + min-width: 0; + align-self: stretch; + margin: 0 var(--value-w) 0 var(--neg-w); + } + .bars .zero { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .bar-seg { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transform-origin: left center; + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + .bar-seg.negative { + transform-origin: right center; + } + .mode-stacked .bar-seg, + .mode-percent .bar-seg { + border-radius: 2px; + border-right: 1px solid var(--card-background-color, #fff); + } + :host(:not([ready])) .bar-seg { + transform: scaleX(0); + } + .bar-seg:focus-visible { + outline: 2px solid var(--primary-text-color); + outline-offset: 1px; + } + .seg-text { + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: #fff; + pointer-events: none; + } + .seg-text.dark { + color: rgba(0, 0, 0, 0.8); + } + .seg-text.theme { + color: var(--primary-text-color); + } + .bar-value { + position: absolute; + display: flex; + align-items: center; + padding: 0 4px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: var(--primary-text-color); + transition: + left var(--cd-duration) var(--cd-easing), + right var(--cd-duration) var(--cd-easing); + } + .bar-value.unavailable { + color: var(--secondary-text-color); + } + .bar-value.total { + font-size: 12px; + font-weight: 500; + } + :host([static]) .bar-row, + :host([static]) .bar-rows, + :host([static]) .bar-value { + transition: none; + } + .bar-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .bar-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .bar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `, + thumbnail: () => svg` + + + + + `, + validate: validateBars, + editor: barsEditor, + entityFields: (hass) => ({ + schema: cachedSchema('grouped_bars_entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'bar_group', selector: { text: {} } }, + { name: 'bar_series', selector: { text: {} } }, + ], + }, + { name: 'bar_color', selector: { text: {} } }, + ]), + fields: ENTITY_FIELDS, + }), + render: renderBars, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 499d9f2..025ab42 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -26,6 +26,7 @@ import { radar } from './radar'; import { polarArea } from './polar-area'; import { treemap } from './treemap'; import { icicle, sunburst } from './sunburst'; +import { groupedBars } from './grouped-bars'; export interface RenderContext { show: ShowOptions; @@ -141,4 +142,5 @@ export const STYLES: Record = { treemap, sunburst, icicle, + grouped_bars: groupedBars, }; diff --git a/src/types.ts b/src/types.ts index 60f44f3..384e4d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/compare.test.ts b/test/compare.test.ts new file mode 100644 index 0000000..ef3f689 --- /dev/null +++ b/test/compare.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { + direction, + extentOf, + lowHigh, + niceStep, + niceTicks, + readingAt, + relativeChange, + withZero, +} from '../src/chart/compare'; + +describe('comparison maths', () => { + it('picks a 1/2/2.5/5 step', () => { + expect(niceStep(10, 5)).toBe(2); + expect(niceStep(100, 4)).toBe(25); + expect(niceStep(7, 5)).toBe(2); + expect(niceStep(0.3, 3)).toBeCloseTo(0.1); + expect(niceStep(40, 5)).toBe(10); + expect(niceStep(0, 5)).toBe(1); + expect(niceStep(10, 0)).toBe(1); + }); + + it('makes round ticks that enclose the data', () => { + expect(niceTicks(16.1, 24.6, 5)).toEqual([16, 18, 20, 22, 24, 26]); + expect(niceTicks(0, 5, 5)).toEqual([0, 1, 2, 3, 4, 5]); + expect(niceTicks(-1800, 2200, 4)).toEqual([-2000, -1000, 0, 1000, 2000, 3000]); + expect(niceTicks(0.1, 0.3, 2)).toEqual([0.1, 0.2, 0.3]); + expect(niceTicks(5, 1, 4)).toEqual([1, 2, 3, 4, 5]); + expect(niceTicks(3, 3, 4)).toEqual([2.5, 2.75, 3, 3.25, 3.5]); + expect(niceTicks(NaN, 3)).toEqual([]); + }); + + it('finds the extent, skipping gaps', () => { + expect(extentOf([3, null, -2, undefined, NaN, 7])).toEqual([-2, 7]); + expect(extentOf([null])).toBeUndefined(); + expect(withZero([3, 7])).toEqual([0, 7]); + expect(withZero([-3, -1])).toEqual([-3, 0]); + expect(withZero([-3, 4])).toEqual([-3, 4]); + }); + + it('reads the value in force at a time', () => { + const points = [ + { t: 10, v: 1 }, + { t: 20, v: null }, + { t: 30, v: 3 }, + ]; + expect(readingAt(points, 5)).toBeNull(); + expect(readingAt(points, 10)).toBe(1); + expect(readingAt(points, 15)).toBe(1); + expect(readingAt(points, 25)).toBeNull(); + expect(readingAt(points, 99)).toBe(3); + expect(readingAt([], 1)).toBeNull(); + }); + + it('takes low and high from readings and statistics', () => { + expect( + lowHigh([ + { t: 1, v: 5, min: 2, max: 9 }, + { t: 2, v: null }, + { t: 3, v: 6 }, + ]), + ).toEqual([2, 9]); + expect(lowHigh([{ t: 1, v: null }])).toBeUndefined(); + }); + + it('measures change and direction', () => { + expect(relativeChange(100, 112)).toBeCloseTo(0.12); + expect(relativeChange(-10, -5)).toBeCloseTo(0.5); + expect(relativeChange(0, 5)).toBeUndefined(); + expect(relativeChange(null, 5)).toBeUndefined(); + expect(direction(1, 2)).toBe('up'); + expect(direction(2, 1)).toBe('down'); + expect(direction(2, 2)).toBe('flat'); + }); +}); diff --git a/test/grouped-bars.test.ts b/test/grouped-bars.test.ts new file mode 100644 index 0000000..072c18c --- /dev/null +++ b/test/grouped-bars.test.ts @@ -0,0 +1,288 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { layoutBars, rowHeight } from '../src/styles/grouped-bars'; +import type { BarInput } from '../src/styles/grouped-bars'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const input = (index: number, group: string, series: string, value: number | null): BarInput => ({ + index, + group, + series, + value, +}); + +describe('bar layout', () => { + it('groups entities into rows, side by side, from zero', () => { + const layout = layoutBars( + [input(0, 'K', 'Today', 4), input(1, 'K', 'Yesterday', 3), input(2, 'L', 'Today', -2)], + 'grouped', + ); + expect(layout.rows.map((r) => r.key)).toEqual(['K', 'L']); + expect(layout.series).toEqual(['Today', 'Yesterday']); + expect(layout.rows[0].segments.map((s) => [s.from, s.to, s.slot])).toEqual([ + [0, 4, 0], + [0, 3, 1], + ]); + expect(layout.rows[0].total).toBe(7); + expect(layout.domain).toEqual([-2, 4]); + }); + + it('stacks positives right and negatives left of zero', () => { + const layout = layoutBars( + [input(0, 'A', 'x', 3), input(1, 'A', 'y', -1), input(2, 'A', 'z', 2), input(3, 'A', 'w', -4)], + 'stacked', + ); + expect(layout.rows[0].segments.map((s) => [s.from, s.to])).toEqual([ + [0, 3], + [0, -1], + [3, 5], + [-1, -5], + ]); + expect(layout.domain).toEqual([-5, 5]); + expect(layout.rows[0].total).toBe(0); + }); + + it('stacks to 100 % of the absolute total', () => { + const layout = layoutBars([input(0, 'A', 'x', 3), input(1, 'A', 'y', 1), input(2, 'B', 'x', 0)], 'percent'); + expect(layout.rows[0].segments.map((s) => [s.from, s.to])).toEqual([ + [0, 0.75], + [0.75, 1], + ]); + expect(layout.rows[1].segments[0].to).toBe(0); + expect(layout.domain).toEqual([0, 1]); + const mixed = layoutBars([input(0, 'A', 'x', 3), input(1, 'A', 'y', -1)], 'percent'); + expect(mixed.domain).toEqual([-0.25, 0.75]); + }); + + it('leaves a missing value out, and sorts rows by total with empty rows last', () => { + const inputs = [input(0, 'A', '', 1), input(1, 'B', '', null), input(2, 'C', '', 5), input(3, 'A', '', 1)]; + expect(layoutBars(inputs, 'stacked', 'descending').rows.map((r) => r.key)).toEqual(['C', 'A', 'B']); + expect(layoutBars(inputs, 'stacked', 'ascending').rows.map((r) => r.key)).toEqual(['A', 'C', 'B']); + const layout = layoutBars(inputs, 'grouped'); + expect(layout.rows[1].total).toBeNull(); + expect(layout.rows[1].segments[0].to).toBe(0); + expect(layoutBars([input(0, 'A', '', null)], 'grouped').domain).toEqual([0, 1]); + }); + + it('sizes grouped rows by their bars', () => { + const layout = layoutBars([input(0, 'A', 'x', 1), input(1, 'A', 'y', 1), input(2, 'B', 'x', 1)], 'grouped'); + expect(rowHeight(layout.rows[0], 'grouped')).toBeGreaterThan(rowHeight(layout.rows[1], 'grouped')); + expect(rowHeight(layout.rows[0], 'stacked')).toBe(rowHeight(layout.rows[1], 'stacked')); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass(values: Record = {}) { + return makeHass({ + 'sensor.k_today': [values.k_today ?? '4', { friendly_name: 'Kitchen today', unit_of_measurement: 'kWh' }], + 'sensor.k_yday': ['3', { friendly_name: 'Kitchen yesterday', unit_of_measurement: 'kWh' }], + 'sensor.l_today': [values.l_today ?? '2', { friendly_name: 'Lounge today', unit_of_measurement: 'kWh' }], + 'sensor.l_yday': ['unavailable', { friendly_name: 'Lounge yesterday', unit_of_measurement: 'kWh' }], + 'sensor.grid': ['-500', { friendly_name: 'Grid', unit_of_measurement: 'W' }], + }); +} + +const ENTITIES = [ + { entity: 'sensor.k_today', group: 'Kitchen', series: 'Today' }, + { entity: 'sensor.k_yday', group: 'Kitchen', series: 'Yesterday' }, + { entity: 'sensor.l_today', group: 'Lounge', series: 'Today' }, + { entity: 'sensor.l_yday', group: 'Lounge', series: 'Yesterday', color: 'red' }, + { entity: 'sensor.missing', group: 'Hall', series: 'Today' }, +]; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'grouped_bars', + decimals: 0, + entities: ENTITIES, + ...config, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const rowNames = (card: Card) => qa(card, '.row-name').map((e) => e.textContent!.trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('grouped bars', () => { + it('draws a row per group with one tappable bar per entity, values and a series legend', async () => { + const card = await mount({}); + expect(rowNames(card)).toEqual(['Kitchen', 'Lounge', 'Hall']); + const bars = qa(card, '.bar-seg.entity'); + expect(bars.map((b) => b.dataset.index)).toEqual(['0', '1', '2', '3', '4']); + expect(bars[0].getAttribute('style')).toContain('--primary-color'); + expect(bars[1].getAttribute('style')).toContain('--warning-color'); + expect(bars[3].getAttribute('style')).toContain('--error-color'); + expect(bars[3].classList.contains('unavailable')).toBe(true); + expect(bars[0].getAttribute('aria-label')).toBe('Kitchen today, 4 kWh'); + const values = qa(card, '.bar-value').map((e) => e.textContent!.trim()); + expect(values).toEqual(['4 kWh', '3 kWh', '2 kWh', 'Unavailable', 'Entity not found']); + expect(qa(card, '.bar-legend-item').map((e) => e.textContent!.trim())).toEqual(['Today', 'Yesterday']); + expect(q(card, '.bars')!.getAttribute('role')).toBe('img'); + expect(q(card, '.bars')!.getAttribute('aria-label')).toMatch(/^Bar chart of Kitchen today/); + }); + + it('stacks, totals each row, and re-orders rows by total, keeping each row element', async () => { + const card = await mount({ mode: 'stacked', sort: 'descending' }); + expect(rowNames(card)).toEqual(['Kitchen', 'Lounge', 'Hall']); + expect(qa(card, '.bar-value.total').map((e) => e.textContent!.trim())).toEqual([ + '7 kWh', + '2 kWh', + 'Entity not found', + ]); + const kitchen = qa(card, '.bar-row')[0]; + const before = kitchen.style.transform; + card.hass = hass({ k_today: '0', l_today: '9' }); + await card.updateComplete; + expect(rowNames(card)).toEqual(['Lounge', 'Kitchen', 'Hall']); + // The same element moved down rather than another row being redrawn into it. + expect(qa(card, '.bar-row').indexOf(kitchen)).toBe(1); + expect(kitchen.style.transform).not.toBe(before); + }); + + it('labels shares in percent mode', async () => { + const card = await mount({ mode: 'percent' }); + const labels = qa(card, '.seg-text').map((e) => e.textContent!.trim()); + expect(labels).toEqual(['57 %', '43 %', '100 %']); + expect(qa(card, '.bar-value')).toHaveLength(0); + }); + + it('draws negatives left of zero, and rows without a group under the entity name', async () => { + const card = await mount({ entities: ['sensor.grid', 'sensor.k_today'], show: { legend: true } }); + expect(rowNames(card)).toEqual(['Grid', 'Kitchen today']); + const [grid, kitchen] = qa(card, '.bar-seg'); + expect(grid.classList.contains('negative')).toBe(true); + expect(kitchen.classList.contains('negative')).toBe(false); + expect(q(card, '.bar-legend')).toBeNull(); + }); + + it('colours by band, hides names, values and legend when asked', async () => { + const card = await mount({ + color_by: 'bands', + show: { name: false, value: false }, + severity: [ + { from: 0, color: 'green' }, + { from: 3.5, color: 'red' }, + ], + } as Partial); + const bars = qa(card, '.bar-seg'); + expect(bars[0].getAttribute('style')).toContain('--error-color'); + expect(bars[1].getAttribute('style')).toContain('--success-color'); + expect(qa(card, '.row-name')).toHaveLength(0); + expect(qa(card, '.bar-value')).toHaveLength(0); + expect(q(card, '.bar-legend')).toBeNull(); + const off = await mount({ show: { legend: false } }); + expect(q(off, '.bar-legend')).toBeNull(); + }); + + it('runs an entity action from a bar', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const bar = qa(card, '.bar-seg')[2]; + bar.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + bar.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.l_today']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'grouped_bars', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ mode: 'sideways' })).toThrow(/"mode" must be one of: grouped, stacked, percent/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ entities: [{ entity: 'sensor.a', group: ['x'] }] })).toThrow(/Entity 1: "group" must be text/); + expect(set({ entities: ['sensor.a', { entity: 'sensor.b', series: 2 }] })).not.toThrow(); + }); +}); + +describe('grouped bars editor', () => { + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes mode and colour from the Chart section', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'grouped_bars', entities: ['sensor.a'] } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ mode: 'grouped', bars_color_by: 'series' }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { mode: 'percent', bars_color_by: 'bands' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, mode: 'percent', color_by: 'bands' }); + }); + + it("shows row, series and colour in each entity's options, only for this style", async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'grouped_bars', + entities: [{ entity: 'sensor.a', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + chartFields?: unknown; + }; + expect(list.chartFields).toBeDefined(); + (list.shadowRoot!.querySelector('.expand') as HTMLElement).click(); + await list.updateComplete; + const form = list.shadowRoot!.querySelector('ha-form[data-section="entity-chart"]') as Stub; + expect(form.data).toEqual({ bar_group: 'Kitchen' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { bar_group: 'Kitchen', bar_series: 'Today', bar_color: 'green' } }, + }), + ); + expect(events.at(-1)!.entities).toEqual([ + { entity: 'sensor.a', group: 'Kitchen', series: 'Today', color: 'green' }, + ]); + + document.body.innerHTML = ''; + const bar = await editor({ type: 'custom:chartdeck-card', style: 'bar', entities: ['sensor.a'] } as CardConfig); + const plain = bar.ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + }; + (plain.shadowRoot!.querySelector('.expand') as HTMLElement).click(); + await plain.updateComplete; + expect(plain.shadowRoot!.querySelector('ha-form[data-section="entity-chart"]')).toBeNull(); + expect(plain.shadowRoot!.querySelector('ha-form[data-section="entity-text"]')).not.toBeNull(); + }); +}); From 3e747c79fc20c999689818774a62dc89fc5105cc Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:36:22 +0000 Subject: [PATCH 10/41] feat: compare values against targets with bullet and lollipop charts Two new chart styles in the "Comparisons" group of the style picker. Bullet draws one row per entity: the name (with "target 18 kWh" under it when the entity has a target), a track from the entity's min to max with its colour bands as ranges behind the bar, the value bar, a line at the target, and the value on the right. The ranges are grey shades that get deeper band by band, or the bands' own colours, faint ("Ranges behind the bar" in the editor's Chart section, `ranges: shades | colours`). The bar takes the colour of the band the value is in. Targets can be a number or an entity, as for the other styles. Lollipop draws a stem with a dot at the value for each entity, upright in columns over a shared baseline (value above, name below) or across in rows ("Direction" in the Chart section, `orientation: vertical | horizontal`). A target shows as a hollow ring on the stem's line. Both use each entity's own min and max, like the bar styles, so values below zero grow left of (or below) the zero point when min is negative. Every entity is its own element with tap, hold and double-tap actions and a meter role for screen readers. Unavailable and missing entities keep their track but draw no bar, dot or target, and say what is wrong where the value goes. The marks grow in like the bar styles and stay still when the browser asks for less motion. src/chart/compare.ts and src/chart/compare-form.ts are shared helpers for the comparison charts; compare-form.ts matches the line chart's editor helpers and can go once both are on main. Closes #38 --- src/chart/marks.ts | 116 ++++++++++++++++ src/styles/bullet.ts | 147 ++++++++++++++++++++ src/styles/index.ts | 4 + src/styles/lollipop.ts | 241 +++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/bullet-lollipop.test.ts | 251 +++++++++++++++++++++++++++++++++++ 6 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 src/chart/marks.ts create mode 100644 src/styles/bullet.ts create mode 100644 src/styles/lollipop.ts create mode 100644 test/bullet-lollipop.test.ts diff --git a/src/chart/marks.ts b/src/chart/marks.ts new file mode 100644 index 0000000..c40b687 --- /dev/null +++ b/src/chart/marks.ts @@ -0,0 +1,116 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatNumber, withUnit } from '../format'; +import { isInteractive } from '../actions'; +import { localize } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import { position } from '../scale'; +import type { HomeAssistant } from '../types'; + +/** One qualitative range behind a bullet bar, in track fractions (0..1). */ +export interface RangeSegment { + from: number; + to: number; + color: string; + /** 0 for the lowest band, counting up. */ + level: number; +} + +/** + * The entity's colour bands cut to its min..max, as track fractions. Bands wholly outside the + * limits are left out; the first visible one starts at the track's left edge. + */ +export function rangeSegments(e: Pick): RangeSegment[] { + const out: RangeSegment[] = []; + e.bands.forEach((band, i) => { + const next = e.bands[i + 1]; + const from = Number.isFinite(band.from) ? position(band.from, e.min, e.max) : 0; + const to = next ? position(next.from, e.min, e.max) : 1; + if (to <= from) return; + out.push({ from: out.length ? from : 0, to, color: band.color, level: 0 }); + }); + out.forEach((s, i) => (s.level = i)); + return out; +} + +/** + * The background of one range: a grey tint that deepens band by band ('shades'), or the band's + * own colour, faint ('colours'). + */ +export function rangeFill(segment: RangeSegment, count: number, mode: 'shades' | 'colours'): string { + if (mode === 'colours') return `color-mix(in srgb, ${baseColor(segment.color)} 22%, transparent)`; + const strength = count <= 1 ? 8 : 6 + Math.round((segment.level / (count - 1)) * 12); + return `color-mix(in srgb, var(--primary-text-color, #212121) ${strength}%, transparent)`; +} + +/** The drawing variables every entity's mark reads; the card forces them to 0 until it is ready. */ +export function markVars(e: ResolvedEntity): string { + const color = baseColor(e.band.color); + return ( + `--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};` + + `--target:${e.targetPosition ?? 0};--c-start:${color}` + ); +} + +/** "target 18 kWh", or nothing when the entity has no target. */ +export function targetText(e: ResolvedEntity, hass: HomeAssistant): string | undefined { + if (e.target === null) return undefined; + return localize(hass, 'compare_target', { target: withUnit(formatNumber(e.target, e.decimals, hass), e.unit) }); +} + +/** + * The element for one entity: `.entity` with its `data-index`, a meter role and the drawing + * variables, so tap, hold and double-tap actions and screen readers work as in the value styles. + */ +export function entityMark(e: ResolvedEntity, index: number, inner: TemplateResult, extra = ''): TemplateResult { + const interactive = isInteractive(e.config); + return html`
+ ${inner} +
`; +} + +/** Look shared by the bullet and lollipop charts. */ +export const markStyles = css` + .compare .entity.interactive { + cursor: pointer; + } + .compare .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + .compare .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .compare .value { + font-weight: 500; + white-space: nowrap; + } + .compare .entity.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 0.875em; + white-space: normal; + } + .compare .sub { + color: var(--secondary-text-color); + font-size: 0.8em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`; diff --git a/src/styles/bullet.ts b/src/styles/bullet.ts new file mode 100644 index 0000000..17f932a --- /dev/null +++ b/src/styles/bullet.ts @@ -0,0 +1,147 @@ +import { css, html, nothing, svg } from 'lit'; +import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import { entityMark, markStyles, rangeFill, rangeSegments, targetText } from '../chart/marks'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const RANGE_MODES = ['shades', 'colours'] as const; +export type RangeMode = (typeof RANGE_MODES)[number]; + +registerStrings('en', { + editor_style_bullet: 'Bullet', + editor_ranges: 'Ranges behind the bar', + editor_ranges_shades: 'Shades of grey', + editor_ranges_colours: 'Colour band colours', + compare_target: 'target {target}', + chart_aria: '{style} chart of {names}', +}); + +function bulletRow(e: ResolvedEntity, index: number, ctx: ChartContext, mode: RangeMode) { + const { show } = ctx.config; + const segments = rangeSegments(e); + const sub = show.target ? targetText(e, ctx.hass) : undefined; + const value = show.value || !e.available; + const inner = html` + ${ + show.name + ? html`
+ ${e.name}${sub ? html`${sub}` : nothing} +
` + : nothing + } +
+ ${segments.map( + (s) => + html`
`, + )} + ${e.available ? html`
` : nothing} + ${show.target && e.available && e.targetPosition !== null ? html`
` : nothing} +
+ ${value ? html`${e.display}` : html``} + `; + return entityMark(e, index, inner, 'bullet-row'); +} + +export const bullet: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 120, + styles: css` + ${markStyles} + .style-bullet .bullets { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-bullet .bullets.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-bullet .bullet-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-bullet .label { + display: flex; + flex-direction: column; + min-width: 0; + max-width: 14em; + } + .style-bullet .track { + position: relative; + height: 28px; + border-radius: 6px; + overflow: hidden; + background: var(--cd-track); + } + .style-bullet .range { + position: absolute; + top: 0; + bottom: 0; + } + .style-bullet .fill { + position: absolute; + top: 30%; + bottom: 30%; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), color-mix(in srgb, var(--c-start) 80%, #fff)); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-bullet .target { + position: absolute; + top: 3px; + bottom: 3px; + width: 3px; + border-radius: 2px; + left: calc(var(--target) * (100% - 3px)); + background: var(--primary-text-color); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-bullet .value { + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 0.62em); + } + `, + thumbnail: () => svg` + + + + + + + + `, + validate(raw: CardConfig) { + if (raw.ranges !== undefined && !RANGE_MODES.includes(raw.ranges as RangeMode)) { + throw new Error(`"ranges" must be one of: ${RANGE_MODES.join(', ')}`); + } + }, + editor(ctx: ChartEditorContext) { + const schema = cachedSchema('bullet', ctx.hass, () => [ + { name: 'ranges', selector: choice(ctx.hass, 'ranges', RANGE_MODES) }, + ]); + return chartForm(ctx, 'chart', schema, [{ name: 'ranges', path: ['ranges'], default: 'shades' }]); + }, + render(ctx) { + const mode = (ctx.raw.ranges as RangeMode | undefined) ?? 'shades'; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_bullet'), + names: ctx.items.map((e) => e.name).join(', '), + }); + return html`
+ ${ctx.items.map((e, i) => bulletRow(e, i, ctx, mode))} +
`; + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 025ab42..70d87bc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -27,6 +27,8 @@ import { polarArea } from './polar-area'; import { treemap } from './treemap'; import { icicle, sunburst } from './sunburst'; import { groupedBars } from './grouped-bars'; +import { bullet } from './bullet'; +import { lollipop } from './lollipop'; export interface RenderContext { show: ShowOptions; @@ -143,4 +145,6 @@ export const STYLES: Record = { sunburst, icicle, grouped_bars: groupedBars, + bullet, + lollipop, }; diff --git a/src/styles/lollipop.ts b/src/styles/lollipop.ts new file mode 100644 index 0000000..11737dd --- /dev/null +++ b/src/styles/lollipop.ts @@ -0,0 +1,241 @@ +import { css, html, nothing, svg } from 'lit'; +import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import { entityMark, markStyles } from '../chart/marks'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig } from '../types'; + +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +registerStrings('en', { + editor_style_lollipop: 'Lollipop', + editor_orientation: 'Direction', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'Across', + chart_aria: '{style} chart of {names}', +}); + +/** The stem, dot and target ring, placed by the entity's drawing variables. */ +function stick(e: ResolvedEntity, ctx: ChartContext, withValue: boolean) { + const { show } = ctx.config; + return html`
+
+ ${ + e.available + ? html`
+ ${show.target && e.targetPosition !== null ? html`
` : nothing} +
+ ${withValue && show.value ? html`${e.display}` : nothing}` + : withValue + ? html`${e.display}` + : nothing + } +
+
`; +} + +function verticalItem(e: ResolvedEntity, index: number, ctx: ChartContext) { + const { show } = ctx.config; + const inner = html`${stick(e, ctx, true)} ${show.name ? html`${e.name}` : nothing}`; + return entityMark(e, index, inner, 'pop'); +} + +function horizontalItem(e: ResolvedEntity, index: number, ctx: ChartContext) { + const { show } = ctx.config; + const value = show.value || !e.available; + const inner = html`${show.name ? html`${e.name}` : nothing} ${stick(e, ctx, false)} + ${value ? html`${e.display}` : html``}`; + return entityMark(e, index, inner, 'pop'); +} + +export const lollipop: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 200, + styles: css` + ${markStyles} + .style-lollipop .lane { + position: relative; + } + .style-lollipop .scale { + position: absolute; + } + .style-lollipop .stem { + position: absolute; + background: var(--c-start); + border-radius: 2px; + } + .style-lollipop .dot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--c-start); + background-image: radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--c-start) 70%, #fff), + var(--c-start) + ); + } + .style-lollipop .target { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--primary-text-color); + background: var(--card-background-color, #fff); + box-sizing: content-box; + } + + /* Upright: one column per entity over a shared baseline. */ + .style-lollipop .vertical { + display: flex; + align-items: stretch; + } + .style-lollipop .vertical .pop { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .style-lollipop .vertical .lane { + align-self: stretch; + height: 150px; + border-bottom: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .vertical .scale { + left: 50%; + top: 26px; + bottom: 0; + } + .style-lollipop .vertical .stem { + left: -1.5px; + width: 3px; + bottom: calc(var(--start) * 100%); + height: calc((var(--end) - var(--start)) * 100%); + transition: + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .dot { + left: -7px; + bottom: calc(var(--pos) * 100% - 7px); + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .target { + left: -6px; + bottom: calc(var(--target) * 100% - 6px); + } + .style-lollipop .vertical .tip { + position: absolute; + left: 0; + bottom: calc(var(--pos) * 100% + 10px); + transform: translateX(-50%); + font-size: 0.875em; + line-height: 1.2; + padding: 0 3px; + border-radius: 3px; + background: var(--card-background-color, #fff); + z-index: 1; + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .name { + max-width: 100%; + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 0 2px; + } + .style-lollipop .vertical .entity.unavailable .value { + font-size: 0.75em; + white-space: nowrap; + } + + /* Across: one row per entity, stems growing to the right. */ + .style-lollipop .horizontal { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-lollipop .horizontal.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-lollipop .horizontal .pop { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-lollipop .horizontal .name { + max-width: 14em; + } + .style-lollipop .horizontal .lane { + height: 20px; + border-left: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .horizontal .scale { + top: 50%; + left: 0; + right: 7px; + } + .style-lollipop .horizontal .stem { + top: -1.5px; + height: 3px; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .dot { + top: -7px; + left: calc(var(--pos) * 100% - 7px); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .target { + top: -6px; + left: calc(var(--target) * 100% - 6px); + } + .style-lollipop .horizontal .value { + text-align: right; + } + `, + thumbnail: () => svg` + + + + + + + `, + validate(raw: CardConfig) { + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + }, + editor(ctx: ChartEditorContext) { + const schema = cachedSchema('lollipop', ctx.hass, () => [ + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ]); + return chartForm(ctx, 'chart', schema, [{ name: 'orientation', path: ['orientation'], default: 'vertical' }]); + }, + render(ctx) { + const orientation = (ctx.raw.orientation as Orientation | undefined) ?? 'vertical'; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_lollipop'), + names: ctx.items.map((e) => e.name).join(', '), + }); + const item = orientation === 'vertical' ? verticalItem : horizontalItem; + return html`
+ ${ctx.items.map((e, i) => item(e, i, ctx))} +
`; + }, +}; diff --git a/src/types.ts b/src/types.ts index 384e4d5..cd67cde 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/bullet-lollipop.test.ts b/test/bullet-lollipop.test.ts new file mode 100644 index 0000000..da18fb6 --- /dev/null +++ b/test/bullet-lollipop.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { rangeFill, rangeSegments, targetText } from '../src/chart/marks'; +import { STYLES } from '../src/styles/index'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hass() { + return makeHass({ + 'sensor.elec': ['14.2', { friendly_name: 'Electricity', unit_of_measurement: 'kWh' }], + 'sensor.gas': ['22.5', { friendly_name: 'Gas', unit_of_measurement: 'kWh' }], + 'sensor.budget': ['20', { friendly_name: 'Budget' }], + 'sensor.grid': ['-500', { friendly_name: 'Grid', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', ...config } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel) as HTMLElement | null; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('bullet and lollipop maths', () => { + const bands = [ + { from: -Infinity, color: 'red' }, + { from: 10, color: 'amber' }, + { from: 25, color: 'green' }, + ]; + + it('cuts colour bands to the entity limits', () => { + expect(rangeSegments({ bands, min: 0, max: 50 })).toEqual([ + { from: 0, to: 0.2, color: 'red', level: 0 }, + { from: 0.2, to: 0.5, color: 'amber', level: 1 }, + { from: 0.5, to: 1, color: 'green', level: 2 }, + ]); + // Bands below min drop out and the first one left starts at the edge. + expect(rangeSegments({ bands, min: 30, max: 50 })).toEqual([{ from: 0, to: 1, color: 'green', level: 0 }]); + expect(rangeSegments({ bands: [{ from: -Infinity, color: 'primary' }], min: 0, max: 100 })).toHaveLength(1); + }); + + it('shades ranges darker band by band, or tints them with the band colour', () => { + const segs = rangeSegments({ bands, min: 0, max: 50 }); + expect(rangeFill(segs[0], 3, 'shades')).toContain(' 6%'); + expect(rangeFill(segs[2], 3, 'shades')).toContain(' 18%'); + expect(rangeFill(segs[0], 1, 'shades')).toContain(' 8%'); + expect(rangeFill(segs[1], 3, 'colours')).toContain('var(--warning-color'); + }); + + it('writes the target with the unit, or nothing without one', () => { + const h = hass(); + expect(targetText({ target: 18, decimals: 1, unit: 'kWh' } as never, h)).toBe('target 18.0 kWh'); + expect(targetText({ target: null } as never, h)).toBeUndefined(); + }); +}); + +describe('bullet chart', () => { + it('draws one row per entity with ranges, bar, target and value', async () => { + const card = await mount({ + style: 'bullet', + max: 30, + severity: [ + { from: 0, color: 'green' }, + { from: 15, color: 'amber' }, + { from: 22, color: 'red' }, + ], + entities: [ + { entity: 'sensor.elec', target: 18 }, + { entity: 'sensor.gas', target: 'sensor.budget' }, + 'sensor.off', + 'sensor.missing', + ], + }); + const rows = qa(card, '.bullet-row.entity'); + expect(rows.map((r) => r.dataset.index)).toEqual(['0', '1', '2', '3']); + expect(q(card, '.bullets')!.getAttribute('aria-label')).toBe( + 'Bullet chart of Electricity, Gas, Off, sensor.missing', + ); + expect(rows[0].querySelectorAll('.range')).toHaveLength(3); + expect(rows[0].querySelector('.fill')).not.toBeNull(); + expect(rows[0].style.getPropertyValue('--end')).toBe(String(14.2 / 30)); + expect(rows[0].style.getPropertyValue('--target')).toBe('0.6'); + expect(rows[0].querySelector('.sub')!.textContent).toBe('target 18.0 kWh'); + expect(rows[1].querySelector('.sub')!.textContent).toBe('target 20.0 kWh'); + expect(rows[1].querySelector('.value')!.textContent).toBe('22.5 kWh'); + expect(rows[1].style.getPropertyValue('--c-start')).toContain('--error-color'); + // Unavailable and missing: the track, no bar or target, the status in place of the value. + expect(rows[2].classList.contains('unavailable')).toBe(true); + expect(rows[2].querySelector('.fill, .target')).toBeNull(); + expect(rows[2].querySelector('.value')!.textContent).toBe('Unavailable'); + expect(rows[3].querySelector('.value')!.textContent).toBe('Entity not found'); + expect(rows[0].getAttribute('role')).toBe('meter'); + }); + + it('tints ranges with band colours, hides target and names when asked, and draws below zero', async () => { + const card = await mount({ + style: 'bullet', + ranges: 'colours', + min: -1000, + max: 1000, + target: 0, + show: { target: false, name: false, value: false }, + entities: ['sensor.grid'], + }); + const row = q(card, '.entity')!; + expect(q(card, '.bullets.no-name')).not.toBeNull(); + expect(row.querySelector('.range')!.getAttribute('style')).toContain('color-mix(in srgb, var(--primary-color'); + expect(row.querySelector('.target, .name, .value')).toBeNull(); + expect(row.style.getPropertyValue('--start')).toBe('0.25'); + expect(row.style.getPropertyValue('--end')).toBe('0.5'); + }); + + it('opens more-info from a row', async () => { + const card = await mount({ style: 'bullet', entities: ['sensor.elec', 'sensor.gas'] }); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.entity')[1]; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.gas']); + }); +}); + +describe('lollipop chart', () => { + it('stands one stem per entity, with value above and name below', async () => { + const card = await mount({ + style: 'lollipop', + target: 50, + entities: ['sensor.elec', 'sensor.off', 'sensor.missing'], + }); + expect(q(card, '.compare.vertical')).not.toBeNull(); + const items = qa(card, '.pop.entity'); + expect(items.map((r) => r.dataset.index)).toEqual(['0', '1', '2']); + expect(items[0].querySelector('.stem')).not.toBeNull(); + expect(items[0].querySelector('.dot')).not.toBeNull(); + expect(items[0].querySelector('.target')).not.toBeNull(); + expect(items[0].querySelector('.tip')!.textContent).toBe('14.2 kWh'); + expect(items[0].querySelector('.name')!.textContent).toBe('Electricity'); + expect(items[0].style.getPropertyValue('--pos')).toBe('0.142'); + expect(items[1].querySelector('.stem, .dot, .target')).toBeNull(); + expect(items[1].querySelector('.value')!.textContent).toBe('Unavailable'); + expect(items[2].querySelector('.value')!.textContent).toBe('Entity not found'); + expect(q(card, '.compare')!.getAttribute('aria-label')).toBe('Lollipop chart of Electricity, Off, sensor.missing'); + }); + + it('lies across with the value on the right, and leaves values out when hidden', async () => { + const card = await mount({ + style: 'lollipop', + orientation: 'horizontal', + show: { value: false }, + entities: ['sensor.elec', 'sensor.off'], + }); + expect(q(card, '.compare.horizontal')).not.toBeNull(); + const [on, off] = qa(card, '.pop.entity'); + expect(on.querySelector('.value')).toBeNull(); + expect(on.querySelector('.target')).toBeNull(); + // An unavailable entity still says so with values hidden. + expect(off.querySelector('.value')!.textContent).toBe('Unavailable'); + }); +}); + +describe('bullet and lollipop options', () => { + it('checks them', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', entities: ['sensor.elec'], ...extra } as CardConfig); + expect(set({ style: 'bullet', ranges: 'stripes' })).toThrow('"ranges" must be one of: shades, colours'); + expect(set({ style: 'lollipop', orientation: 'diagonal' })).toThrow( + '"orientation" must be one of: vertical, horizontal', + ); + expect(set({ style: 'bullet', ranges: 'colours' })).not.toThrow(); + }); + + it('have thumbnails in the comparisons group', () => { + for (const name of ['bullet', 'lollipop'] as const) { + const style = STYLES[name]; + expect(style.group).toBe('compare'); + expect(style.layout).toBe('chart'); + } + }); + + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const change = (value: Record) => + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), ...value } } }), + ); + return { ed, form, events, change }; + } + + it('are set in the editor', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'bullet', entities: ['sensor.elec'] } as CardConfig; + const b = await editor(raw); + expect(b.form.data).toEqual({ ranges: 'shades' }); + const labels = ( + (b.form.schema as { selector: { select: { options: { label: string }[] } } }[])[0].selector.select.options as { + label: string; + }[] + ).map((o) => o.label); + expect(labels).toEqual(['Shades of grey', 'Colour band colours']); + b.change({ ranges: 'colours' }); + expect(b.events.at(-1)).toEqual({ ...raw, ranges: 'colours' }); + document.body.innerHTML = ''; + + const raw2 = { type: 'custom:chartdeck-card', style: 'lollipop', orientation: 'horizontal' } as CardConfig; + const l = await editor(raw2); + expect(l.form.data).toEqual({ orientation: 'horizontal' }); + l.change({ orientation: 'vertical' }); + // Back to the default removes the key. + expect(l.events.at(-1)).toEqual({ type: 'custom:chartdeck-card', style: 'lollipop' }); + }); +}); From 4476338af7ca634842271e9a15752f02af802682 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:36:42 +0000 Subject: [PATCH 11/41] feat: show how a total is built up or worn down with a waterfall chart A new "Waterfall" style (Parts of a total) draws each entity as a step that lifts or lowers a running total, joined by dashed lines at the running level, with an end bar showing where the steps finished. By default a step is how much the entity changed over the chart's period (its last reading less its first), read from history or long-term statistics like the line chart. "steps: current" uses each entity's current value instead, for sensors that already hold a part (this month's cost of heating), and then no history is loaded. "start_from_first" turns the first entity into a Start bar from its reading at the start of the period, for "battery charge through the day". An entity marked "invert" counts down, for meters that count energy used. "total: false" leaves the end bar out, and "orientation: horizontal" draws rows instead of columns. Rises are green and falls red from the theme; start and end bars use the primary colour. A step with no number (unavailable, missing, or no readings in the period) adds nothing and is drawn as a dashed outline with its state. Bars and names open the entity like any other card part; the end total is not an entity. Bars grow in unless motion is reduced. Everything is in the editor's Chart section: period, source, what a step is, direction, start and total, then a "Counts down" switch for each listed entity. Closes #26 --- src/styles/index.ts | 2 + src/styles/waterfall.ts | 452 ++++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/waterfall.test.ts | 277 ++++++++++++++++++++++++ 4 files changed, 733 insertions(+), 2 deletions(-) create mode 100644 src/styles/waterfall.ts create mode 100644 test/waterfall.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 70d87bc..9f35107 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -29,6 +29,7 @@ import { icicle, sunburst } from './sunburst'; import { groupedBars } from './grouped-bars'; import { bullet } from './bullet'; import { lollipop } from './lollipop'; +import { waterfall } from './waterfall'; export interface RenderContext { show: ShowOptions; @@ -147,4 +148,5 @@ export const STYLES: Record = { grouped_bars: groupedBars, bullet, lollipop, + waterfall, }; diff --git a/src/styles/waterfall.ts b/src/styles/waterfall.ts new file mode 100644 index 0000000..a30f321 --- /dev/null +++ b/src/styles/waterfall.ts @@ -0,0 +1,452 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import { entityOptionsForm } from '../editor/entity-options'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const STEP_MODES = ['change', 'current'] as const; +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +type StepMode = (typeof STEP_MODES)[number]; + +registerStrings('en', { + editor_style_waterfall: 'Waterfall', + editor_steps: 'Each step is', + editor_steps_change: 'How much it changed in the period', + editor_steps_current: 'Its current value', + editor_orientation: 'Direction', + editor_orientation_vertical: 'Columns', + editor_orientation_horizontal: 'Rows', + editor_start_from_first: "Start from the first entity's reading at the start of the period", + editor_total: 'Show the total at the end', + editor_waterfall_invert: 'Counts down (a rise in these is a drop in the total)', + waterfall_start: 'Start', + waterfall_end: 'End', + waterfall_total: 'Total', + waterfall_no_readings: 'No readings', + waterfall_aria: 'Waterfall chart: {bars}', +}); + +/** How much a series changed: its last reading less its first, or null with no readings. */ +export function change(points: Point[]): number | null { + const readings = points.filter((p) => p.v !== null); + if (readings.length === 0) return null; + return (readings[readings.length - 1].v as number) - (readings[0].v as number); +} + +/** The first reading in a series (at the start of the period), or null with none. */ +export function firstReading(points: Point[]): number | null { + const p = points.find((x) => x.v !== null); + return p ? (p.v as number) : null; +} + +export interface WaterfallBar { + kind: 'start' | 'step' | 'total'; + /** Level the bar starts at and ends at; a step goes from the running total before it to after. */ + from: number; + to: number; + /** How much the bar adds (the value itself for start and total bars). */ + delta: number; + /** Index into the chart's items; none for the total bar. */ + index?: number; + /** A step with no number: drawn as an empty outline that adds nothing. */ + missing: boolean; +} + +export interface StepInput { + value: number | null; + index: number; +} + +/** + * The bars of a waterfall: an optional start bar from zero, a step per input carrying the running + * total along, and an optional total bar from zero to where the steps ended. The domain covers + * every bar and zero, so bars always stand on a visible baseline. + */ +export function waterfallBars( + start: StepInput | null, + steps: StepInput[], + total: boolean, +): { bars: WaterfallBar[]; domain: [number, number] } { + const bars: WaterfallBar[] = []; + let level = 0; + if (start) { + const v = start.value ?? 0; + bars.push({ kind: 'start', from: 0, to: v, delta: v, index: start.index, missing: start.value === null }); + level = v; + } + for (const s of steps) { + const delta = s.value ?? 0; + bars.push({ kind: 'step', from: level, to: level + delta, delta, index: s.index, missing: s.value === null }); + level += delta; + } + if (total) bars.push({ kind: 'total', from: 0, to: level, delta: level, missing: false }); + let lo = 0; + let hi = 0; + for (const b of bars) { + lo = Math.min(lo, b.from, b.to); + hi = Math.max(hi, b.from, b.to); + } + if (lo === hi) hi = lo + 1; + return { bars, domain: [lo, hi] }; +} + +/** "+6.1" or "−3.4" (a real minus sign); no sign for zero. */ +export function signed(value: number, decimals: number, hass?: HomeAssistant): string { + const text = formatNumber(Math.abs(value), decimals, hass); + if (Number(value.toFixed(decimals)) === 0) return formatNumber(0, decimals, hass); + return `${value > 0 ? '+' : '−'}${text}`; +} + +function inverted(e: ResolvedEntity): boolean { + return (e.config as { invert?: unknown }).invert === true; +} + +function needsHistory(raw: CardConfig): boolean { + return (raw.steps ?? 'change') === 'change' || raw.start_from_first === true; +} + +/** Room left beyond the highest (and lowest, if below zero) bar for its label, as a share. */ +const HEADROOM = { vertical: 0.14, horizontal: 0.24 }; + +interface Placed extends WaterfallBar { + name: string; + label: string; + entity?: ResolvedEntity; +} + +function placeBars(ctx: ChartContext, frame: HistoryFrame | undefined): Placed[] { + const { items, raw, hass } = ctx; + const mode = (raw.steps as StepMode | undefined) ?? 'change'; + const fromFirst = raw.start_from_first === true; + const points = (i: number) => frame?.series[i]?.points ?? []; + const stepValue = (e: ResolvedEntity, i: number): number | null => { + const v = mode === 'change' ? change(points(i)) : e.available ? e.value : null; + return v === null ? null : inverted(e) ? -v : v; + }; + const start = fromFirst && items.length ? { value: firstReading(points(0)), index: 0 } : null; + const steps = items.map((e, index) => ({ value: stepValue(e, index), index })).slice(fromFirst ? 1 : 0); + const { bars } = waterfallBars(start, steps, raw.total !== false); + const decimals = items[0]?.decimals ?? 1; + return bars.map((b) => { + const entity = b.index === undefined ? undefined : items[b.index]; + const places = entity?.decimals ?? decimals; + let name: string; + let label: string; + if (b.kind === 'start') { + name = localize(hass, 'waterfall_start'); + label = b.missing ? localize(hass, 'waterfall_no_readings') : formatNumber(b.to, places, hass); + } else if (b.kind === 'total') { + name = localize(hass, start ? 'waterfall_end' : 'waterfall_total'); + label = formatNumber(b.to, decimals, hass); + } else { + name = entity!.name; + if (!b.missing) label = signed(b.delta, places, hass); + else label = entity!.available ? localize(hass, 'waterfall_no_readings') : entity!.display; + } + return { ...b, name, label, entity }; + }); +} + +function colorOf(b: WaterfallBar): string { + if (b.kind !== 'step') return baseColor('primary'); + return baseColor(b.delta < 0 ? 'red' : 'green'); +} + +function drawing(ctx: ChartContext, bars: Placed[]): TemplateResult { + const horizontal = ctx.raw.orientation === 'horizontal'; + const values = bars.flatMap((b) => [b.from, b.to]); + let lo = Math.min(0, ...values); + let hi = Math.max(0, ...values); + if (lo === hi) hi = lo + 1; + const room = (hi - lo) * HEADROOM[horizontal ? 'horizontal' : 'vertical']; + hi += room; + if (lo < 0) lo -= room; + const at = (v: number) => ((v - lo) / (hi - lo)) * 100; + const n = bars.length; + const slot = 100 / n; + // The bar fills the middle of its slot; the rest is the gap the connectors cross. + const fill = horizontal ? 0.64 : 0.6; + const c0 = (i: number) => slot * i + (slot * (1 - fill)) / 2; + const c1 = (i: number) => c0(i) + slot * fill; + const unit = ctx.items[0]?.unit ?? ''; + const aria = localize(ctx.hass, 'waterfall_aria', { + bars: bars.map((b) => `${b.name} ${b.missing ? b.label : withUnit(b.label, b.entity?.unit ?? unit)}`).join(', '), + }); + const entityAttrs = (b: Placed) => (b.index === undefined ? '' : 'entity'); + const marks = bars.map((b, i) => { + const a0 = at(Math.min(b.from, b.to)); + const a1 = at(Math.max(b.from, b.to)); + const vars = `--a0:${a0}%;--a1:${a1}%;--c0:${c0(i)}%;--c1:${c1(i)}%;--cm:${(c0(i) + c1(i)) / 2}%;--color:${colorOf(b)}`; + const next = i + 1 < n ? `--a:${at(b.to)}%;--c0:${c1(i)}%;--c1:${c0(i + 1)}%` : ''; + return html`
+ ${b.missing && !horizontal ? '\u2013' : b.label} + ${next ? html`
` : nothing}`; + }); + const names = bars.map( + (b) => + html`
+ ${b.name} +
`, + ); + return html`
+
${names}
+ +
`; +} + +const FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'steps', path: ['steps'], default: 'change' }, + { name: 'orientation', path: ['orientation'], default: 'vertical' }, + { name: 'start_from_first', path: ['start_from_first'], default: false }, + { name: 'total', path: ['total'], default: true }, +]; + +export function waterfallEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('waterfall', ctx.hass, () => [ + ...historySchema(ctx.hass), + { + type: 'grid', + name: '', + schema: [ + { name: 'steps', selector: choice(ctx.hass, 'steps', STEP_MODES) }, + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ], + }, + { name: 'start_from_first', selector: { boolean: {} } }, + { name: 'total', selector: { boolean: {} } }, + ]); + return html`${chartForm(ctx, 'chart', schema, FIELDS)} + ${entityOptionsForm(ctx, 'waterfall_invert', 'invert', { boolean: {} }, false)}`; +} + +function checkBoolean(value: unknown, field: string): void { + if (value !== undefined && typeof value !== 'boolean') throw new Error(`"${field}" must be true or false`); +} + +export const waterfall: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + ${frameStyles} + .wf { + display: grid; + gap: 6px; + } + .wf.vertical { + grid-template-rows: 180px auto; + } + .wf.vertical .names { + grid-row: 2; + display: grid; + grid-template-columns: repeat(var(--n), minmax(0, 1fr)); + } + .wf.vertical .wf-plot { + grid-row: 1; + } + .wf.horizontal { + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + max-width: 100%; + } + .wf.horizontal .names { + display: grid; + grid-template-rows: repeat(var(--n), 30px); + max-width: 140px; + } + .wf.horizontal .wf-plot { + height: calc(var(--n) * 30px); + } + .wf-plot { + position: relative; + min-width: 0; + } + .bar-name { + min-width: 0; + display: flex; + align-items: center; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .bar-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .wf.vertical .bar-name { + justify-content: center; + padding: 0 2px; + } + .bar-name.entity { + cursor: pointer; + border-radius: 4px; + } + .bar { + position: absolute; + box-sizing: border-box; + background: linear-gradient(var(--color), color-mix(in srgb, var(--color) 80%, #fff)); + border-radius: 4px; + transition: transform var(--cd-duration) var(--cd-easing); + } + .bar.entity { + cursor: pointer; + } + .bar.missing { + background: none; + border: 1.5px dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .bar { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a0); + height: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 50% 100%; + transform: scaleY(0); + } + .wf.horizontal .bar { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a0); + width: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 0 50%; + transform: scaleX(0); + } + .wf.vertical .bar.missing { + height: 16px; + bottom: calc(var(--a0) - 8px); + } + .wf.horizontal .bar.missing { + width: 16px; + left: calc(var(--a0) - 8px); + } + :host([ready]) .wf .bar { + transform: none; + } + .bar-label { + position: absolute; + font-size: 0.8125em; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + color: var(--primary-text-color); + } + .bar-label:not(.start):not(.total):not(.missing) { + color: var(--color); + } + .bar-label.missing { + color: var(--secondary-text-color); + font-weight: 400; + } + .wf.horizontal .bar-label.missing { + margin-left: 10px; + } + .wf.vertical .bar-label { + left: var(--cm); + bottom: calc(var(--a1) + 4px); + transform: translateX(-50%); + } + .wf.horizontal .bar-label { + left: calc(var(--a1) + 6px); + top: var(--cm); + transform: translateY(-50%); + } + .connector { + position: absolute; + pointer-events: none; + border: 0 dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .connector { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a); + border-top-width: 1px; + } + .wf.horizontal .connector { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a); + border-left-width: 1px; + } + /* Narrow cards: smaller text so neighbouring columns' labels don't run into each other. */ + @container (max-width: 400px) { + .wf .bar-label, + .wf .bar-name { + font-size: 0.6875em; + } + .wf.horizontal .names { + max-width: 96px; + } + } + .zero { + position: absolute; + pointer-events: none; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .wf.vertical .zero { + left: 0; + right: 0; + bottom: var(--a); + height: 1px; + } + .wf.horizontal .zero { + top: 0; + bottom: 0; + left: var(--a); + width: 1px; + } + `, + thumbnail: () => svg` + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + if (raw.steps !== undefined && !STEP_MODES.includes(raw.steps as StepMode)) { + throw new Error(`"steps" must be one of: ${STEP_MODES.join(', ')}`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as (typeof ORIENTATIONS)[number])) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + checkBoolean(raw.start_from_first, 'start_from_first'); + checkBoolean(raw.total, 'total'); + for (const e of raw.entities ?? []) { + if (typeof e === 'object') checkBoolean((e as { invert?: unknown }).invert, 'invert'); + } + }, + editor: waterfallEditor, + disconnect: disconnectHistory, + render(ctx) { + let frame: HistoryFrame | undefined; + if (needsHistory(ctx.raw)) { + frame = historyFrame(ctx); + const message = frameMessage(frame, ctx); + if (message) return message; + } + return drawing(ctx, placeBars(ctx, frame)); + }, +}; diff --git a/src/types.ts b/src/types.ts index cd67cde..dae88c6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/waterfall.test.ts b/test/waterfall.test.ts new file mode 100644 index 0000000..8031b51 --- /dev/null +++ b/test/waterfall.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { change, firstReading, signed, waterfallBars } from '../src/styles/waterfall'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.battery': ['6', { friendly_name: 'Battery', unit_of_measurement: 'kWh' }], + 'sensor.solar': ['10', { friendly_name: 'Solar', unit_of_measurement: 'kWh' }], + 'sensor.house': ['7', { friendly_name: 'House', unit_of_measurement: 'kWh' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.battery': [ + { s: '4', lu: sec(20 * H) }, + { s: '6', lu: sec(2 * H) }, + ], + 'sensor.solar': [ + { s: '4', lu: sec(20 * H) }, + { s: '10', lu: sec(3 * H) }, + ], + 'sensor.house': [ + { s: '3', lu: sec(20 * H) }, + { s: 'unavailable', lu: sec(10 * H) }, + { s: '7', lu: sec(1 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'waterfall', + entities: ['sensor.solar', 'sensor.house'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const text = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('waterfall maths', () => { + it('measures change from the first reading to the last, skipping gaps', () => { + expect( + change([ + { t: 0, v: null }, + { t: 1, v: 4 }, + { t: 2, v: null }, + { t: 3, v: 1.5 }, + { t: 4, v: null }, + ]), + ).toBe(-2.5); + expect(change([{ t: 0, v: null }])).toBeNull(); + expect( + firstReading([ + { t: 0, v: null }, + { t: 1, v: 3 }, + ]), + ).toBe(3); + expect(firstReading([])).toBeNull(); + }); + + it('carries a running total from the start through each step to the end', () => { + const { bars, domain } = waterfallBars( + { value: 4.2, index: 0 }, + [ + { value: 6.1, index: 1 }, + { value: -3.4, index: 2 }, + { value: null, index: 3 }, + ], + true, + ); + expect(bars.map((b) => [b.kind, b.from, +b.to.toFixed(2), b.index, b.missing])).toEqual([ + ['start', 0, 4.2, 0, false], + ['step', 4.2, 10.3, 1, false], + ['step', 10.3, 6.9, 2, false], + ['step', 6.9, 6.9, 3, true], + ['total', 0, 6.9, undefined, false], + ]); + expect(domain[0]).toBe(0); + expect(domain[1]).toBeCloseTo(10.3); + }); + + it('reaches below zero, and gives a flat chart some height', () => { + expect(waterfallBars(null, [{ value: -5, index: 0 }], false).domain).toEqual([-5, 0]); + expect(waterfallBars(null, [], false).domain).toEqual([0, 1]); + expect(waterfallBars({ value: null, index: 0 }, [], true).bars[0]).toMatchObject({ to: 0, missing: true }); + }); + + it('signs changes with a real minus', () => { + expect(signed(6.12, 1)).toBe('+6.1'); + expect(signed(-3.4, 1)).toBe('−3.4'); + expect(signed(-0.01, 1)).toBe('0.0'); + }); +}); + +describe('waterfall chart', () => { + it('draws a column per entity from its change over the period, then the total', async () => { + const card = await mount({}); + expect(text(card, '.bar-name')).toEqual(['Solar', 'House', 'Total']); + expect(text(card, '.bar-label')).toEqual(['+6.0', '+4.0', '10.0']); + const bars = qa(card, '.bar'); + expect(bars.map((b) => b.dataset.index)).toEqual(['0', '1', undefined]); + expect(bars[0].classList.contains('entity')).toBe(true); + expect(bars[2].classList.contains('entity')).toBe(false); + expect(qa(card, '.connector')).toHaveLength(2); + expect(qa(card, '.wf.vertical')).toHaveLength(1); + expect(qa(card, '.wf-plot')[0].getAttribute('role')).toBe('img'); + expect(qa(card, '.wf-plot')[0].getAttribute('aria-label')).toBe( + 'Waterfall chart: Solar +6.0 kWh, House +4.0 kWh, Total 10.0 kWh', + ); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('starts from the first entity, counts inverted entities down, and ends', async () => { + const card = await mount({ + start_from_first: true, + entities: ['sensor.battery', 'sensor.solar', { entity: 'sensor.house', invert: true }], + } as Partial); + expect(text(card, '.bar-name')).toEqual(['Start', 'Solar', 'House', 'End']); + expect(text(card, '.bar-label')).toEqual(['4.0', '+6.0', '−4.0', '6.0']); + const bars = qa(card, '.bar'); + expect(bars[0].dataset.index).toBe('0'); + expect(bars[2].classList.contains('fall')).toBe(true); + expect(bars[2].getAttribute('style')).toContain('--error-color'); + expect(bars[1].getAttribute('style')).toContain('--success-color'); + }); + + it('uses current values as rows without loading history, and can leave out the total', async () => { + const hass = hassWith(history); + const card = await mount( + { + steps: 'current', + orientation: 'horizontal', + total: false, + entities: ['sensor.solar', 'sensor.off', 'sensor.missing'], + } as Partial, + hass, + ); + expect(hass.callWS).not.toHaveBeenCalled(); + expect(qa(card, '.wf.horizontal')).toHaveLength(1); + expect(text(card, '.bar-label')).toEqual(['+10.0', 'Unavailable', 'Entity not found']); + expect(qa(card, '.bar.missing')).toHaveLength(2); + }); + + it('shows a dash for a column with no readings, and says when history is loading or failed', async () => { + const card = await mount({ entities: ['sensor.solar', 'sensor.off'] }); + expect(text(card, '.bar-label')).toEqual(['+6.0', '–', '6.0']); + expect(qa(card, '.bar')[1].getAttribute('aria-label')).toBe('Off, Unavailable'); + const loading = document.createElement('chartdeck-card') as Card; + loading.setConfig({ type: 'custom:chartdeck-card', style: 'waterfall', entities: ['sensor.solar'] } as CardConfig); + loading.hass = hassWith(history); + document.body.appendChild(loading); + await loading.updateComplete; + expect(text(loading, '.chart-message')).toEqual(['Loading history…']); + const failed = await mount({}, hassWith(history, true)); + expect(text(failed, '.chart-message.error')).toEqual(["Couldn't load history: boom"]); + }); + + it('says when an available entity has no readings in the period', async () => { + const card = await mount({ + steps: 'current', + start_from_first: true, + orientation: 'horizontal', + entities: ['sensor.off', 'sensor.solar'], + } as Partial); + expect(text(card, '.bar-label')[0]).toBe('No readings'); + }); + + it('opens more-info from a column', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const bar = qa(card, '.bar')[1]; + bar.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + bar.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.house']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'waterfall', entities: ['sensor.solar'], ...extra } as CardConfig); + expect(set({ steps: 'sum' })).toThrow(/"steps" must be one of: change, current/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ total: 'yes' })).toThrow(/"total" must be true or false/); + expect(set({ start_from_first: 1 })).toThrow(/start_from_first/); + expect(set({ entities: [{ entity: 'sensor.solar', invert: 'yes' }] })).toThrow(/invert/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ steps: 'current', orientation: 'horizontal', total: false })).not.toThrow(); + }); +}); + +describe('waterfall editor', () => { + it('writes period, steps, direction, start and total, and each entity counting down', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = hassWith({}); + const raw = { + type: 'custom:chartdeck-card', + style: 'waterfall', + entities: ['sensor.solar', { entity: 'sensor.house', invert: true }], + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form[data-section="chart"]') as Stub; + expect(form.data).toEqual({ + period_last: 24, + period_unit: 'hour', + source: 'history', + steps: 'change', + orientation: 'vertical', + start_from_first: false, + total: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { ...(form.data as object), orientation: 'horizontal', start_from_first: true, total: false }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'horizontal', start_from_first: true, total: false }); + + const list = ed.shadowRoot!.querySelector('.entity-options[data-section="waterfall_invert"] ha-form') as Stub; + expect(list.data).toEqual({ e0: false, e1: true }); + expect((list.computeLabel as (s: { name: string }) => string)({ name: 'e0' })).toBe('Solar'); + list.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { e0: true, e1: false } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.solar', invert: true }, 'sensor.house']); + }); +}); From 9acf0720a592f8dc6cd16bc29bcdf73c519b4bea Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:34:47 +0000 Subject: [PATCH 12/41] feat: slope chart comparing each entity then and now A new "Slope" style (Comparisons group in the style picker) draws one line per entity from its value at the start of the period to its value now, so it is easy to see which went up and which went down. Values come from history or long-term statistics, with the same period and source options as the line chart. With "Compare: how much it rose in each period" (measure: change) it is made for meters such as energy totals: the left end is how much the reading rose over the previous period and the right end how much over the latest one, so a one-month period gives "this month vs last month". The card loads twice the period for this; months step back by calendar month. A meter that resets inside a period gives a wrong rise, which is not corrected. Lines are coloured by direction (a rise red and a fall green, or the other way round with "A rise is: good"), or by group: each entity can set a group in its own options in the editor, entities in a group share a colour (an entity's own colour wins) and a legend lists the groups. The headings say "24 hours ago | Now" or "Previous month | Last month", the shared unit sits between them (or goes on each value when units differ), and the right-hand labels show the change in per cent, hidden on narrow cards. Labels are pushed apart so they never overlap; the dots stay at the true values. An entity with no reading at one end gets a lone dot; one with neither is left out; with nothing at all the chart says there are no readings. Every line, dot and label belongs to its entity's element, so tap, hold and double-tap actions work on any of them. Chart styles can now declare per-entity options (entityFields), which the entities editor shows in each entity's options. Small comparison maths shared with the other comparison charts lives in src/chart/compare.ts. Closes #28 --- src/styles/index.ts | 2 + src/styles/slope.ts | 486 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/slope.test.ts | 308 ++++++++++++++++++++++++++++ 4 files changed, 798 insertions(+), 2 deletions(-) create mode 100644 src/styles/slope.ts create mode 100644 test/slope.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 9f35107..3e6c194 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -30,6 +30,7 @@ import { groupedBars } from './grouped-bars'; import { bullet } from './bullet'; import { lollipop } from './lollipop'; import { waterfall } from './waterfall'; +import { slope } from './slope'; export interface RenderContext { show: ShowOptions; @@ -149,4 +150,5 @@ export const STYLES: Record = { bullet, lollipop, waterfall, + slope, }; diff --git a/src/styles/slope.ts b/src/styles/slope.ts new file mode 100644 index 0000000..e32c2ff --- /dev/null +++ b/src/styles/slope.ts @@ -0,0 +1,486 @@ +import { statisticsPeriod } from '../history/fetch'; +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { direction, extentOf, readingAt, relativeChange } from '../chart/compare'; +import { + SERIES_COLORS, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + validateHistoryOptions, +} from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { padDomain } from '../chart/scale'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber } from '../format'; +import { parsePeriod, rollingRange } from '../history/period'; +import type { Period } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +export const MEASURES = ['value', 'change'] as const; +export const SLOPE_COLOR_BY = ['direction', 'group'] as const; +export const INCREASE = ['bad', 'good'] as const; +type Measure = (typeof MEASURES)[number]; + +/** Vertical room one label needs, px. */ +const LABEL_GAP = 18; +/** Plot height: at least this, more when there are many entities. */ +const MIN_PLOT_H = 160; + +registerStrings('en', { + editor_style_slope: 'Slope', + editor_measure: 'Compare', + editor_measure_value: 'The reading then and now', + editor_measure_change: 'How much it rose in each period (meters)', + editor_color_by_direction: 'Up or down', + editor_color_by_group: 'By group', + editor_increase: 'A rise is', + editor_increase_bad: 'Bad (red)', + editor_increase_good: 'Good (green)', + editor_group: 'Group', + slope_now: 'Now', + slope_ago: '{n} {unit} ago', + slope_ago_one: 'A {unit} ago', + slope_previous: 'Previous {n} {unit}', + slope_previous_one: 'Previous {unit}', + slope_last: 'Last {n} {unit}', + slope_last_one: 'Last {unit}', + slope_unit_hour: 'hour', + slope_unit_hour_other: 'hours', + slope_unit_day: 'day', + slope_unit_day_other: 'days', + slope_unit_week: 'week', + slope_unit_week_other: 'weeks', + slope_unit_month: 'month', + slope_unit_month_other: 'months', + slope_entity_aria: '{name}, {from} to {to}', + slope_no_reading: 'no reading', +}); + +/** + * Spread label centres apart so none are closer than `gap`, keeping their order and staying + * within `lo`..`hi` where there is room. Returns the new positions in the input's order. + */ +export function spreadLabels(positions: number[], gap: number, lo: number, hi: number): number[] { + const order = positions.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y || a.i - b.i); + const ys = order.map((o) => Math.min(hi, Math.max(lo, o.y))); + for (let k = 1; k < ys.length; k++) ys[k] = Math.max(ys[k], ys[k - 1] + gap); + if (ys.length && ys[ys.length - 1] > hi) { + ys[ys.length - 1] = hi; + for (let k = ys.length - 2; k >= 0; k--) ys[k] = Math.min(ys[k], ys[k + 1] - gap); + } + // Too many to fit: start at the top and let the last ones run past the bottom. + if (ys.length && ys[0] < lo) { + ys[0] = lo; + for (let k = 1; k < ys.length; k++) ys[k] = Math.max(ys[k], ys[k - 1] + gap); + } + const out = new Array(positions.length); + order.forEach((o, k) => (out[o.i] = ys[k])); + return out; +} + +const STATISTICS_MS = { '5minute': 300_000, hour: 3_600_000, day: 86_400_000, month: 31 * 86_400_000 }; + +/** The two values a slope joins, for one entity's points. */ +export function slopeEnds( + measure: Measure, + points: { t: number; v: number | null }[], + times: { start: number; middle: number }, + now: number | null, + /** Statistics rows start on the hour (or day), just after the span starts: within this many ms, + * the first row stands in for the reading at the start. 0 for history. */ + slack = 0, +): [number | null, number | null] { + const first = points.find((p) => p.v !== null); + const start = readingAt(points, times.start) ?? (first && first.t - times.start <= slack ? first.v : null); + if (measure === 'value') return [start, now]; + const middle = readingAt(points, times.middle); + const before = start === null || middle === null ? null : middle - start; + const after = middle === null || now === null ? null : now - middle; + return [before, after]; +} + +/** The colour of a slope by which way it went: rises red unless a rise is good, flat grey. */ +export function directionColor(from: number, to: number, increase: 'bad' | 'good'): string { + const way = direction(from, to); + if (way === 'flat') return 'grey'; + return (way === 'up') === (increase === 'bad') ? 'red' : 'green'; +} + +function periodText(hass: HomeAssistant | undefined, key: 'ago' | 'previous' | 'last', period: Period): string { + const one = period.last === 1; + const unit = localize(hass, `slope_unit_${period.unit}${one ? '' : '_other'}`); + return localize(hass, one ? `slope_${key}_one` : `slope_${key}`, { n: period.last, unit }); +} + +/** "+12 %" / "−20 %", or '' when a percentage means nothing. */ +function changeText(from: number, to: number, hass: HomeAssistant): string { + const rel = relativeChange(from, to); + if (rel === undefined) return ''; + const pct = Math.round(rel * 100); + const sign = pct > 0 ? '+' : pct < 0 ? '−' : ''; + return `${sign}${formatNumber(Math.abs(pct), 0, hass)} %`; +} + +interface Row { + index: number; + entity: ResolvedEntity; + from: number | null; + to: number | null; + color: string; + group?: string; +} + +function groupOf(e: ResolvedEntity): string | undefined { + const g = (e.config as { group?: unknown }).group; + return typeof g === 'string' && g.trim() !== '' ? g.trim() : undefined; +} + +function rowsFor(frame: HistoryFrame, ctx: ChartContext, measure: Measure, times: { start: number; middle: number }) { + const slack = frame.source === 'statistics' ? STATISTICS_MS[statisticsPeriod(frame.range)] : 0; + const byGroup = ctx.raw.color_by === 'group'; + const increase = ctx.raw.increase === 'good' ? 'good' : 'bad'; + const groupColors = new Map(); + const rows: Row[] = frame.series.map((s) => { + const now = s.entity.available ? s.entity.value : null; + const [from, to] = slopeEnds(measure, s.points, times, now, slack); + const group = groupOf(s.entity); + const own = (s.entity.config as { color?: string }).color; + let color: string; + if (byGroup) { + const k = group ?? `#${s.index}`; + if (!groupColors.has(k)) groupColors.set(k, SERIES_COLORS[groupColors.size % SERIES_COLORS.length]); + color = own ?? groupColors.get(k)!; + } else { + color = from !== null && to !== null ? directionColor(from, to, increase) : 'grey'; + } + return { index: s.index, entity: s.entity, from, to, color: baseColor(color), group }; + }); + return rows; +} + +function legend(rows: Row[]): TemplateResult | typeof nothing { + const seen = new Map(); + for (const r of rows) if (r.group && !seen.has(r.group)) seen.set(r.group, r.color); + if (!seen.size) return nothing; + return html`
+ ${[...seen].map( + ([name, color]) => + html`${name}`, + )} +
`; +} + +const FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'measure', path: ['measure'], default: 'value' }, + { name: 'color_by', path: ['color_by'], default: 'direction' }, + { name: 'increase', path: ['increase'], default: 'bad' }, +]; + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'group', path: ['group'] }]; + +export const slope: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 230, + styles: css` + ${frameStyles} + .style-slope .slope-head, + .style-slope .slope-plot { + --lw: min(36%, 180px); + } + .slope-head { + display: grid; + grid-template-columns: var(--lw) 1fr var(--lw); + gap: 0 8px; + padding: 4px 0 6px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-head .from { + grid-column: 1; + text-align: right; + } + .slope-head .to { + grid-column: 3; + } + .slope-head .unit { + grid-column: 2; + text-align: center; + } + .slope-plot { + position: relative; + margin: 8px 0; + } + .slope-plot .axis { + position: absolute; + top: -6px; + bottom: -6px; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .slope-plot .axis.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .axis.to { + right: calc(var(--lw) + 8px); + } + .slope-plot .entity { + position: absolute; + inset: 0; + pointer-events: none; + outline: none; + opacity: 1; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .slope-plot .entity { + opacity: 0; + } + .slope-plot .entity > * { + pointer-events: auto; + cursor: pointer; + } + .slope-plot .entity:focus-visible .lbl { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + border-radius: 3px; + } + .slope-plot svg { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--lw) + 8px); + right: calc(var(--lw) + 8px); + width: calc(100% - 2 * var(--lw) - 16px); + height: 100%; + overflow: visible; + pointer-events: none; + } + .slope-plot svg line { + vector-effect: non-scaling-stroke; + stroke-width: 3; + stroke-linecap: round; + pointer-events: stroke; + } + .slope-plot .dot { + position: absolute; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border-radius: 50%; + } + .slope-plot .dot.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .dot.to { + left: calc(100% - var(--lw) - 8px); + } + .slope-plot .lbl { + position: absolute; + transform: translateY(-50%); + width: var(--lw); + display: flex; + gap: 4px; + align-items: baseline; + font-size: 0.875em; + line-height: 1.2; + white-space: nowrap; + min-width: 0; + } + .slope-plot .lbl.from { + left: 0; + justify-content: flex-end; + } + .slope-plot .lbl.to { + right: 0; + } + .slope-plot .lbl .name { + color: var(--secondary-text-color); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .slope-plot .lbl .value { + font-weight: 500; + flex: none; + } + .slope-plot .lbl .pct { + flex: none; + padding-left: 2px; + font-size: 0.9em; + } + /* Narrow cards: the change column would squeeze the names to nothing. */ + @container (max-width: 380px) { + .slope-plot .lbl .pct { + display: none; + } + } + .slope-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 4px 0; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + `, + thumbnail: () => svg` + + + + ${[ + [14, 28], + [50, 10], + [14, 12], + [50, 22], + [14, 32], + [50, 30], + ].map(([x, y]) => svg``)}`, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + const check = (key: string, values: readonly string[]) => { + if (raw[key] !== undefined && !values.includes(raw[key] as string)) { + throw new Error(`"${key}" must be one of: ${values.join(', ')}`); + } + }; + check('measure', MEASURES); + check('color_by', SLOPE_COLOR_BY); + check('increase', INCREASE); + for (const e of raw.entities ?? []) { + const group = typeof e === 'object' && e ? (e as { group?: unknown }).group : undefined; + if (group !== undefined && typeof group !== 'string') throw new Error('"group" must be text'); + } + }, + editor(ctx) { + const schema = cachedSchema('slope', ctx.hass, () => [ + ...historySchema(ctx.hass), + { name: 'measure', selector: choice(ctx.hass, 'measure', MEASURES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'color_by', selector: choice(ctx.hass, 'color_by', SLOPE_COLOR_BY) }, + { name: 'increase', selector: choice(ctx.hass, 'increase', INCREASE) }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); + }, + entityFields: { + schema: (hass) => cachedSchema('slope-entity', hass, () => [{ name: 'group', selector: { text: {} } }]), + fields: ENTITY_FIELDS, + }, + disconnect: disconnectHistory, + render(ctx) { + const now = Date.now(); + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + const measure: Measure = ctx.raw.measure === 'change' ? 'change' : 'value'; + // Change needs the period before this one too: load twice as far back. + const frameCtx = + measure === 'change' + ? { ...ctx, raw: { ...ctx.raw, period: { last: period.last * 2, unit: period.unit } } } + : ctx; + const frame = historyFrame(frameCtx, now); + const middle = rollingRange(period, now).start; + const times = { start: frame.range.start, middle }; + const hass = ctx.hass; + const fromText = measure === 'change' ? periodText(hass, 'previous', period) : periodText(hass, 'ago', period); + const toText = measure === 'change' ? periodText(hass, 'last', period) : localize(hass, 'slope_now'); + if (frame.status !== 'ready') return html`${frameMessage(frame, ctx)}`; + const rows = rowsFor(frame, ctx, measure, times); + const values = rows.flatMap((r) => [r.from, r.to]); + const ext = extentOf(values); + if (!ext) return html`
${localize(hass, 'chart_no_data')}
`; + const { show } = ctx.config; + // Missing entities have no unit; they shouldn't stop the others sharing one. + const units = new Set(rows.filter((r) => r.entity.stateObj).map((r) => r.entity.unit)); + const sharedUnit = units.size === 1 ? [...units][0] : undefined; + const fmt = (r: Row, v: number) => { + const n = formatNumber(v, r.entity.decimals, hass); + return sharedUnit !== undefined || !r.entity.unit ? n : `${n} ${r.entity.unit}`; + }; + + const drawn = rows.filter((r) => r.from !== null || r.to !== null); + const height = Math.max(MIN_PLOT_H, drawn.length * (LABEL_GAP + 4)); + const [lo, hi] = padDomain(ext, 0.06); + const pad = LABEL_GAP / 2; + const y = (v: number) => pad + ((hi - v) / (hi - lo)) * (height - 2 * pad); + const side = (pick: (r: Row) => number | null) => { + const list = drawn.filter((r) => pick(r) !== null); + const spread = spreadLabels( + list.map((r) => y(pick(r)!)), + LABEL_GAP, + pad, + height - pad, + ); + return new Map(list.map((r, k) => [r, spread[k]])); + }; + const leftY = side((r) => r.from); + const rightY = side((r) => r.to); + const noReading = localize(hass, 'slope_no_reading'); + + const entity = (r: Row) => { + const aria = localize(hass, 'slope_entity_aria', { + name: r.entity.name, + from: r.from === null ? noReading : fmt(r, r.from), + to: r.to === null ? noReading : fmt(r, r.to), + }); + const pct = show.value && r.from !== null && r.to !== null ? changeText(r.from, r.to, hass) : ''; + const name = (n: string) => (show.name ? html`${n}` : nothing); + return html``; + }; + + const label = localize(hass, 'chart_aria', { + style: localize(hass, 'editor_style_slope'), + names: rows.map((r) => r.entity.name).join(', '), + }); + return html`
+ ${fromText} + ${sharedUnit ? html`${sharedUnit}` : nothing} + ${toText} +
+
+
+
+ ${drawn.map(entity)} +
+ ${ctx.raw.color_by === 'group' ? legend(rows) : nothing}`; + }, +}; diff --git a/src/types.ts b/src/types.ts index dae88c6..05136b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/slope.test.ts b/test/slope.test.ts new file mode 100644 index 0000000..989f651 --- /dev/null +++ b/test/slope.test.ts @@ -0,0 +1,308 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { directionColor, slopeEnds, spreadLabels } from '../src/styles/slope'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +describe('slope maths', () => { + it('spreads labels apart, keeping order and staying in bounds', () => { + expect(spreadLabels([50, 52, 100], 10, 0, 200)).toEqual([50, 60, 100]); + // Input order is kept in the output; ties keep their input order. + expect(spreadLabels([52, 50], 10, 0, 200)).toEqual([60, 50]); + expect(spreadLabels([10, 10], 10, 0, 200)).toEqual([10, 20]); + // Pushed past the bottom: the stack moves up. + expect(spreadLabels([195, 198, 199], 10, 0, 200)).toEqual([180, 190, 200]); + // Below the top edge: clamped. + expect(spreadLabels([-20], 10, 0, 200)).toEqual([0]); + // More than fit: start at the top and run on. + expect(spreadLabels([5, 5, 5], 10, 0, 15)).toEqual([0, 10, 20]); + expect(spreadLabels([], 10, 0, 100)).toEqual([]); + }); + + it('takes the reading then and now, or the rise over each period', () => { + const points = [ + { t: 0, v: 10 }, + { t: 50, v: 16 }, + { t: 70, v: null }, + ]; + const times = { start: 0, middle: 60 }; + expect(slopeEnds('value', points, times, 30)).toEqual([10, 30]); + expect(slopeEnds('value', [], times, 30)).toEqual([null, 30]); + expect(slopeEnds('change', points, times, 30)).toEqual([6, 14]); + expect(slopeEnds('change', points, times, null)).toEqual([6, null]); + expect(slopeEnds('change', points, { start: -5, middle: 60 }, 30)).toEqual([null, 14]); + }); + + it('colours by direction', () => { + expect(directionColor(1, 2, 'bad')).toBe('red'); + expect(directionColor(2, 1, 'bad')).toBe('green'); + expect(directionColor(1, 2, 'good')).toBe('green'); + expect(directionColor(2, 1, 'good')).toBe('red'); + expect(directionColor(2, 2, 'good')).toBe('grey'); + }); +}); + +const history = { + 'sensor.a': [ + { s: '10', lu: sec(1700 * H) }, + { s: '10', lu: sec(30 * H) }, + { s: '16', lu: sec(18 * H) }, + ], + 'sensor.b': [{ s: '50', lu: sec(1700 * H) }], + 'sensor.u': [{ s: '5', lu: sec(30 * H) }], +}; + +function hassWith(data: Record = history, fail = false) { + const hass = makeHass({ + 'sensor.a': ['30', { friendly_name: 'Alpha', unit_of_measurement: 'kWh' }], + 'sensor.b': ['40', { friendly_name: 'Beta', unit_of_measurement: 'kWh' }], + 'sensor.u': ['unavailable', { friendly_name: 'Gone quiet', unit_of_measurement: 'kWh' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return data; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'slope', + entities: ['sensor.a', 'sensor.b'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); +/** A label's parts (name, value, change), spaced the way the flex gap shows them. */ +const lbl = (el: Element | null) => [...el!.children].map((c) => c.textContent!.trim()).join(' '); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('slope chart', () => { + it('joins the reading a period ago to now, coloured by which way it went', async () => { + const card = await mount({}); + const entities = qa(card, '.slope-plot .entity'); + expect(entities.map((e) => e.getAttribute('data-index'))).toEqual(['0', '1']); + const [a, b] = entities.map((e) => e.querySelector('line')!); + expect(a.getAttribute('stroke')).toContain('--error-color'); + expect(b.getAttribute('stroke')).toContain('--success-color'); + expect(Number(a.getAttribute('y1'))).toBeGreaterThan(Number(a.getAttribute('y2'))); + expect(lbl(entities[0].querySelector('.lbl.from'))).toBe('Alpha 10.0'); + expect(lbl(entities[0].querySelector('.lbl.to'))).toBe('30.0 Alpha +200 %'); + expect(lbl(entities[1].querySelector('.lbl.to'))).toBe('40.0 Beta −20 %'); + expect(entities[0].getAttribute('aria-label')).toBe('Alpha, 10.0 to 30.0'); + expect(text(q(card, '.slope-head'))).toBe('24 hours ago kWh Now'); + expect(q(card, '.slope-plot')!.getAttribute('aria-label')).toBe('Slope chart of Alpha, Beta'); + expect(q(card, '.slope-legend')).toBeNull(); + }); + + it('turns the colours round when a rise is good, and hides names or values when asked', async () => { + const card = await mount({ increase: 'good', show: { name: false }, period: { last: 1, unit: 'week' } }); + const [a, b] = qa(card, '.slope-plot line'); + expect(a.getAttribute('stroke')).toContain('--success-color'); + expect(b.getAttribute('stroke')).toContain('--error-color'); + expect(qa(card, '.lbl .name')).toHaveLength(0); + expect(text(q(card, '.slope-head .from'))).toBe('A week ago'); + const plain = await mount({ show: { value: false } }); + expect(qa(plain, '.lbl .value')).toHaveLength(0); + expect(qa(plain, '.lbl .pct')).toHaveLength(0); + }); + + it('colours by group, sharing a colour within a group, with a legend', async () => { + const card = await mount({ + color_by: 'group', + entities: [ + { entity: 'sensor.a', group: 'Kitchen' }, + { entity: 'sensor.b', group: 'Kitchen' }, + { entity: 'sensor.u', group: 'Garage', color: '#123456' }, + ], + } as Partial); + const [a, b] = qa(card, '.slope-plot line'); + expect(a.getAttribute('stroke')).toBe(b.getAttribute('stroke')); + expect(a.getAttribute('stroke')).toContain('--primary-color'); + expect(qa(card, '.slope-legend-item').map(text)).toEqual(['Kitchen', 'Garage']); + // Ungrouped entities each get their own colour and no legend. + const solo = await mount({ color_by: 'group' }); + const [x, y] = qa(solo, '.slope-plot line'); + expect(x.getAttribute('stroke')).not.toBe(y.getAttribute('stroke')); + expect(q(solo, '.slope-legend')).toBeNull(); + }); + + it('compares the rise over the previous period with the latest one', async () => { + const card = await mount({ measure: 'change', period: { last: 12, unit: 'hour' } }); + const first = q(card, '.slope-plot .entity')!; + // 10 → 16 in the previous 12 hours, 16 → 30 in the last 12. + expect(lbl(first.querySelector('.lbl.from'))).toBe('Alpha 6.0'); + expect(lbl(first.querySelector('.lbl.to'))).toBe('14.0 Alpha +133 %'); + expect(text(q(card, '.slope-head'))).toBe('Previous 12 hours kWh Last 12 hours'); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(Date.parse(call.end_time) - Date.parse(call.start_time)).toBeCloseTo(24 * H, -4); + const monthly = await mount({ measure: 'change', period: { last: 1, unit: 'month' } }); + expect(text(q(monthly, '.slope-head'))).toBe('Previous month kWh Last month'); + }); + + it('draws a lone dot for an entity without a reading at one end, and leaves out ones with none', async () => { + const card = await mount({ entities: ['sensor.a', 'sensor.u', 'sensor.missing'] }); + const entities = qa(card, '.slope-plot .entity'); + expect(entities.map((e) => e.getAttribute('data-index'))).toEqual(['0', '1']); + const quiet = entities[1]; + expect(quiet.querySelector('line')).toBeNull(); + expect(quiet.querySelector('.dot.from')).not.toBeNull(); + expect(quiet.querySelector('.dot.to')).toBeNull(); + expect(quiet.getAttribute('aria-label')).toBe('Gone quiet, 5.0 to no reading'); + }); + + it('adds units to values when the entities differ', async () => { + const hass = hassWith(); + hass.states['sensor.b'].attributes.unit_of_measurement = 'W'; + const card = await mount({}, hass); + expect(q(card, '.slope-head .unit')).toBeNull(); + expect(lbl(q(card, '.lbl.from'))).toBe('Alpha 10.0 kWh'); + }); + + it('says when history is loading, failed, or empty', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'slope', entities: ['sensor.a'] } as CardConfig); + card.hass = hassWith(); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({ entities: ['sensor.u', 'sensor.missing'] }, hassWith({})); + expect(text(q(empty, '.chart-message'))).toBe('No readings in this period.'); + }); + + it('runs the entity actions from any part of a slope', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const dot = qa(card, '.slope-plot .entity')[1].querySelector('.dot.to') as HTMLElement; + dot.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + dot.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.b']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'slope', entities: ['sensor.a'], ...extra } as CardConfig); + expect(set({ measure: 'ratio' })).toThrow(/"measure" must be one of: value, change/); + expect(set({ color_by: 'series' })).toThrow(/"color_by" must be one of: direction, group/); + expect(set({ increase: 'meh' })).toThrow(/"increase"/); + expect(set({ entities: [{ entity: 'sensor.a', group: 3 }] })).toThrow(/"group" must be text/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ measure: 'change', color_by: 'group', increase: 'good' })).not.toThrow(); + }); +}); + +describe('slope chart editor', () => { + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events }; + } + + it('writes period, measure and colouring from the Chart section', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'slope', entities: ['sensor.a'] } as CardConfig; + const { ed, events } = await editor(raw); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_last: 24, + period_unit: 'hour', + source: 'history', + measure: 'value', + color_by: 'direction', + increase: 'bad', + }); + const names = JSON.stringify(form.schema); + for (const name of ['measure', 'color_by', 'increase', 'period_last']) expect(names).toContain(`"${name}"`); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), measure: 'change', color_by: 'group', period_unit: 'month' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, measure: 'change', color_by: 'group', period: { unit: 'month' } }); + }); + + it("puts each entity's group in its own options", async () => { + const raw = { + type: 'custom:chartdeck-card', + style: 'slope', + entities: [{ entity: 'sensor.a', group: 'Kitchen' }], + } as CardConfig; + const { ed, events } = await editor(raw); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as HTMLElement & { + updateComplete: Promise; + shadowRoot: ShadowRoot; + }; + await list.updateComplete; + (list.shadowRoot.querySelector('.expand') as HTMLElement).click(); + await list.updateComplete; + const form = list.shadowRoot.querySelector('ha-form[data-section="entity-chart"]') as Stub; + expect(form).not.toBeNull(); + expect(form.data).toEqual({ group: 'Kitchen' }); + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { group: 'Garage' } } })); + expect(events.at(-1)!.entities).toEqual([{ entity: 'sensor.a', group: 'Garage' }]); + }); + + it('shows no per-entity chart options for a style without any', async () => { + const { ed } = await editor({ type: 'custom:chartdeck-card', style: 'bar', entities: ['sensor.a'] } as CardConfig); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect(list.chartFields).toBeUndefined(); + }); + + it('lets the first statistics row stand in for the start when it begins just after it', () => { + const points = [ + { t: 1_800_000, v: 10 }, + { t: 5_400_000, v: 12 }, + ]; + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20)).toEqual([null, 20]); + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20, 3_600_000)).toEqual([10, 20]); + expect(slopeEnds('value', points, { start: 0, middle: 0 }, 20, 1_000_000)).toEqual([null, 20]); + }); +}); From 06e91571c44cf3549b71c6ac849c134e03ac5318 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 08:38:06 +0000 Subject: [PATCH 13/41] feat: dumbbell chart comparing two or three values per entity A new "Dumbbell" style in the Comparisons group draws one row per entity with its values as dots on a shared axis, joined by a bar. What the dots are is chosen with `ends`: - range (default): the lowest and highest reading over the period, from history or long-term statistics (their min and max count), with the current value marked between them as a ring (`show_now`, on by default). The range is stretched to take in the current value, which can be newer than the last stored reading. - then: the reading at the start of the period against the current value, e.g. now against a week ago. With statistics, whose first row can start after the period does, the first reading stands in. - target: the entity's target (a number or an entity) against the current value, for set point against actual. This mode loads no history and draws straight away. `orientation: vertical` draws columns instead of rows. The axis has round tick values; the unit is shown once, beside the legend, when every entity shares it, and narrow cards show every other tick label. Low is drawn in the theme's info colour, high in its error colour, the start of the period in grey, and a target or the current value in range mode as a hollow ring; otherwise the current value takes the entity's colour band. Every row is the entity's tap target, with a spoken description of its values. Unavailable and missing entities keep their row with their status text. Loading and failed history use the same messages as the line chart. In the editor, the Chart section sets what to compare, rows or columns, the period and source (hidden for a target) and the current-value mark (range only). src/chart/compare.ts holds round-tick and reading-at-a-time maths shared with the other comparison charts. Closes #27 --- src/styles/dumbbell.ts | 616 +++++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/dumbbell.test.ts | 282 +++++++++++++++++++ 4 files changed, 902 insertions(+), 2 deletions(-) create mode 100644 src/styles/dumbbell.ts create mode 100644 test/dumbbell.test.ts diff --git a/src/styles/dumbbell.ts b/src/styles/dumbbell.ts new file mode 100644 index 0000000..e132fa0 --- /dev/null +++ b/src/styles/dumbbell.ts @@ -0,0 +1,616 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { extentOf, lowHigh, niceTicks, readingAt } from '../chart/compare'; +import type { TimedValue } from '../chart/compare'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { parsePeriod } from '../history/period'; +import type { Period } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant } from '../types'; + +export const ENDS = ['range', 'then', 'target'] as const; +export type Ends = (typeof ENDS)[number]; +export const ORIENTATIONS = ['horizontal', 'vertical'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +registerStrings('en', { + editor_style_dumbbell: 'Dumbbell', + editor_ends: 'Compare', + editor_ends_range: 'Lowest and highest in the period', + editor_ends_then: 'Start of the period and now', + editor_ends_target: 'Target and now', + editor_show_now: 'Mark the current value', + editor_orientation: 'Direction', + editor_orientation_horizontal: 'Rows', + editor_orientation_vertical: 'Columns', + dumbbell_low: 'Low', + dumbbell_high: 'High', + dumbbell_now: 'Now', + dumbbell_target: 'Target', + dumbbell_ago_hour_one: 'An hour ago', + dumbbell_ago_hour: '{n} hours ago', + dumbbell_ago_day_one: 'A day ago', + dumbbell_ago_day: '{n} days ago', + dumbbell_ago_week_one: 'A week ago', + dumbbell_ago_week: '{n} weeks ago', + dumbbell_ago_month_one: 'A month ago', + dumbbell_ago_month: '{n} months ago', + dumbbell_no_value: 'No value', +}); + +/** What a mark on a row stands for. */ +export type MarkKind = 'low' | 'high' | 'now' | 'then' | 'target'; + +export interface Mark { + kind: MarkKind; + value: number; +} + +/** One entity's row: its marks, lowest first, and which of them get a value label. */ +export interface DumbbellRow { + index: number; + entity: ResolvedEntity; + marks: Mark[]; + /** The marks labelled at the low and high ends (the same mark when there is only one). */ + labelled: Mark[]; +} + +/** The earliest reading in a period when none was in force at its start (statistics start late). */ +function firstReading(points: TimedValue[]): number | null { + for (const p of points) if (p.v !== null) return p.v; + return null; +} + +/** + * The marks for one entity. `range`: lowest and highest over the period (stretched to take in + * the current value, which may be newer than the last stored reading), plus the current value + * when `showNow`. `then`: the reading at the start of the period and now. `target`: the entity's + * target and now. Anything missing is left out, so a row may have one mark or none. + */ +export function rowMarks(e: ResolvedEntity, ends: Ends, points: TimedValue[], start: number, showNow: boolean): Mark[] { + const now = e.available ? e.value : null; + const marks: Mark[] = []; + if (ends === 'range') { + const lh = lowHigh(points); + const span = lh ? extentOf([lh[0], lh[1], now]) : undefined; + if (span) marks.push({ kind: 'low', value: span[0] }, { kind: 'high', value: span[1] }); + if (now !== null && (showNow || !span)) marks.push({ kind: 'now', value: now }); + } else { + const other = ends === 'then' ? (readingAt(points, start) ?? firstReading(points)) : e.target; + if (other !== null) marks.push({ kind: ends, value: other }); + if (now !== null) marks.push({ kind: 'now', value: now }); + } + // Stable sort keeps low before high when they are equal. + return marks.sort((a, b) => a.value - b.value); +} + +/** + * The marks that carry a value label: the lowest and highest. In range mode with low and high, + * the current value sits between them unlabelled (it is in the row's description). + */ +export function labelledMarks(marks: Mark[]): Mark[] { + if (marks.length === 0) return []; + const ends = marks.some((m) => m.kind === 'low') ? marks.filter((m) => m.kind !== 'now') : marks; + const lo = ends[0]; + const hi = ends[ends.length - 1]; + return lo === hi ? [hi] : [lo, hi]; +} + +export function dumbbellRows( + items: ResolvedEntity[], + ends: Ends, + pointsFor: (index: number) => TimedValue[], + start: number, + showNow: boolean, +): DumbbellRow[] { + return items.map((entity, index) => { + const marks = rowMarks(entity, ends, pointsFor(index), start, showNow); + return { index, entity, marks, labelled: labelledMarks(marks) }; + }); +} + +/** Decimals a tick step needs: 0 for whole steps, 1 for 0.5, 2 for 0.25. */ +export function stepDecimals(ticks: number[]): number { + if (ticks.length < 2) return 0; + const step = Math.abs(ticks[1] - ticks[0]); + for (let d = 0; d < 6; d++) if (Math.abs(Math.round(step * 10 ** d) - step * 10 ** d) < 1e-6) return d; + return 6; +} + +/** "A week ago", "3 days ago": the label for the start of the period. */ +export function agoText(hass: HomeAssistant | undefined, period: Period): string { + return period.last === 1 + ? localize(hass, `dumbbell_ago_${period.unit}_one`) + : localize(hass, `dumbbell_ago_${period.unit}`, { n: period.last }); +} + +const MARK_COLORS: Record, string> = { + low: baseColor('blue'), + high: baseColor('red'), + then: baseColor('grey'), + target: 'var(--primary-text-color, #212121)', +}; + +/** Hollow rings: range's current value, and a target. */ +function hollow(kind: MarkKind, ends: Ends): boolean { + return kind === 'target' || (kind === 'now' && ends === 'range'); +} + +function markColor(kind: MarkKind, ends: Ends, e: ResolvedEntity): string { + if (kind !== 'now') return MARK_COLORS[kind]; + return ends === 'range' ? 'var(--primary-text-color, #212121)' : baseColor(e.band.color); +} + +const DEFAULTS = { ends: 'range' as Ends, orientation: 'horizontal' as Orientation, showNow: true }; + +function optionsOf(raw: CardConfig) { + return { + ends: (raw.ends as Ends | undefined) ?? DEFAULTS.ends, + orientation: (raw.orientation as Orientation | undefined) ?? DEFAULTS.orientation, + showNow: raw.show_now === undefined ? DEFAULTS.showNow : raw.show_now === true, + }; +} + +/** One unit for the axis when every entity shares it, else none. */ +function sharedUnit(items: ResolvedEntity[]): string { + const units = new Set(items.map((e) => e.unit).filter((u) => u !== '')); + return units.size === 1 ? [...units][0] : ''; +} + +function legend(ctx: ChartContext, ends: Ends, showNow: boolean, rows: DumbbellRow[], period: Period, unit: string) { + const t = (k: string) => localize(ctx.hass, k); + const bands = new Set(rows.map((r) => baseColor(r.entity.band.color))); + const nowFill = bands.size === 1 ? [...bands][0] : baseColor('primary'); + const items: [string, string, boolean][] = + ends === 'range' + ? [ + [t('dumbbell_low'), MARK_COLORS.low, false], + ...(showNow + ? [[t('dumbbell_now'), markColor('now', ends, rows[0].entity), true] as [string, string, boolean]] + : []), + [t('dumbbell_high'), MARK_COLORS.high, false], + ] + : [ + ends === 'then' + ? [agoText(ctx.hass, period), MARK_COLORS.then, false] + : [t('dumbbell_target'), MARK_COLORS.target, true], + [t('dumbbell_now'), nowFill, false], + ]; + return html`
+ ${unit ? html`${unit}` : nothing} + ${items.map( + ([text, color, ring]) => + html`${text}`, + )} +
`; +} + +function describe(row: DumbbellRow, ctx: ChartContext, period: Period): string { + const e = row.entity; + if (row.marks.length === 0) return `${e.name}, ${e.available ? localize(ctx.hass, 'dumbbell_no_value') : e.display}`; + const name = (k: MarkKind) => (k === 'then' ? agoText(ctx.hass, period) : localize(ctx.hass, `dumbbell_${k}`)); + const parts = row.marks.map( + (m) => `${name(m.kind)} ${withUnit(formatNumber(m.value, e.decimals, ctx.hass), e.unit)}`, + ); + return `${e.name}: ${parts.join(', ')}`; +} + +function renderRow( + row: DumbbellRow, + ctx: ChartContext, + ends: Ends, + at: (v: number) => number, + ticks: number[], + period: Period, +): TemplateResult { + const e = row.entity; + const { show } = ctx.config; + // The labelled ends: low and high in range mode, so a current value equal to one of them + // doesn't take over the bar's colour. + const lo = row.labelled[0]; + const hi = row.labelled[row.labelled.length - 1]; + const fill = (m: Mark) => + hollow(m.kind, ends) + ? 'color-mix(in srgb, var(--primary-text-color, #212121) 30%, transparent)' + : markColor(m.kind, ends, e); + return html``; +} + +const FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'ends', path: ['ends'], default: DEFAULTS.ends }, + { name: 'show_now', path: ['show_now'], default: DEFAULTS.showNow }, + { name: 'orientation', path: ['orientation'], default: DEFAULTS.orientation }, +]; + +export function dumbbellEditor(ctx: ChartEditorContext): TemplateResult { + const { ends } = optionsOf(ctx.config); + const schema = cachedSchema(`dumbbell-${ends}`, ctx.hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'ends', selector: choice(ctx.hass, 'ends', ENDS) }, + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + ], + }, + // A target needs no history. + ...(ends === 'target' ? [] : historySchema(ctx.hass)), + ...(ends === 'range' ? [{ name: 'show_now', selector: { boolean: {} } }] : []), + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +export const dumbbell: ChartStyleModule = { + layout: 'chart', + group: 'compare', + height: 220, + styles: css` + ${frameStyles} + .db { + --pad: 40px; + --span: calc(100% - 2 * var(--pad)); + --dot: 14px; + display: flex; + flex-direction: column; + } + .db.no-values { + --pad: 12px; + } + .db-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 4px 14px; + padding: 2px 0 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .db-unit { + margin-right: auto; + } + /* Narrow cards: every other tick label, so they don't run together. */ + @container (max-width: 400px) { + .db.horizontal .db-tick:nth-child(even) { + visibility: hidden; + } + } + .db-key { + display: inline-flex; + align-items: center; + gap: 6px; + } + .db-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + } + .db-swatch.ring, + .db-dot.ring { + background: var(--card-background-color, #fff); + border: 2.5px solid var(--mark); + } + .db-row { + position: relative; + cursor: pointer; + border-radius: 6px; + outline: none; + } + .db-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .db-name { + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .db-plot { + position: relative; + } + .db-grid, + .db-bar, + .db-dot, + .db-label, + .db-tick { + position: absolute; + } + .db-dot { + width: var(--dot); + height: var(--dot); + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + z-index: 1; + } + .db-dot.now.ring { + z-index: 2; + } + .db-label, + .db-tick, + .db-status { + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + line-height: 1; + } + .db-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + .db-bar, + .db-dot, + .db-label { + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .db-bar, + :host(:not([ready])) .db-dot, + :host(:not([ready])) .db-label { + opacity: 0; + } + + /* Rows: value across, one entity per row. */ + .db.horizontal .db-row { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + align-items: center; + column-gap: 8px; + height: 36px; + } + .db.horizontal.no-names .db-row, + .db.horizontal.no-names .db-axis { + grid-template-columns: minmax(0, 1fr); + } + .db.horizontal .db-plot { + height: 100%; + } + .db.horizontal .db-grid { + top: 0; + bottom: 0; + left: calc(var(--pad) + var(--span) * var(--f)); + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.horizontal .db-bar { + top: 50%; + height: 6px; + transform: translateY(-50%); + border-radius: 3px; + left: calc(var(--pad) + var(--span) * var(--a)); + width: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to right, var(--from), var(--to)); + opacity: 0.45; + } + .db.horizontal .db-dot { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, -50%); + } + .db.horizontal .db-label { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + } + .db.horizontal .db-label.lo { + transform: translate(calc(-100% - 11px), -50%); + } + .db.horizontal .db-label.hi { + transform: translate(11px, -50%); + } + .db.horizontal .db-axis { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + column-gap: 8px; + height: 22px; + } + .db.horizontal .db-axis .db-plot { + grid-column: -2; + } + .db.horizontal .db-tick { + top: 6px; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translateX(-50%); + } + + /* Columns: value up, one entity per column. */ + .db.vertical .db-body { + display: flex; + } + .db.vertical .db-row { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column-reverse; + align-items: stretch; + } + .db.vertical .db-name { + text-align: center; + font-size: 0.8125em; + padding-top: 4px; + height: 1.4em; + } + .db.vertical .db-plot { + height: 180px; + } + /* Columns are narrow and tall: status text runs up the column. */ + .db.vertical .db-status { + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + } + .db.vertical { + --pad: 22px; + } + .db.vertical .db-grid { + left: 0; + right: 0; + bottom: calc(var(--pad) + var(--span) * var(--f)); + border-top: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.vertical .db-bar { + left: 50%; + width: 6px; + transform: translateX(-50%); + border-radius: 3px; + bottom: calc(var(--pad) + var(--span) * var(--a)); + height: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to top, var(--from), var(--to)); + opacity: 0.45; + } + .db.vertical .db-dot { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, 50%); + } + .db.vertical .db-label { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + } + .db.vertical .db-label.lo { + transform: translate(-50%, calc(100% + 11px)); + } + .db.vertical .db-label.hi { + transform: translate(-50%, -11px); + } + .db.vertical .db-axis { + flex: none; + position: relative; + width: 3.5em; + height: 180px; + } + .db.vertical .db-tick { + right: 6px; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translateY(50%); + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + if (raw.ends !== undefined && !ENDS.includes(raw.ends as Ends)) { + throw new Error(`"ends" must be one of: ${ENDS.join(', ')}`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + if (raw.show_now !== undefined && typeof raw.show_now !== 'boolean') { + throw new Error('"show_now" must be true or false'); + } + }, + editor: dumbbellEditor, + disconnect: disconnectHistory, + render(ctx) { + const { ends, orientation, showNow } = optionsOf(ctx.raw); + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + let rows: DumbbellRow[]; + if (ends === 'target') { + // Current values only: no history to load. + rows = dumbbellRows(ctx.items, ends, () => [], 0, showNow); + } else { + const frame = historyFrame(ctx); + if (frame.status !== 'ready') return frameMessage(frame, ctx) ?? html``; + rows = dumbbellRows(ctx.items, ends, (i) => frame.series[i]?.points ?? [], frame.range.start, showNow); + } + const span = extentOf(rows.flatMap((r) => r.marks.map((m) => m.value))); + const ticks = span ? niceTicks(span[0], span[1], 4) : []; + const [d0, d1] = ticks.length ? [ticks[0], ticks[ticks.length - 1]] : [0, 1]; + const at = (v: number) => (d1 === d0 ? 0.5 : (v - d0) / (d1 - d0)); + const unit = sharedUnit(ctx.items); + const decimals = stepDecimals(ticks); + const { show } = ctx.config; + const longest = Math.max(...ctx.items.map((e) => e.name.length)); + const nameW = `min(${Math.min(longest, 16) + 1}ch, 38%)`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_dumbbell'), + names: ctx.items.map((e) => e.name).join(', '), + }); + const tickLabels = ticks.map( + (v) => html`${formatNumber(v, decimals, ctx.hass)}`, + ); + const body = rows.map((row) => renderRow(row, ctx, ends, at, ticks, period)); + const classes = `db ${orientation} ${show.name ? '' : 'no-names'} ${show.value ? '' : 'no-values'}`; + return html`
+ ${span ? legend(ctx, ends, showNow, rows, period, unit) : nothing} + ${ + orientation === 'horizontal' + ? html`${body} + ${ticks.length ? html`` : nothing}` + : html`
+ ${ticks.length ? html`` : nothing}${body} +
` + } +
`; + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 3e6c194..82a43d2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -31,6 +31,7 @@ import { bullet } from './bullet'; import { lollipop } from './lollipop'; import { waterfall } from './waterfall'; import { slope } from './slope'; +import { dumbbell } from './dumbbell'; export interface RenderContext { show: ShowOptions; @@ -151,4 +152,5 @@ export const STYLES: Record = { lollipop, waterfall, slope, + dumbbell, }; diff --git a/src/types.ts b/src/types.ts index 05136b1..09f8947 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/dumbbell.test.ts b/test/dumbbell.test.ts new file mode 100644 index 0000000..977015d --- /dev/null +++ b/test/dumbbell.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { agoText, labelledMarks, rowMarks, stepDecimals } from '../src/styles/dumbbell'; +import type { ResolvedEntity } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const entity = (value: number | null, target: number | null = null) => + ({ available: value !== null, value, target }) as unknown as ResolvedEntity; + +describe('dumbbell maths', () => { + const points = [ + { t: 10, v: 18 }, + { t: 20, v: null }, + { t: 30, v: 23 }, + ]; + + it('takes low and high over the period, stretched to the current value, with now between', () => { + expect(rowMarks(entity(20), 'range', points, 0, true)).toEqual([ + { kind: 'low', value: 18 }, + { kind: 'now', value: 20 }, + { kind: 'high', value: 23 }, + ]); + expect(rowMarks(entity(25), 'range', points, 0, false)).toEqual([ + { kind: 'low', value: 18 }, + { kind: 'high', value: 25 }, + ]); + // No history: the current value alone, even when it isn't asked for. + expect(rowMarks(entity(20), 'range', [], 0, false)).toEqual([{ kind: 'now', value: 20 }]); + expect(rowMarks(entity(null), 'range', [], 0, true)).toEqual([]); + }); + + it('compares the start of the period with now', () => { + expect(rowMarks(entity(21), 'then', points, 15, true)).toEqual([ + { kind: 'then', value: 18 }, + { kind: 'now', value: 21 }, + ]); + // Statistics start after the period does: the first reading stands in. + expect(rowMarks(entity(16), 'then', points, 5, true)).toEqual([ + { kind: 'now', value: 16 }, + { kind: 'then', value: 18 }, + ]); + expect(rowMarks(entity(null), 'then', [], 0, true)).toEqual([]); + }); + + it('compares the target with now', () => { + expect(rowMarks(entity(21, 19), 'target', [], 0, true)).toEqual([ + { kind: 'target', value: 19 }, + { kind: 'now', value: 21 }, + ]); + expect(rowMarks(entity(21), 'target', [], 0, true)).toEqual([{ kind: 'now', value: 21 }]); + }); + + it('labels the two ends, skipping the current value in range mode', () => { + const low = { kind: 'low' as const, value: 1 }; + const now = { kind: 'now' as const, value: 2 }; + const high = { kind: 'high' as const, value: 3 }; + expect(labelledMarks([low, now, high])).toEqual([low, high]); + expect(labelledMarks([now])).toEqual([now]); + expect(labelledMarks([])).toEqual([]); + const then = { kind: 'then' as const, value: 0 }; + expect(labelledMarks([then, now])).toEqual([then, now]); + }); + + it('formats ticks with the decimals their step needs, and names the period', () => { + expect(stepDecimals([0, 5, 10])).toBe(0); + expect(stepDecimals([15, 17.5])).toBe(1); + expect(stepDecimals([0, 0.25])).toBe(2); + expect(stepDecimals([3])).toBe(0); + expect(agoText(undefined, { last: 1, unit: 'week' })).toBe('A week ago'); + expect(agoText(undefined, { last: 3, unit: 'day' })).toBe('3 days ago'); + }); +}); + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.lounge': ['21', { friendly_name: 'Lounge', unit_of_measurement: '°C' }], + 'sensor.kitchen': ['19.5', { friendly_name: 'Kitchen', unit_of_measurement: '°C' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off', unit_of_measurement: '°C' }], + 'sensor.set': ['20', {}], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.lounge': [ + { s: '18', lu: sec(20 * H) }, + { s: '23', lu: sec(10 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.kitchen': [ + { s: '17', lu: sec(30 * H) }, + { s: '19.5', lu: sec(5 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'dumbbell', + entities: ['sensor.lounge', 'sensor.kitchen', 'sensor.off', 'sensor.gone'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const dots = (row: Element) => [...row.querySelectorAll('.db-dot')].map((d) => d.classList[1]); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('dumbbell chart', () => { + it("draws each entity's low, now and high on a shared axis, with rows for the unavailable and missing", async () => { + const card = await mount({}); + const rows = qa(card, '.entity.db-row'); + expect(rows.map((r) => r.getAttribute('data-index'))).toEqual(['0', '1', '2', '3']); + expect(dots(rows[0])).toEqual(['low', 'now', 'high']); + expect(rows[0].getAttribute('aria-label')).toBe('Lounge: Low 18.0 °C, Now 21.0 °C, High 23.0 °C'); + expect([...rows[0].querySelectorAll('.db-label')].map((l) => l.textContent)).toEqual(['18.0', '23.0']); + // Kitchen's history starts before the period: the carried reading counts. + expect(dots(rows[1])).toEqual(['low', 'high', 'now']); + expect(rows[2].querySelector('.db-status')!.textContent).toBe('Unavailable'); + expect(rows[2].classList.contains('unavailable')).toBe(true); + expect(rows[3].querySelector('.db-status')!.textContent).toBe('Entity not found'); + expect(qa(card, '.db-tick').map((t) => t.textContent)).toEqual(['16', '18', '20', '22', '24']); + expect(q(card, '.db-unit')!.textContent).toBe('°C'); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Low', 'Now', 'High']); + expect(q(card, '.db')!.getAttribute('aria-label')).toBe('Dumbbell chart of Lounge, Kitchen, Off, sensor.gone'); + }); + + it('leaves the current value out when asked, and draws columns', async () => { + const card = await mount({ show_now: false, orientation: 'vertical', show: { name: false, value: false } }); + expect(q(card, '.db.vertical.no-names.no-values')).not.toBeNull(); + expect(dots(qa(card, '.db-row')[0])).toEqual(['low', 'high']); + expect(qa(card, '.db-label')).toHaveLength(0); + expect(qa(card, '.db-name')).toHaveLength(0); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Low', 'High']); + }); + + it('compares the start of the period with now', async () => { + const card = await mount({ ends: 'then', period: { last: 1, unit: 'day' } }); + const rows = qa(card, '.db-row'); + expect(dots(rows[0])).toEqual(['then', 'now']); + expect(rows[0].getAttribute('aria-label')).toBe('Lounge: A day ago 18.0 °C, Now 21.0 °C'); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['A day ago', 'Now']); + }); + + it('compares the target with now without loading history', async () => { + const hass = hassWith(history); + const card = await mount( + { + ends: 'target', + entities: [{ entity: 'sensor.lounge', target: 'sensor.set' }, { entity: 'sensor.kitchen' }], + } as Partial, + hass, + ); + expect(hass.callWS).not.toHaveBeenCalled(); + const rows = qa(card, '.db-row'); + expect(dots(rows[0])).toEqual(['target', 'now']); + expect(rows[0].querySelector('.db-dot.target')!.classList.contains('ring')).toBe(true); + expect(dots(rows[1])).toEqual(['now']); + expect(rows[1].querySelector('.db-bar')).toBeNull(); + expect(qa(card, '.db-key').map((k) => k.textContent)).toEqual(['Target', 'Now']); + }); + + it('says when history is loading or failed', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'dumbbell', entities: ['sensor.lounge'] } as CardConfig); + card.hass = hassWith(history); + document.body.appendChild(card); + await card.updateComplete; + expect(q(card, '.chart-message')!.textContent).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + }); + + it('draws rows without marks when nothing has a value', async () => { + const card = await mount({ entities: ['sensor.off'] }, hassWith({})); + expect(q(card, '.db-status')!.textContent).toBe('Unavailable'); + expect(q(card, '.db-legend')).toBeNull(); + expect(q(card, '.db-axis')).toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'dumbbell', entities: ['sensor.lounge'], ...extra } as CardConfig); + expect(set({ ends: 'both' })).toThrow(/"ends" must be one of: range, then, target/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ show_now: 'yes' })).toThrow(/show_now/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ ends: 'target', orientation: 'vertical', show_now: false })).not.toThrow(); + }); + + it('opens more-info from a row', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.db-row')[1] as HTMLElement; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.kitchen']); + }); +}); + +describe('dumbbell editor', () => { + async function editor(raw: CardConfig) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + return { form, events }; + } + const names = (schema: unknown) => + (schema as { name: string; schema?: { name: string }[] }[]).flatMap((s) => + s.schema ? s.schema.map((x) => x.name) : [s.name], + ); + + it('sets what to compare, the direction, the period and the current-value mark', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'dumbbell', entities: ['sensor.a'] } as CardConfig; + const { form, events } = await editor(raw); + expect(form.data).toMatchObject({ ends: 'range', orientation: 'horizontal', show_now: true, period_last: 24 }); + expect(names(form.schema)).toEqual(['ends', 'orientation', 'period_last', 'period_unit', 'source', 'show_now']); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), orientation: 'vertical', show_now: false, period_unit: 'week' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'vertical', show_now: false, period: { unit: 'week' } }); + }); + + it('hides the period for a target and the mark for a start-of-period comparison', async () => { + const target = await editor({ type: 'x', style: 'dumbbell', ends: 'target', entities: [] } as CardConfig); + expect(names(target.form.schema)).toEqual(['ends', 'orientation']); + document.body.innerHTML = ''; + const then = await editor({ type: 'x', style: 'dumbbell', ends: 'then', entities: [] } as CardConfig); + expect(names(then.form.schema)).toEqual(['ends', 'orientation', 'period_last', 'period_unit', 'source']); + }); +}); From 0a0c483d2b0b026c21b7b75737a5ad27a408c32c Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 14:57:18 +0000 Subject: [PATCH 14/41] fix: the dumbbell, slope and waterfall editors only offer the periods those charts read They read a rolling span (how far back, and from history or statistics). The shared history fields now also hold calendar spans, offsets, previous/next buttons and grouping, which these charts ignore, so their editors use a rolling-only form. Also moves the slope chart's per-entity options to the function form every chart uses. --- src/editor/chart-editors.ts | 20 ++++++++++++++++++++ src/styles/dumbbell.ts | 6 +++--- src/styles/slope.ts | 12 ++++++------ src/styles/waterfall.ts | 6 +++--- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 8d32245..cb522cc 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -106,6 +106,26 @@ export function historySchema(hass?: HomeAssistant, config?: CardConfig) { ]; } +/** History fields for charts that only read a rolling span (how far back, and from where). */ +export const ROLLING_HISTORY_FIELDS: FieldBinding[] = HISTORY_FIELDS.filter((f) => + ['period_last', 'period_unit', 'source'].includes(f.name), +); + +/** `historySchema` for those charts: no calendar spans, offset, buttons or grouping. */ +export function rollingHistorySchema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'period_last', selector: { number: { min: 1, mode: 'box' } } }, + { name: 'period_unit', selector: choice(hass, 'period_unit', PERIOD_UNITS) }, + ], + }, + { name: 'source', selector: choice(hass, 'source', SOURCES) }, + ]; +} + registerStrings('en', { editor_x_axis: 'Time labels', editor_y_axis: 'Value labels', diff --git a/src/styles/dumbbell.ts b/src/styles/dumbbell.ts index e132fa0..8012629 100644 --- a/src/styles/dumbbell.ts +++ b/src/styles/dumbbell.ts @@ -4,7 +4,7 @@ import { baseColor } from '../bands'; import { extentOf, lowHigh, niceTicks, readingAt } from '../chart/compare'; import type { TimedValue } from '../chart/compare'; import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; -import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber, withUnit } from '../format'; import { parsePeriod } from '../history/period'; @@ -267,7 +267,7 @@ function renderRow( } const FIELDS: FieldBinding[] = [ - ...HISTORY_FIELDS, + ...ROLLING_HISTORY_FIELDS, { name: 'ends', path: ['ends'], default: DEFAULTS.ends }, { name: 'show_now', path: ['show_now'], default: DEFAULTS.showNow }, { name: 'orientation', path: ['orientation'], default: DEFAULTS.orientation }, @@ -285,7 +285,7 @@ export function dumbbellEditor(ctx: ChartEditorContext): TemplateResult { ], }, // A target needs no history. - ...(ends === 'target' ? [] : historySchema(ctx.hass)), + ...(ends === 'target' ? [] : rollingHistorySchema(ctx.hass)), ...(ends === 'range' ? [{ name: 'show_now', selector: { boolean: {} } }] : []), ]); return chartForm(ctx, 'chart', schema, FIELDS); diff --git a/src/styles/slope.ts b/src/styles/slope.ts index e32c2ff..7d892c4 100644 --- a/src/styles/slope.ts +++ b/src/styles/slope.ts @@ -13,7 +13,7 @@ import { } from '../chart/frame'; import type { HistoryFrame } from '../chart/frame'; import { padDomain } from '../chart/scale'; -import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber } from '../format'; import { parsePeriod, rollingRange } from '../history/period'; @@ -178,7 +178,7 @@ function legend(rows: Row[]): TemplateResult | typeof nothing { } const FIELDS: FieldBinding[] = [ - ...HISTORY_FIELDS, + ...ROLLING_HISTORY_FIELDS, { name: 'measure', path: ['measure'], default: 'value' }, { name: 'color_by', path: ['color_by'], default: 'direction' }, { name: 'increase', path: ['increase'], default: 'bad' }, @@ -364,7 +364,7 @@ export const slope: ChartStyleModule = { }, editor(ctx) { const schema = cachedSchema('slope', ctx.hass, () => [ - ...historySchema(ctx.hass), + ...rollingHistorySchema(ctx.hass), { name: 'measure', selector: choice(ctx.hass, 'measure', MEASURES) }, { type: 'grid', @@ -377,10 +377,10 @@ export const slope: ChartStyleModule = { ]); return chartForm(ctx, 'chart', schema, FIELDS); }, - entityFields: { - schema: (hass) => cachedSchema('slope-entity', hass, () => [{ name: 'group', selector: { text: {} } }]), + entityFields: (hass) => ({ + schema: cachedSchema('slope-entity', hass, () => [{ name: 'group', selector: { text: {} } }]), fields: ENTITY_FIELDS, - }, + }), disconnect: disconnectHistory, render(ctx) { const now = Date.now(); diff --git a/src/styles/waterfall.ts b/src/styles/waterfall.ts index a30f321..39eba99 100644 --- a/src/styles/waterfall.ts +++ b/src/styles/waterfall.ts @@ -3,7 +3,7 @@ import type { TemplateResult } from 'lit'; import { baseColor } from '../bands'; import { disconnectHistory, frameMessage, frameStyles, historyFrame, validateHistoryOptions } from '../chart/frame'; import type { HistoryFrame } from '../chart/frame'; -import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema } from '../editor/chart-editors'; +import { ROLLING_HISTORY_FIELDS, cachedSchema, chartForm, choice, rollingHistorySchema } from '../editor/chart-editors'; import { entityOptionsForm } from '../editor/entity-options'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber, withUnit } from '../format'; @@ -215,7 +215,7 @@ function drawing(ctx: ChartContext, bars: Placed[]): TemplateResult { } const FIELDS: FieldBinding[] = [ - ...HISTORY_FIELDS, + ...ROLLING_HISTORY_FIELDS, { name: 'steps', path: ['steps'], default: 'change' }, { name: 'orientation', path: ['orientation'], default: 'vertical' }, { name: 'start_from_first', path: ['start_from_first'], default: false }, @@ -224,7 +224,7 @@ const FIELDS: FieldBinding[] = [ export function waterfallEditor(ctx: ChartEditorContext): TemplateResult { const schema = cachedSchema('waterfall', ctx.hass, () => [ - ...historySchema(ctx.hass), + ...rollingHistorySchema(ctx.hass), { type: 'grid', name: '', From 5d406bdebcbf06ebd4081093e898df1fd6ab8285 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:21:08 +0000 Subject: [PATCH 15/41] feat: range area chart, shading each period's lowest to highest A new history style, `range_area`: a shaded band from each period's minimum to its maximum, with the average drawn as a line through it (`mean`, on by default). Daily temperature range over a month, or min/max humidity per day. It reads long-term statistics by default, since Home Assistant already keeps each hour's (or day's, month's) min, mean and max. Grouped by day, week or month, each group's band runs from the lowest of its rows' minimums to the highest of their maximums. History works too: it has no spread per reading, so a history range area is grouped (by hour up to two days, else by day) and each group spans the values held in it. Gaps in the data break the band. The tooltip shows "low - high" and the average. The shared history frame gained an opt-in that asks the recorder for min and max and keeps them on grouped points; charts that don't ask are unchanged. Editor: Chart section has the period, source (Statistics by default) and grouping, Line shape, "Draw the average as a line" and its width, then axes and layout. Each entity has its own Colour, Line shape and Dashed. Closes #13 --- src/styles/index.ts | 2 + src/styles/range-area.ts | 268 +++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/range-area.test.ts | 140 ++++++++++++++++++++ 4 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 src/styles/range-area.ts create mode 100644 test/range-area.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 82a43d2..9b88938 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -32,6 +32,7 @@ import { lollipop } from './lollipop'; import { waterfall } from './waterfall'; import { slope } from './slope'; import { dumbbell } from './dumbbell'; +import { rangeArea } from './range-area'; export interface RenderContext { show: ShowOptions; @@ -153,4 +154,5 @@ export const STYLES: Record = { waterfall, slope, dumbbell, + range_area: rangeArea, }; diff --git a/src/styles/range-area.ts b/src/styles/range-area.ts new file mode 100644 index 0000000..3543470 --- /dev/null +++ b/src/styles/range-area.ts @@ -0,0 +1,268 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { validateAxisOptions } from '../chart/axes'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { CURVES, runPath } from '../chart/paths'; +import type { Curve, XY } from '../chart/paths'; +import { extent } from '../chart/scale'; +import { DEFAULT_LINE_WIDTH, validateSeriesStyle } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipLayer, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import type { TooltipOptions } from '../chart/tooltip'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import { LINE_WIDTH_SELECTOR } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_range_area: 'Range area', + editor_mean: 'Draw the average as a line', + editor_range_color: 'Colour', +}); + +/** + * Where a range area reads from when the card doesn't say: long-term statistics, which keep each + * period's lowest, average and highest. History has no spread until it is grouped, so a history + * range area with no grouping is grouped by hour (two days or less) or by day. + */ +export function rangeRaw(raw: CardConfig): CardConfig { + const out: Record = { ...raw, source: raw.source ?? 'statistics' }; + if (out.source === 'history' && raw.group_by === undefined) { + const period = (raw.period ?? {}) as { last?: number; unit?: string; calendar?: string }; + const hours = (period.last ?? 24) * ({ hour: 1, day: 24, week: 168, month: 720 }[period.unit ?? 'hour'] ?? 1); + out.group_by = period.calendar === 'day' || (!period.calendar && hours <= 48) ? 'hour' : 'day'; + } + return out as CardConfig; +} + +/** Corners of a stepped line, so the band's lower edge can be walked back along the same steps. */ +function corners(pts: XY[]): XY[] { + return pts.flatMap((p, i) => (i > 0 ? [[p[0], pts[i - 1][1]] as XY, p] : [p])); +} + +/** The closed band between the highs and lows of one unbroken run. */ +export function bandPath(highs: XY[], lows: XY[], curve: Curve): string { + if (highs.length < 2) return ''; + const [h, l, c] = curve === 'step' ? [corners(highs), corners(lows), 'straight' as Curve] : [highs, lows, curve]; + return `${runPath(h, c)}${runPath([...l].reverse(), c).replace(/^M/, 'L')}Z`; +} + +/** Unbroken runs of points that have a value; each point's low and high default to its value. */ +export function bandRuns(points: Point[]): { t: number; v: number; lo: number; hi: number }[][] { + const out: { t: number; v: number; lo: number; hi: number }[][] = []; + let run: { t: number; v: number; lo: number; hi: number }[] = []; + for (const p of points) { + if (p.v === null) { + if (run.length) out.push(run); + run = []; + continue; + } + run.push({ t: p.t, v: p.v, lo: p.min ?? p.v, hi: p.max ?? p.v }); + } + if (run.length) out.push(run); + return out; +} + +function drawSeries(s: ChartSeries, scales: PlotScales, mean: boolean, width: number) { + const bands: string[] = []; + const edges: string[] = []; + const lines: string[] = []; + for (const run of bandRuns(s.points)) { + const highs = run.map((p) => [scales.x(p.t), scales.y(p.hi)] as XY); + const lows = run.map((p) => [scales.x(p.t), scales.y(p.lo)] as XY); + bands.push(bandPath(highs, lows, s.curve)); + edges.push(runPath(highs, s.curve), runPath(lows, s.curve)); + lines.push( + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v)] as XY), + s.curve, + ), + ); + } + return svg` + + + ${ + mean + ? svg`` + : nothing + } + `; +} + +function render(ctx0: ChartContext): TemplateResult { + const ctx = { ...ctx0, raw: rangeRaw(ctx0.raw) }; + const frame = historyFrame(ctx, Date.now(), { range: true }); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const series = frame.series; + const axes = plotAxes(ctx, frame.range, extent(series.map((s) => s.points))); + const mean = ctx.raw.mean !== false; + const width = (ctx.raw.line_width as number | undefined) ?? DEFAULT_LINE_WIDTH; + const tip: TooltipOptions = { + text: (s, p) => { + if (!p || p.v === null) return '—'; + const f = (v: number) => formatNumber(v, s.entity.decimals, ctx.hass); + const lo = p.min ?? p.v; + const hi = p.max ?? p.v; + return withUnit(`${f(lo)} – ${f(hi)}`, s.entity.unit) + (mean ? ` · ${f(p.v)}` : ''); + }, + }; + if (!mean) tip.dotY = () => null; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_range_area'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content: svg`${series.map((s) => drawSeries(s, axes.scales, mean, width))}`, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${tooltip ? tooltipLayer(ctx, series, axes.scales, hoverTime(ctx.host), plotWidth(ctx), tip) : nothing}`, + hover: tooltip + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points), + ) + : undefined, + }), + ); +} + +const RANGE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.map((f) => (f.name === 'source' ? { ...f, default: 'statistics' } : f)), + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'mean', path: ['mean'], default: true }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +export function rangeAreaEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`range_area|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'mean', selector: { boolean: {} } }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + ], + }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, RANGE_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'range_color', path: ['color'] }, + { name: 'curve', path: ['curve'] }, + { name: 'dash', path: ['dash'], default: false }, +]; + +export const rangeArea: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .plot .band { + fill-opacity: 0.22; + } + .plot .band-edge { + stroke-width: 1px; + stroke-opacity: 0.45; + } + .plot .series .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .plot .series .line.dashed { + stroke-dasharray: 6 5; + } + `, + thumbnail: () => svg` + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateSeriesStyle(raw); + validateLayout(raw); + if (raw.mean !== undefined && typeof raw.mean !== 'boolean') throw new Error('"mean" must be true or false'); + }, + editor: rangeAreaEditor, + entityFields: (hass) => ({ + schema: cachedSchema('range-area-entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'range_color', selector: { text: {} } }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'dash', selector: { boolean: {} } }, + ], + }, + ]), + fields: ENTITY_FIELDS, + }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render, +}; diff --git a/src/types.ts b/src/types.ts index 09f8947..bd36f44 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/range-area.test.ts b/test/range-area.test.ts new file mode 100644 index 0000000..663539b --- /dev/null +++ b/test/range-area.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { groupHistory, groupStatistics, statisticsQueryFor } from '../src/history/group'; +import { bandPath, bandRuns, rangeRaw } from '../src/styles/range-area'; +import type { CardConfig } from '../src/types'; +import { H, ago, chartHass, mountCard, mountEditor, pointAt, q, qa, tooltipRows } from './helpers/chart'; +import type { Card } from './helpers/chart'; + +const now = Date.now(); + +const states = { + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], +} as const; + +/** Hourly statistics for the last day: mean 10 + i/4, spread ±3; a gap three hours long. */ +const hourly = Array.from({ length: 24 }, (_, i) => { + const gap = i >= 10 && i < 13; + const mean = 10 + i / 4; + return { start: now - (24 - i) * H, mean: gap ? null : mean, min: gap ? null : mean - 3, max: gap ? null : mean + 3 }; +}); + +const history = { + 'sensor.in': [ + { s: '18', lu: ago(now, 40 * H) }, + { s: '22', lu: ago(now, 30 * H) }, + { s: '20', lu: ago(now, 20 * H) }, + ], +}; + +const mount = (config: Record, hass = chartHass(states, history, { 'sensor.out': hourly })) => + mountCard({ style: 'range_area', entities: ['sensor.out'], ...config } as Partial, hass); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('range area maths', () => { + it('reads statistics unless told otherwise, and groups history so it has a spread', () => { + const raw = { type: 'x' } as CardConfig; + expect(rangeRaw(raw).source).toBe('statistics'); + expect(rangeRaw(raw).group_by).toBeUndefined(); + expect(rangeRaw({ ...raw, source: 'history' }).group_by).toBe('hour'); + expect(rangeRaw({ ...raw, source: 'history', period: { last: 7, unit: 'day' } }).group_by).toBe('day'); + expect(rangeRaw({ ...raw, source: 'history', period: { calendar: 'day' } }).group_by).toBe('hour'); + expect(rangeRaw({ ...raw, source: 'history', group_by: 'week' }).group_by).toBe('week'); + }); + + it('splits at gaps and closes the band back along the lows', () => { + const runs = bandRuns([ + { t: 0, v: 5, min: 2, max: 8 }, + { t: 1, v: 6 }, + { t: 2, v: null }, + { t: 3, v: 4, min: 1, max: 9 }, + ]); + expect(runs).toHaveLength(2); + expect(runs[0][1]).toEqual({ t: 1, v: 6, lo: 6, hi: 6 }); + const highs: [number, number][] = [ + [0, 10], + [10, 20], + ]; + const lows: [number, number][] = [ + [0, 50], + [10, 60], + ]; + expect(bandPath(highs, lows, 'straight')).toBe('M0,10L10,20L10,60L0,50Z'); + expect(bandPath(highs, lows, 'step')).toBe('M0,10L10,10L10,20L10,60L10,50L0,50Z'); + expect(bandPath([[0, 1]], [[0, 2]], 'smooth')).toBe(''); + }); + + it('keeps each group’s spread when asked', () => { + const pts = [ + { t: 0, v: 5 }, + { t: 10, v: 9 }, + ]; + expect(groupHistory(pts, [0, 20], 'mean', 20, true)[0]).toMatchObject({ min: 5, max: 9 }); + const rows = [{ t: 0, v: 5, min: 1, max: 7 }]; + expect(groupStatistics(rows, [0, 20], 'mean', Infinity, true)[0]).toMatchObject({ min: 1, max: 7 }); + const zone = { timeZone: 'UTC', firstDay: 1 }; + expect(statisticsQueryFor({ unit: 'day', aggregate: 'max' }, zone, 'UTC', true).types).toEqual(['max', 'min']); + }); +}); + +describe('range area chart', () => { + it('shades lowest to highest from statistics, broken at a gap, with the average through it', async () => { + const hass = chartHass(states, history, { 'sensor.out': hourly }); + const card = await mount({}, hass); + const band = q(card, 'path.band')!; + expect(band.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(band.getAttribute('d')).toMatch(/Z$/); + expect(q(card, 'path.line')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Range area chart of Outside'); + const msg = (hass.callWS as unknown as { mock: { calls: [{ type: string; types: string[] }][] } }).mock.calls[0][0]; + expect(msg.type).toBe('recorder/statistics_during_period'); + expect(msg.types).toEqual(expect.arrayContaining(['min', 'max'])); + pointAt(q(card, '.plot-area')!, 'pointermove', 0.25); + await card.updateComplete; + expect(tooltipRows(card)[0]).toMatch(/^Outside \d+\.\d – \d+\.\d °C · \d+\.\d$/); + expect(qa(card, '.hover-dot')).toHaveLength(1); + }); + + it('can leave the average out', async () => { + const card = await mount({ mean: false, curve: 'step' }); + expect(q(card, 'path.line')).toBeNull(); + pointAt(q(card, '.plot-area')!, 'pointermove', 0.25); + await card.updateComplete; + expect(tooltipRows(card)[0]).not.toContain('·'); + expect(qa(card, '.hover-dot')).toHaveLength(0); + }); + + it('groups history to get a spread, and says when there is nothing', async () => { + const card = await mount({ source: 'history', entities: ['sensor.in'], period: { last: 2, unit: 'day' } }); + expect(q(card, 'path.band')!.getAttribute('d')).toMatch(/Z/); + const empty = await mount({ entities: ['sensor.missing'] }, chartHass(states, {}, {})); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'range_area', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ mean: 'yes' })).toThrow(/mean/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ mean: false, group_by: 'day' })).not.toThrow(); + }); +}); + +describe('range area editor', () => { + it('defaults to statistics and edits the average line', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'range_area', entities: ['sensor.out'] } as CardConfig; + const { ed, form, change, events, entityFields } = await mountEditor(raw, chartHass(states, {})); + expect(form().data).toMatchObject({ source: 'statistics', mean: true, curve: 'smooth' }); + change({ mean: false, source: 'history', group_by: 'day' }); + expect(events.at(-1)).toEqual({ ...raw, mean: false, source: 'history', group_by: 'day' }); + await ed.updateComplete; + change({ source: 'statistics' }); + expect(events.at(-1)).not.toHaveProperty('source'); + expect(entityFields().fields.map((f) => f.name)).toEqual(['range_color', 'curve', 'dash']); + }); +}); From 7480498cb81b111a7fabfd04a472acdc54954c05 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:26:15 +0000 Subject: [PATCH 16/41] feat: sparklines, a tiny trend beside each entity's name and value A new history style, `sparkline`: one row per entity with its name, a small chart of its recent history with no axes, and its current value. A row per sensor showing its last 24 hours, in the space of a 1.0 bar row. - `spark: line`, `area` or `column`, for the card or per entity. Each row is scaled to its own readings, gaps break the line, and a column sparkline that isn't grouped is grouped by hour (two days or less) or by day so its columns aren't hairlines. - `bar: true` puts the 1.0 thin bar (current value between min and max, in its band colour, with the target) under the name and value, with the sparkline beside it. This is an option on the sparkline style rather than on the bar styles because those draw one entity at a time without access to history; this style loads every row's history in one call. - Rows are coloured like the 1.0 bars (the value's band) unless an entity sets its own colour, and every sparkline lines up across rows whatever the width of its value. - Each row opens more-info (and takes the card's actions). Unavailable and missing entities keep their row and say so. - The shared period options apply: period, previous/next, source and grouping. Editor: Chart section has the period, Sparkline (Line, Area, Columns), Line shape, Line width and "Bar under the name and value". Each entity can pick its own kind and colour. Closes #14 --- src/styles/index.ts | 2 + src/styles/sparkline.ts | 436 ++++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/sparkline.test.ts | 147 ++++++++++++++ 4 files changed, 587 insertions(+), 2 deletions(-) create mode 100644 src/styles/sparkline.ts create mode 100644 test/sparkline.test.ts diff --git a/src/styles/index.ts b/src/styles/index.ts index 9b88938..43771a6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -33,6 +33,7 @@ import { waterfall } from './waterfall'; import { slope } from './slope'; import { dumbbell } from './dumbbell'; import { rangeArea } from './range-area'; +import { sparklineStyle } from './sparkline'; export interface RenderContext { show: ShowOptions; @@ -155,4 +156,5 @@ export const STYLES: Record = { slope, dumbbell, range_area: rangeArea, + sparkline: sparklineStyle, }; diff --git a/src/styles/sparkline.ts b/src/styles/sparkline.ts new file mode 100644 index 0000000..7658088 --- /dev/null +++ b/src/styles/sparkline.ts @@ -0,0 +1,436 @@ +import { boundaries, zoneFor } from '../history/timezone'; +import { groupHistory } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Range } from '../history/types'; +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor, colorStops } from '../bands'; +import { downsample } from '../chart/downsample'; +import { disconnectHistory, frameStyles, historyFrame, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { CURVES, runPath, runs } from '../chart/paths'; +import type { XY } from '../chart/paths'; +import { linear, padDomain } from '../chart/scale'; +import { MAX_LINE_WIDTH, MIN_LINE_WIDTH } from '../chart/series-style'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import { LINE_WIDTH_SELECTOR } from '../editor/schemas'; +import type { FieldBinding } from '../editor/schemas'; +import { localize, registerStrings } from '../localize'; +import { iconOverride } from '../resolve'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +/** Kinds of sparkline. */ +export const SPARK_KINDS = ['line', 'area', 'column'] as const; +export type SparkKind = (typeof SPARK_KINDS)[number]; + +/** A sparkline's drawing box; it stretches to its cell, strokes don't. */ +const W = 100; +const H = 24; +/** Points kept per sparkline: it is only a hundred or so pixels wide. */ +const MAX_POINTS = 120; +/** A sparkline is thin: its default line is lighter than a line chart's. */ +const SPARK_LINE_WIDTH = 1.5; + +registerStrings('en', { + editor_style_sparkline: 'Sparklines', + editor_spark: 'Sparkline', + editor_spark_line: 'Line', + editor_spark_area: 'Area', + editor_spark_column: 'Columns', + editor_spark_bar: 'Bar under the name and value', + editor_spark_color: 'Colour', + spark_aria: 'Trend of {name}', +}); + +/** + * Which kind a row draws: its own `spark`, else the card's, else a line. Columns of raw readings + * would be hairlines, so an ungrouped column sparkline is grouped by hour (two days or less) or + * by day: see `sparkRaw`. + */ +export function sparkKind(e: ResolvedEntity, raw: CardConfig): SparkKind { + const own = (e.config as { spark?: SparkKind }).spark; + return own ?? (raw.spark as SparkKind | undefined) ?? 'line'; +} + +/** The grouping an ungrouped column sparkline gets: by hour up to two days, else by day. */ +export function columnUnit(raw: CardConfig): 'hour' | 'day' { + const period = (raw.period ?? {}) as { last?: number; unit?: string; calendar?: string }; + const hours = (period.last ?? 24) * ({ hour: 1, day: 24, week: 168, month: 720 }[period.unit ?? 'hour'] ?? 1); + const short = period.calendar ? period.calendar === 'day' : hours <= 48; + return short ? 'hour' : 'day'; +} + +/** + * When every row is a column sparkline and nothing is grouped, group the whole card (statistics + * can then be asked for in the right period). Mixed cards stay ungrouped, so line and area rows + * keep every reading; their column rows are grouped on their own (see `columnPoints`). + */ +export function sparkRaw(raw: CardConfig, kinds: SparkKind[]): CardConfig { + if (raw.group_by !== undefined || kinds.length === 0 || !kinds.every((k) => k === 'column')) return raw; + return { ...raw, group_by: columnUnit(raw), aggregate: raw.aggregate ?? 'mean' }; +} + +/** A column row's points in a card left ungrouped: averaged per hour or day in HA's time zone. */ +export function columnPoints(points: Point[], raw: CardConfig, range: Range, hass: HomeAssistant | undefined): Point[] { + const bounds = boundaries(columnUnit(raw), range.start, range.end, zoneFor(hass)); + return groupHistory(points, bounds, (raw.aggregate as Aggregate | undefined) ?? 'mean', Date.now()); +} + +/** The value range a sparkline is drawn over: its own readings, padded; zero kept in for columns. */ +export function sparkDomain(values: number[], zero: boolean): [number, number] | undefined { + if (values.length === 0) return undefined; + let lo = Math.min(...values); + let hi = Math.max(...values); + if (zero) { + lo = Math.min(lo, 0); + hi = Math.max(hi, 0); + return lo === hi ? [lo, lo + 1] : [lo, hi]; + } + [lo, hi] = padDomain([lo, hi], 0.1); + return [lo, hi]; +} + +/** One sparkline: a line, a filled area or columns, in the row's colour. */ +export function sparkline(s: ChartSeries, kind: SparkKind, range: { start: number; end: number }, width: number) { + const points = downsample(s.points, MAX_POINTS); + const values = points.filter((p) => p.v !== null).map((p) => p.v as number); + const domain = sparkDomain(values, kind === 'column'); + if (!domain) return svg``; + const x = linear([range.start, range.end], [0, W]); + // A pixel of room at top and bottom, so a line along the edge isn't cut in half. + const y = linear(domain, [H - 1, 1]); + const base = y(Math.min(Math.max(0, domain[0]), domain[1])); + if (kind === 'column') { + const bars: SVGTemplateResult[] = []; + points.forEach((p, i) => { + if (p.v === null) return; + const end = p.end ?? points[i + 1]?.t ?? range.end; + const x0 = x(p.t); + const w = Math.max(0, Math.min(W, x(end)) - x0); + if (w <= 0) return; + const top = Math.min(y(p.v), base); + bars.push(svg``); + }); + return svg`${bars}`; + } + const pieces = runs(points).map((r) => r.map((p) => [x(p.t), y(p.v as number)] as XY)); + const line = pieces.map((pts) => runPath(pts, s.curve)).join(''); + const fill = + kind === 'area' + ? pieces + .filter((pts) => pts.length > 1) + .map( + (pts) => + `${runPath(pts, s.curve === 'step' ? 'straight' : s.curve)}L${pts[pts.length - 1][0]},${base}L${pts[0][0]},${base}Z`, + ) + .join('') + : ''; + return svg`${fill ? svg`` : nothing} + `; +} + +/** The 1.0 thin bar: the current value between min and max, in its band's colour, with the target. */ +function bar(e: ResolvedEntity, showTarget: boolean) { + const stops = colorStops(e.band.color); + return html`
+
+ ${showTarget && e.targetPosition !== null && e.available ? html`
` : nothing} +
`; +} + +function render(ctx0: ChartContext): TemplateResult { + const kinds = ctx0.items.map((e) => sparkKind(e, ctx0.raw)); + const ctx = { ...ctx0, raw: sparkRaw(ctx0.raw, kinds) }; + const frame = historyFrame(ctx); + const { show } = ctx.config; + const withBar = ctx.raw.bar === true; + const width = (ctx.raw.line_width as number | undefined) ?? SPARK_LINE_WIDTH; + const message = + frame.status === 'error' + ? html`
${localize(ctx.hass, 'chart_error', { error: frame.error ?? '' })}
` + : nothing; + const rows = frame.series.map((s, i) => { + const e = s.entity; + // A row is coloured like the 1.0 bars (its value's band) unless the entity sets a colour. + const own = (e.config as { color?: string }).color; + const color = own ? baseColor(own) : baseColor(e.band.color); + const grouped = sparkKind(e, ctx0.raw) === 'column' && ctx.raw.group_by === undefined; + const series = { ...s, color, points: grouped ? columnPoints(s.points, ctx.raw, frame.range, ctx.hass) : s.points }; + const lineWidth = (e.config as { line_width?: number }).line_width ?? width; + const name = show.name ? html`${e.name}` : nothing; + const value = show.value || !e.available ? html`${e.display}` : nothing; + return html`
+ ${ + show.icon + ? html`` + : nothing + } + ${ + withBar + ? html`
+
${name}${value}
+ ${bar(e, show.target)} +
` + : name + } + + ${frame.status === 'ready' ? sparkline(series, kinds[i], frame.range, lineWidth) : nothing} + + ${withBar ? nothing : value} +
`; + }); + return html`${periodNav(frame, ctx)}${message} +
${rows}
`; +} + +const SPARK_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'spark', path: ['spark'], default: 'line' }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: SPARK_LINE_WIDTH }, + { name: 'spark_bar', path: ['bar'], default: false }, +]; + +export function sparklineEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`sparkline|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'spark', selector: choice(ctx.hass, 'spark', SPARK_KINDS) }, + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'spark_bar', selector: { boolean: {} } }, + ], + }, + ]); + return chartForm(ctx, 'chart', schema, SPARK_FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'spark', path: ['spark'] }, + { name: 'spark_color', path: ['color'] }, +]; + +function validate(raw: CardConfig): void { + validateHistoryOptions(raw); + const kinds = [ + raw.spark, + ...(raw.entities ?? []).map((e) => (typeof e === 'object' ? (e as { spark?: unknown }).spark : undefined)), + ]; + for (const k of kinds) { + if (k !== undefined && !SPARK_KINDS.includes(k as SparkKind)) { + throw new Error(`"spark" must be one of: ${SPARK_KINDS.join(', ')}`); + } + } + if (raw.bar !== undefined && typeof raw.bar !== 'boolean') throw new Error('"bar" must be true or false'); + const w = raw.line_width; + if (w !== undefined && (typeof w !== 'number' || w < MIN_LINE_WIDTH || w > MAX_LINE_WIDTH)) { + throw new Error(`"line_width" must be a number from ${MIN_LINE_WIDTH} to ${MAX_LINE_WIDTH}`); + } +} + +export const sparklineStyle: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 120, + styles: css` + ${frameStyles} + /* One grid for every row, so the sparklines line up whatever each value's width. */ + .spark-rows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%) auto; + column-gap: 12px; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%); + } + .spark-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + min-height: 28px; + border-radius: 6px; + cursor: pointer; + } + .spark-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .spark-row .icon { + color: var(--state-icon-color, var(--secondary-text-color)); + --mdc-icon-size: 22px; + } + .spark-row .name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .spark-row .value { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 1ch); + font-variant-numeric: tabular-nums; + } + .spark-row.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 13px; + max-width: 7em; + overflow: hidden; + text-overflow: ellipsis; + } + /* Narrow cards: the names keep their room, the sparklines give some up. */ + @container (max-width: 360px) { + .spark-rows { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%); + } + } + .spark { + width: 100%; + height: 28px; + overflow: visible; + } + .spark path { + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; + } + .spark .spark-fill { + fill-opacity: 0.22; + } + .spark-body { + min-width: 0; + } + .spark-body .top { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + .spark-body .value { + font-weight: 400; + font-size: 13px; + color: var(--secondary-text-color); + min-width: 0; + } + .spark-track { + position: relative; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--cd-track); + } + .spark-bar { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), var(--c-end)); + } + .spark-target { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: calc(var(--target) * (100% - 2px)); + background: var(--primary-text-color); + } + .spark-message { + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 4px 0; + } + .spark-message.error { + color: var(--error-color, #db4437); + } + `, + thumbnail: () => svg` + + + + + `, + validate, + editor: sparklineEditor, + entityFields: (hass) => ({ + schema: cachedSchema('sparkline-entity', hass, () => [ + { + type: 'grid', + name: '', + schema: [ + { name: 'spark', selector: choice(hass, 'spark', SPARK_KINDS) }, + { name: 'spark_color', selector: { text: {} } }, + ], + }, + ]), + fields: ENTITY_FIELDS, + }), + disconnect(host) { + disconnectHistory(host); + }, + render, +}; diff --git a/src/types.ts b/src/types.ts index bd36f44..35778ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/sparkline.test.ts b/test/sparkline.test.ts new file mode 100644 index 0000000..e9a4a9f --- /dev/null +++ b/test/sparkline.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { sparkDomain, sparkKind, sparkRaw } from '../src/styles/sparkline'; +import type { ResolvedEntity } from '../src/resolve'; +import type { CardConfig } from '../src/types'; +import { H, ago, chartHass, mountCard, mountEditor, q, qa } from './helpers/chart'; +import type { Card } from './helpers/chart'; + +const now = Date.now(); +const sec = (ms: number) => ago(now, ms); + +const states = { + 'sensor.temp': ['21', { friendly_name: 'Temp', unit_of_measurement: '°C' }], + 'sensor.power': ['-200', { friendly_name: 'Power', unit_of_measurement: 'W' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], +} as const; + +const history = { + 'sensor.temp': [ + { s: '18', lu: sec(20 * H) }, + { s: '22', lu: sec(12 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '21', lu: sec(4 * H) }, + ], + 'sensor.power': [ + { s: '400', lu: sec(20 * H) }, + { s: '-600', lu: sec(10 * H) }, + ], +}; + +const mount = (config: Record, hass = chartHass(states, history)) => + mountCard({ style: 'sparkline', entities: ['sensor.temp', 'sensor.power'], ...config } as Partial, hass); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('sparkline maths', () => { + it('takes the kind from the entity, then the card, then a line', () => { + const e = (spark?: string) => ({ config: { entity: 'x', spark } }) as unknown as ResolvedEntity; + const raw = { type: 'x', spark: 'area' } as CardConfig; + expect(sparkKind(e('column'), raw)).toBe('column'); + expect(sparkKind(e(), raw)).toBe('area'); + expect(sparkKind(e(), { type: 'x' })).toBe('line'); + }); + + it('groups column sparklines that are not grouped', () => { + const raw = { type: 'x' } as CardConfig; + expect(sparkRaw(raw, ['line'])).toBe(raw); + expect(sparkRaw(raw, ['column'])).toMatchObject({ group_by: 'hour', aggregate: 'mean' }); + expect(sparkRaw({ ...raw, period: { last: 7, unit: 'day' } }, ['column']).group_by).toBe('day'); + expect(sparkRaw({ ...raw, period: { calendar: 'day' } }, ['column']).group_by).toBe('hour'); + expect(sparkRaw({ ...raw, group_by: 'week' }, ['column']).group_by).toBe('week'); + expect(sparkRaw({ ...raw, aggregate: 'max' }, ['column']).aggregate).toBe('max'); + }); + + it('fits a sparkline to its own readings, with zero in for columns', () => { + expect(sparkDomain([], false)).toBeUndefined(); + const [lo, hi] = sparkDomain([10, 20], false)!; + expect(lo).toBeLessThan(10); + expect(hi).toBeGreaterThan(20); + expect(sparkDomain([10, 20], true)).toEqual([0, 20]); + expect(sparkDomain([0], true)).toEqual([0, 1]); + }); +}); + +describe('sparklines', () => { + it('draws a row per entity: name, a tiny line broken at gaps, and the value', async () => { + const card = await mount({}); + const rows = qa(card, '.spark-row'); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute('data-index')).toBe('0'); + expect(rows[0].querySelector('.name')!.textContent).toBe('Temp'); + expect(rows[0].querySelector('.value')!.textContent).toBe('21.0 °C'); + expect(rows[0].querySelector('.spark-line')!.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(rows[0].querySelector('svg')!.getAttribute('aria-label')).toBe('Trend of Temp'); + expect(q(card, '.axis-label')).toBeNull(); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('fills areas and draws columns, per card or per entity', async () => { + const card = await mount({ spark: 'area', entities: ['sensor.temp', { entity: 'sensor.power', spark: 'column' }] }); + expect(qa(card, '.spark-fill')).toHaveLength(1); + expect(qa(card, '.spark-columns rect').length).toBeGreaterThan(5); + // The power column sparkline goes below zero. + const rects = qa(card, '.spark-columns rect'); + expect(new Set(rects.map((r) => r.getAttribute('y'))).size).toBeGreaterThan(1); + }); + + it('puts the 1.0 thin bar under the name and value, with the target', async () => { + const card = await mount({ + bar: true, + min: 0, + max: 40, + target: 20, + entities: [{ entity: 'sensor.temp', color: 'red' }], + }); + const row = q(card, '.spark-row')!; + expect(row.classList.contains('with-bar')).toBe(true); + expect(row.querySelector('.spark-body .top .value')!.textContent).toBe('21.0 °C'); + expect(row.querySelector('.spark-track')!.getAttribute('style')).toContain('--end:0.525'); + expect(row.querySelector('.spark-target')).not.toBeNull(); + expect(row.querySelector('.spark-line')!.getAttribute('stroke')).toContain('--error-color'); + }); + + it('keeps a row for unavailable and missing entities, and says when loading failed', async () => { + const card = await mount({ entities: ['sensor.off', 'sensor.nope'] }); + const rows = qa(card, '.spark-row'); + expect(rows).toHaveLength(2); + expect(rows[0].classList.contains('unavailable')).toBe(true); + expect(rows[0].querySelector('.spark-line')).toBeNull(); + const failed = await mount({}, chartHass(states, history, {}, true)); + expect(q(failed, '.spark-message.error')!.textContent).toContain('boom'); + }); + + it('opens more-info from a row', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const row = qa(card, '.spark-row')[1] as HTMLElement; + row.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + row.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.power']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'sparkline', entities: ['sensor.temp'], ...extra } as CardConfig); + expect(set({ spark: 'pie' })).toThrow(/spark/); + expect(set({ entities: [{ entity: 'sensor.temp', spark: 'pie' }] })).toThrow(/spark/); + expect(set({ bar: 'yes' })).toThrow(/bar/); + expect(set({ line_width: 40 })).toThrow(/line_width/); + expect(set({ spark: 'column', bar: true, line_width: 2 })).not.toThrow(); + }); +}); + +describe('sparkline editor', () => { + it('edits the kind, shape, width and bar, and a kind and colour per entity', async () => { + const raw = { type: 'custom:chartdeck-card', style: 'sparkline', entities: ['sensor.temp'] } as CardConfig; + const { form, change, events, entityFields } = await mountEditor(raw, chartHass(states, {})); + expect(form().data).toMatchObject({ spark: 'line', curve: 'smooth', line_width: 1.5, spark_bar: false }); + change({ spark: 'column', spark_bar: true }); + expect(events.at(-1)).toEqual({ ...raw, spark: 'column', bar: true }); + expect(entityFields().fields.map((f) => f.name)).toEqual(['spark', 'spark_color']); + }); +}); From 96431bcda7dfced6fbf1cae4cafba4733a360e65 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:11:35 +0000 Subject: [PATCH 17/41] feat: streamgraph of how several entities share a total over time A new "Streamgraph" style (Parts of a total in the style picker) stacks each entity's history as a stream, so it is easy to see how, say, each device's share of the house's power changed through the day. It uses the same period, source, grouping, axes, tooltip and layout options as the line chart. Shape (baseline) picks how the stack sits: - wiggle (default): the classic streamgraph. The centre line moves so the streams' slopes are as small as they can be overall (Byron & Wattenberg), then the whole stream is centred. - centre: the stack centred on a straight line. - share: each moment scaled to 100 %, for the share of the total over time. - zero: an ordinary stacked area from zero. On wiggle and centre the value axis means nothing, so value labels, lines across and the value range are hidden (and left out of the editor). Share runs 0 to 100; zero starts at zero. Readings are cut into even slices across the period (about one per 4 px), each the time-weighted average of the history in it, or the mean of the statistics rows in it (never finer than the rows themselves). Grouped readings are one slice per group. Gaps, and negative readings such as power going back out, count as nothing: a stack can't show less than nothing. Order "biggest in the middle" (default) puts streams that peak early in first, each on the lighter side, so the edges stay calm; "as listed" stacks the first entity at the bottom. Edges can be smooth, straight or steps. The thickest streams get their name and value (or share) on them, placed so they never overlap; hovering lists every value, top of the stack first, with shares and a total. Each stream opens its entity like any other card item, and each entity can set its own colour in its options. All options are in the editor's Chart section: Shape, Order, Edges, Names on the streams, plus the shared period, axes and layout fields. The stacking maths is in src/chart/stream.ts. Closes #30 --- src/chart/stream.ts | 206 +++++++++++++++++ src/styles/index.ts | 2 + src/styles/streamgraph.ts | 472 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/streamgraph.test.ts | 344 +++++++++++++++++++++++++++ 5 files changed, 1026 insertions(+), 2 deletions(-) create mode 100644 src/chart/stream.ts create mode 100644 src/styles/streamgraph.ts create mode 100644 test/streamgraph.test.ts diff --git a/src/chart/stream.ts b/src/chart/stream.ts new file mode 100644 index 0000000..9d720b2 --- /dev/null +++ b/src/chart/stream.ts @@ -0,0 +1,206 @@ +import { groupHistory, groupStatistics } from '../history/group'; +import type { Point, Range, Source } from '../history/types'; + +/** + * Stacking maths for the streamgraph: series resampled onto shared times, ordered, and stacked + * on a baseline that is zero, centred, shares of the total, or "wiggle" (the streamgraph's own). + */ + +export const BASELINES = ['wiggle', 'centre', 'share', 'zero'] as const; +export type Baseline = (typeof BASELINES)[number]; + +export const STREAM_ORDERS = ['inside_out', 'listed'] as const; +export type StreamOrder = (typeof STREAM_ORDERS)[number]; + +/** One layer of the stack: its bottom and top edge at each sample. */ +export interface Layer { + lower: number[]; + upper: number[]; +} + +/** + * Even bins across `range`, up to `until` (now): each bin's start, then the last end. `count` is + * how many bins; a range already over (or not begun) still gets at least one. + */ +export function sampleBounds(range: Range, until: number, count: number): number[] { + const end = Math.max(range.start + 1, Math.min(range.end, until)); + const n = Math.max(1, Math.round(count)); + const step = (end - range.start) / n; + const out: number[] = []; + for (let i = 0; i < n; i++) out.push(range.start + i * step); + out.push(end); + return out; +} + +/** + * One value per bin: the time-weighted mean of history (a reading holds until the next, so a + * short spike counts for as long as it lasted), or the mean of the statistics rows in it. Gaps + * stay null. + */ +export function binSeries(points: Point[], bounds: number[], source: Source, until: number): (number | null)[] { + const grouped = + source === 'statistics' + ? groupStatistics(points, bounds, 'mean', until) + : groupHistory(points, bounds, 'mean', until); + return grouped.map((p) => p.v); +} + +/** + * Values ready to stack: a gap is nothing (0), and so is a negative reading, which a stack of + * shares can't show (power flowing back out, a sensor glitch). + */ +export function stackable(values: (number | null)[]): number[] { + return values.map((v) => (v === null || !Number.isFinite(v) || v < 0 ? 0 : v)); +} + +/** + * Inside-out order (Byron & Wattenberg): series that peak early go in first, each added to + * whichever side of the stack is lighter, so the biggest swells sit in the middle and the + * edges stay calm. Returns series indices, bottom to top. + */ +export function insideOut(values: number[][]): number[] { + const peaks = values.map((row, i) => { + let at = 0; + for (let j = 1; j < row.length; j++) if (row[j] > row[at]) at = j; + return { i, at, sum: row.reduce((a, v) => a + v, 0) }; + }); + peaks.sort((a, b) => a.at - b.at || a.i - b.i); + let top = 0; + let bottom = 0; + const tops: number[] = []; + const bottoms: number[] = []; + for (const p of peaks) { + if (top < bottom) { + top += p.sum; + tops.push(p.i); + } else { + bottom += p.sum; + bottoms.push(p.i); + } + } + return [...bottoms.reverse(), ...tops]; +} + +/** + * The bottom of the stack at each sample. + * + * - zero: an ordinary stacked area, from nothing up. + * - centre: the stack centred on zero (a "silhouette"). + * - wiggle: the baseline that keeps the layers' slopes smallest overall (Byron & Wattenberg, + * as in d3's stackOffsetWiggle), so each stream reads as flat as it can; then centred. + * - share: zero, with every column already scaled to 100. + * + * `ordered` is the values bottom to top. + */ +export function baseline(ordered: number[][], kind: Baseline): number[] { + const m = ordered[0]?.length ?? 0; + const totals = Array.from({ length: m }, (_, j) => ordered.reduce((a, row) => a + row[j], 0)); + if (kind === 'zero' || kind === 'share') return new Array(m).fill(0); + if (kind === 'centre') return totals.map((t) => -t / 2); + const g = new Array(m).fill(0); + for (let j = 1; j < m; j++) { + let s1 = 0; + let s2 = 0; + for (let i = 0; i < ordered.length; i++) { + const now = ordered[i][j]; + let s3 = (now - ordered[i][j - 1]) / 2; + for (let k = 0; k < i; k++) s3 += ordered[k][j] - ordered[k][j - 1]; + s1 += now; + s2 += s3 * now; + } + g[j] = g[j - 1] - (s1 ? s2 / s1 : 0); + } + // Centre the whole stream on zero, so it sits in the middle of the plot. + let lo = Infinity; + let hi = -Infinity; + for (let j = 0; j < m; j++) { + lo = Math.min(lo, g[j]); + hi = Math.max(hi, g[j] + totals[j]); + } + const shift = m ? -(lo + hi) / 2 : 0; + return g.map((v) => v + shift); +} + +/** Each column as a share of its total, in per cent; a column with nothing in it stays empty. */ +export function toShares(values: number[][]): number[][] { + const m = values[0]?.length ?? 0; + const totals = Array.from({ length: m }, (_, j) => values.reduce((a, row) => a + row[j], 0)); + return values.map((row) => row.map((v, j) => (totals[j] > 0 ? (v / totals[j]) * 100 : 0))); +} + +/** + * Stack `values` (one row per series, same length) in `order` (bottom to top) on `kind`'s + * baseline. Layers come back indexed like `values`, not in stacking order. + */ +export function stack(values: number[][], order: number[], kind: Baseline): Layer[] { + const rows = kind === 'share' ? toShares(values) : values; + const ordered = order.map((i) => rows[i]); + let lower = baseline(ordered, kind); + const layers: Layer[] = new Array(values.length); + for (const i of order) { + const upper = lower.map((b, j) => b + rows[i][j]); + layers[i] = { lower, upper }; + lower = upper; + } + return layers; +} + +/** Lowest bottom and highest top across the layers, or undefined when there are none. */ +export function layersExtent(layers: Layer[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const l of layers) { + for (const v of l.lower) lo = Math.min(lo, v); + for (const v of l.upper) hi = Math.max(hi, v); + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** The sample where a layer is thickest, for its label. */ +export function widest(layer: Layer): { at: number; thickness: number } { + let at = 0; + let thickness = -Infinity; + layer.upper.forEach((u, j) => { + const t = u - layer.lower[j]; + if (t > thickness) { + thickness = t; + at = j; + } + }); + return { at, thickness: Math.max(0, thickness) }; +} + +/** A name to put on a stream: where its middle is (px) and how wide the text is. */ +export interface LabelSpot { + key: number; + x: number; + y: number; + width: number; + /** How thick the stream is there (px): thicker streams get their label first. */ + thickness: number; +} + +/** Text height in px, for keeping labels apart. */ +const LABEL_H = 13; + +/** + * Where labels go without overlapping: thickest stream first, each centred on its spot but + * kept inside the plot; one that would touch a label already placed is left out. Returns each + * placed label's left edge (px), by key. + */ +export function placeLabels(spots: LabelSpot[], plotWidth: number): Map { + const placed: { left: number; right: number; top: number; bottom: number }[] = []; + const out = new Map(); + for (const s of [...spots].sort((a, b) => b.thickness - a.thickness || a.key - b.key)) { + if (s.width > plotWidth - 4) continue; + const left = Math.min(Math.max(2, s.x - s.width / 2), plotWidth - s.width - 2); + const box = { left, right: left + s.width, top: s.y - LABEL_H / 2, bottom: s.y + LABEL_H / 2 }; + const hit = placed.some( + (p) => box.left < p.right + 4 && box.right > p.left - 4 && box.top < p.bottom && box.bottom > p.top, + ); + if (hit) continue; + placed.push(box); + out.set(s.key, left); + } + return out; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 43771a6..9001cf2 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -34,6 +34,7 @@ import { slope } from './slope'; import { dumbbell } from './dumbbell'; import { rangeArea } from './range-area'; import { sparklineStyle } from './sparkline'; +import { streamgraph } from './streamgraph'; export interface RenderContext { show: ShowOptions; @@ -157,4 +158,5 @@ export const STYLES: Record = { dumbbell, range_area: rangeArea, sparkline: sparklineStyle, + streamgraph, }; diff --git a/src/styles/streamgraph.ts b/src/styles/streamgraph.ts new file mode 100644 index 0000000..d9e711f --- /dev/null +++ b/src/styles/streamgraph.ts @@ -0,0 +1,472 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { onFillText } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath } from '../chart/paths'; +import type { Curve, XY } from '../chart/paths'; +import { + BASELINES, + STREAM_ORDERS, + binSeries, + insideOut, + placeLabels, + layersExtent, + sampleBounds, + stack, + stackable, + widest, +} from '../chart/stream'; +import type { Baseline, LabelSpot, Layer, StreamOrder } from '../chart/stream'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +/** Samples across the plot: one per 4 px or so, never more than this. */ +const MAX_SAMPLES = 240; +/** A stream thinner than this (px) gets no name on it. */ +const LABEL_MIN_PX = 18; +/** Plot height in px, for deciding which streams are thick enough to label. */ +const PLOT_PX = 180; +const COMPACT_PLOT_PX = 110; +/** Rough width of one character of a stream's name (11 px text), for keeping names apart. */ +const LABEL_CHAR_PX = 6.2; + +registerStrings('en', { + editor_style_streamgraph: 'Streamgraph', + editor_baseline: 'Shape', + editor_baseline_wiggle: 'Streamgraph (flowing)', + editor_baseline_centre: 'Centred', + editor_baseline_share: 'Share of the total (100 %)', + editor_baseline_zero: 'Stacked from zero', + editor_stream_order: 'Order', + editor_stream_order_inside_out: 'Biggest in the middle', + editor_stream_order_listed: 'As listed, first at the bottom', + editor_stream_labels: 'Names on the streams', + editor_stream_curve: 'Edges', + editor_fill_color: 'Colour', + stream_total: 'Total', +}); + +/** One sample across every stream: when, and each series' value there. */ +interface Samples { + times: number[]; + /** Per series (frame order): the value in each sample, null for a gap. */ + values: (number | null)[][]; +} + +/** + * The series on shared times. Grouped readings already are (one value per group, at its middle); + * otherwise each series is binned into even slices of the range by its time-weighted mean. + * Nothing is drawn past now. + */ +export function streamSamples(frame: HistoryFrame, now: number, width: number): Samples { + const until = Math.min(frame.range.end, now); + if (frame.group) { + const first = frame.series[0]?.points ?? []; + const keep = first.map((_, j) => j).filter((j) => first[j].t < until); + return { + times: keep.map((j) => (first[j].t + Math.min(first[j].end ?? first[j].t, until)) / 2), + values: frame.series.map((s) => keep.map((j) => s.points[j]?.v ?? null)), + }; + } + let count = Math.min(MAX_SAMPLES, Math.max(24, Math.round(width / 4))); + // Statistics rows are a fixed length: never cut finer than they are, or bins come up empty. + if (frame.source === 'statistics') { + const rows = Math.max(1, ...frame.series.map((s) => s.points.filter((p) => !p.carried).length)); + count = Math.min(count, rows); + } + const bounds = sampleBounds(frame.range, until, count); + return { + times: bounds.slice(0, -1).map((t, i) => (t + bounds[i + 1]) / 2), + values: frame.series.map((s) => binSeries(s.points, bounds, frame.source, until)), + }; +} + +function baselineOf(raw: CardConfig): Baseline { + return (raw.baseline as Baseline | undefined) ?? 'wiggle'; +} + +function orderOf(raw: CardConfig): StreamOrder { + return (raw.stream_order as StreamOrder | undefined) ?? 'inside_out'; +} + +/** The area between a layer's edges, from the first sample to the last. */ +export function layerPath(layer: Layer, xs: number[], scales: PlotScales, curve: Curve): string { + const top: XY[] = xs.map((x, j) => [x, scales.y(layer.upper[j])]); + const bottom: XY[] = xs.map((x, j) => [x, scales.y(layer.lower[j])]).reverse() as XY[]; + if (top.length === 0) return ''; + return `${runPath(top, curve)}L${runPath(bottom, curve).slice(1)}Z`; +} + +/** Name of a series' colour before it is turned into CSS, for picking text that reads on it. */ +function colorName(s: ChartSeries): string { + return (s.entity.config as { color?: string }).color ?? SERIES_COLORS[s.index % SERIES_COLORS.length]; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function streamLabels( + frame: HistoryFrame, + layers: Layer[], + xs: number[], + scales: PlotScales, + ctx: ChartContext, + share: boolean, + values: (number | null)[][], +) { + const [lo, hi] = scales.domain; + const plotPx = chartLayout(ctx.raw).compact ? COMPACT_PLOT_PX : PLOT_PX; + const plotW = plotWidth(ctx); + const px = (v: number) => (v / (hi - lo)) * plotPx; + const texts = new Map(); + const spots: LabelSpot[] = []; + for (const s of frame.series) { + const layer = layers[s.index]; + const { at, thickness } = widest(layer); + if (px(thickness) < LABEL_MIN_PX) continue; + const v = values[s.index][at]; + const amount = share + ? `${formatNumber(layer.upper[at] - layer.lower[at], 0, ctx.hass)} %` + : v === null + ? '' + : withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const text = `${s.entity.name} ${amount}`.trim(); + texts.set(s.index, text); + spots.push({ + key: s.index, + x: (xs[at] / VIEW_W) * plotW, + y: (scales.y((layer.upper[at] + layer.lower[at]) / 2) / VIEW_H) * plotPx, + width: text.length * LABEL_CHAR_PX, + thickness: px(thickness), + }); + } + const placed = placeLabels(spots, plotW); + return html``; +} + +/** The guide and a box listing each stream's value (and share) at the sample under the pointer. */ +function streamTooltip( + ctx: ChartContext, + frame: HistoryFrame, + samples: Samples, + layers: Layer[], + order: number[], + xs: number[], + t: number | undefined, + share: boolean, +) { + if (t === undefined) return nothing; + // Rolling spans move with the clock between redraws: take the nearest sample, not an exact one. + const j = nearestIndex( + samples.times.map((x) => ({ t: x })), + t, + ); + if (j < 0) return nothing; + const x = xs[j] / VIEW_W; + const side = tooltipSide(x, plotWidth(ctx)); + // Top of the stack first, as it is drawn. + const rows = [...order].reverse().map((i) => frame.series[i]); + const shares = layers.map((l) => l.upper[j] - l.lower[j]); + const total = samples.values.reduce((a, row) => a + Math.max(0, row[j] ?? 0), 0); + const units = new Set(frame.series.map((s) => s.entity.unit)); + const value = (s: ChartSeries) => { + const v = samples.values[s.index][j]; + if (v === null) return '—'; + const text = withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + return share ? `${text} · ${formatNumber(shares[s.index], 0, ctx.hass)} %` : text; + }; + return html``; +} + +/** Shape fields come first; value labels only mean something on a stack from zero or of shares. */ +function streamVariant(config: CardConfig): string { + const b = baselineOf(config); + return `${historyVariant(config)}|${b === 'wiggle' || b === 'centre' ? 'floating' : 'axis'}`; +} + +const STREAM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'baseline', path: ['baseline'], default: 'wiggle' }, + { name: 'stream_order', path: ['stream_order'], default: 'inside_out' }, + { name: 'stream_curve', path: ['curve'], default: 'smooth' }, + { name: 'stream_labels', path: ['labels'], default: true }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** Floating streams have no value scale to label: drop value labels, lines across and the range. */ +const FLOATING_HIDDEN = new Set(['y_axis', 'grid_horizontal', 'y_min', 'y_max']); + +function withoutFloating(schema: unknown[]): unknown[] { + return schema + .map((item) => { + const s = item as { name: string; schema?: { name: string }[] }; + if (FLOATING_HIDDEN.has(s.name)) return undefined; + if (s.schema) { + const inner = s.schema.filter((x) => !FLOATING_HIDDEN.has(x.name)); + return inner.length ? { ...s, schema: inner } : undefined; + } + return s; + }) + .filter((x) => x !== undefined); +} + +export function streamEditor(ctx: ChartEditorContext): TemplateResult { + const variant = streamVariant(ctx.config); + const schema = cachedSchema(`streamgraph|${variant}`, ctx.hass, () => { + const axes = axesSchema(ctx.hass); + return [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'baseline', selector: choice(ctx.hass, 'baseline', BASELINES) }, + { name: 'stream_order', selector: choice(ctx.hass, 'stream_order', STREAM_ORDERS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'stream_curve', selector: choice(ctx.hass, 'curve', ['smooth', 'straight', 'step']) }, + { name: 'stream_labels', selector: { boolean: {} } }, + ], + }, + ...(variant.endsWith('floating') ? withoutFloating(axes) : axes), + ...layoutSchema(ctx.hass), + ]; + }); + return chartForm(ctx, 'chart', schema, STREAM_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +export const streamgraph: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .style-streamgraph .stream { + cursor: pointer; + stroke: var(--card-background-color, #fff); + stroke-width: 1px; + stroke-linejoin: round; + } + .stream-labels { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + } + .stream-label { + position: absolute; + transform: translateY(-50%); + font-size: 11px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: #fff; + } + .stream-label.dark { + color: rgba(0, 0, 0, 0.8); + } + .stream-label.theme { + color: var(--primary-text-color); + } + .tooltip-row.total { + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + margin-top: 2px; + padding-top: 2px; + } + `, + thumbnail: () => svg` + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + if (raw.baseline !== undefined && !BASELINES.includes(raw.baseline as Baseline)) { + throw new Error(`"baseline" must be one of: ${BASELINES.join(', ')}`); + } + if (raw.stream_order !== undefined && !STREAM_ORDERS.includes(raw.stream_order as StreamOrder)) { + throw new Error(`"stream_order" must be one of: ${STREAM_ORDERS.join(', ')}`); + } + if (raw.labels !== undefined && typeof raw.labels !== 'boolean') throw new Error('"labels" must be true or false'); + }, + editor: streamEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const kind = baselineOf(ctx.raw); + const share = kind === 'share'; + const floating = kind === 'wiggle' || kind === 'centre'; + const samples = streamSamples(frame, now, plotWidth(ctx)); + const values = samples.values.map(stackable); + const order = orderOf(ctx.raw) === 'listed' ? values.map((_, i) => i) : insideOut(values); + const layers = stack(values, order, kind); + const extent = layersExtent(layers); + // A floating stream has no scale worth labelling, so it fills the plot; a share runs 0 to 100, + // and a stack from zero starts at zero unless the card says otherwise. + let axisRaw: CardConfig = ctx.raw; + if (floating) { + const [lo, hi] = extent ?? [0, 1]; + const pad = hi > lo ? (hi - lo) * 0.04 : 1; + axisRaw = { + ...ctx.raw, + y_axis: false, + y_min: lo - pad, + y_max: hi + pad, + gridlines: { ...((ctx.raw.gridlines as object | undefined) ?? {}), horizontal: false }, + }; + } else if (share) axisRaw = { ...ctx.raw, y_min: 0, y_max: 100 }; + else if (ctx.raw.y_min === undefined) axisRaw = { ...ctx.raw, y_min: 0 }; + const axes = plotAxes({ ...ctx, raw: axisRaw }, frame.range, extent); + const { scales } = axes; + const xs = samples.times.map((t) => scales.x(t)); + const curve = (ctx.raw.curve as Curve | undefined) ?? 'smooth'; + // Carry the edge samples out to the edges of the data, so the streams don't start in mid-air. + const until = Math.min(frame.range.end, now); + const edgeX = [scales.x(frame.range.start), ...xs, scales.x(until)]; + const edged = (l: Layer): Layer => ({ + lower: [l.lower[0], ...l.lower, l.lower[l.lower.length - 1]], + upper: [l.upper[0], ...l.upper, l.upper[l.upper.length - 1]], + }); + const streams = + samples.times.length === 0 + ? svg`` + : svg`${order.map((i) => { + const s = frame.series[i]; + return svg``; + })}`; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_streamgraph'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const hoverPoints = samples.times.map((t) => ({ t, v: 0 })); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx: { ...ctx, raw: axisRaw }, + content: streams, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${ + ctx.raw.labels !== false && samples.times.length + ? streamLabels(frame, layers, xs, scales, ctx, share, samples.values) + : nothing + } + ${tooltip ? streamTooltip(ctx, frame, samples, layers, order, xs, hoverTime(ctx.host), share) : nothing}`, + hover: tooltip ? hoverHandlers(ctx, frame.range, [hoverPoints]) : undefined, + }), + ); + }, +}; diff --git a/src/types.ts b/src/types.ts index 35778ea..ca4d7bc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/streamgraph.test.ts b/test/streamgraph.test.ts new file mode 100644 index 0000000..1cba887 --- /dev/null +++ b/test/streamgraph.test.ts @@ -0,0 +1,344 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + baseline, + binSeries, + insideOut, + layersExtent, + placeLabels, + sampleBounds, + stack, + stackable, + toShares, + widest, +} from '../src/chart/stream'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +describe('stream maths', () => { + it('cuts the range into even bins up to now', () => { + expect(sampleBounds({ start: 0, end: 100 }, 1000, 4)).toEqual([0, 25, 50, 75, 100]); + expect(sampleBounds({ start: 0, end: 100 }, 50, 2)).toEqual([0, 25, 50]); + // Not begun yet: one bin, so nothing divides by zero. + expect(sampleBounds({ start: 100, end: 200 }, 50, 3)).toHaveLength(4); + }); + + it('bins history by time-weighted mean, statistics by the mean of their rows', () => { + const history = [ + { t: 0, v: 10 }, + { t: 90, v: 0 }, + { t: 100, v: null }, + ]; + expect(binSeries(history, [0, 100, 200], 'history', 200)).toEqual([9, null]); + const rows = [ + { t: 0, v: 2 }, + { t: 50, v: 4 }, + { t: 100, v: 6 }, + ]; + expect(binSeries(rows, [0, 100, 200], 'statistics', 200)).toEqual([3, 6]); + }); + + it('stacks gaps and negative readings as nothing', () => { + expect(stackable([1, null, -2, Number.NaN, 3])).toEqual([1, 0, 0, 0, 3]); + }); + + it('orders inside out: early peaks first, each to the lighter side', () => { + const values = [ + [5, 1, 1], + [1, 5, 1], + [1, 1, 5], + [9, 9, 9], + ]; + // By peak: 0, 3 (a tie keeps the listed order), 1, 2; each goes to the lighter side. + const order = insideOut(values); + expect(order).toHaveLength(4); + expect([...order].sort()).toEqual([0, 1, 2, 3]); + expect(order).toEqual([2, 1, 0, 3]); + }); + + it('puts the baseline at zero, centred, or where the layers wiggle least', () => { + const rows = [ + [1, 2], + [3, 4], + ]; + expect(baseline(rows, 'zero')).toEqual([0, 0]); + expect(baseline(rows, 'share')).toEqual([0, 0]); + expect(baseline(rows, 'centre')).toEqual([-2, -3]); + // One flat layer: no wiggle to remove, and it is centred on zero. + expect(baseline([[2, 2, 2]], 'wiggle')).toEqual([-1, -1, -1]); + // A layer growing under a flat one: the baseline moves down so the flat one stays flatter. + const g = baseline( + [ + [0, 4], + [2, 2], + ], + 'wiggle', + ); + expect(g[1]).toBeLessThan(g[0]); + expect(baseline([], 'wiggle')).toEqual([]); + }); + + it('turns columns into shares, leaving empty columns empty', () => { + expect( + toShares([ + [1, 0], + [3, 0], + ]), + ).toEqual([ + [25, 0], + [75, 0], + ]); + }); + + it('stacks layers in order, returned in series order', () => { + const layers = stack( + [ + [1, 1], + [2, 2], + ], + [1, 0], + 'zero', + ); + expect(layers[1]).toEqual({ lower: [0, 0], upper: [2, 2] }); + expect(layers[0]).toEqual({ lower: [2, 2], upper: [3, 3] }); + expect(layersExtent(layers)).toEqual([0, 3]); + expect(layersExtent([])).toBeUndefined(); + const shares = stack([[1], [3]], [0, 1], 'share'); + expect(shares[1].upper).toEqual([100]); + expect(widest({ lower: [0, 0, 1], upper: [1, 3, 2] })).toEqual({ at: 1, thickness: 3 }); + }); + + it('places labels thickest first, inside the plot, never overlapping', () => { + const placed = placeLabels( + [ + { key: 0, x: 50, y: 50, width: 60, thickness: 20 }, + { key: 1, x: 60, y: 55, width: 60, thickness: 30 }, + { key: 2, x: 5, y: 10, width: 40, thickness: 20 }, + { key: 3, x: 50, y: 90, width: 500, thickness: 40 }, + ], + 200, + ); + expect(placed.get(1)).toBe(30); + expect(placed.has(0)).toBe(false); + expect(placed.get(2)).toBe(2); + expect(placed.has(3)).toBe(false); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.heat': ['1200', { friendly_name: 'Heat pump', unit_of_measurement: 'W' }], + 'sensor.ev': ['0', { friendly_name: 'EV', unit_of_measurement: 'W' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone', unit_of_measurement: 'W' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.heat': [ + { s: '1000', lu: sec(20 * H) }, + { s: '2000', lu: sec(10 * H) }, + { s: '1200', lu: sec(2 * H) }, + ], + 'sensor.ev': [ + { s: '7000', lu: sec(20 * H) }, + { s: '-50', lu: sec(12 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '0', lu: sec(6 * H) }, + ], + 'sensor.gone': [{ s: 'unavailable', lu: sec(20 * H) }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'streamgraph', + entities: ['sensor.heat', 'sensor.ev', 'sensor.gone'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('streamgraph', () => { + it('draws one closed stream per entity with no value labels, an unavailable one flat', async () => { + const card = await mount({}); + const streams = qa(card, 'path.stream'); + expect(streams).toHaveLength(3); + for (const s of streams) expect(s.getAttribute('d')).toMatch(/^M.*Z$/); + expect(streams.find((s) => s.getAttribute('data-index') === '0')!.getAttribute('fill')).toContain( + '--primary-color', + ); + expect(q(card, '.y-axis')).toBeNull(); + expect(q(card, '.x-axis')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Streamgraph chart of Heat pump, EV, Gone'); + expect(qa(card, '.series-item')).toHaveLength(3); + // The heat pump is the thickest stream: it gets its name on it. + expect(qa(card, '.stream-label').map((l) => l.textContent)[0]).toMatch(/^Heat pump [\d,.]+ W$/); + }); + + it('stacks shares of the total from 0 to 100, and from zero when asked', async () => { + const card = await mount({ baseline: 'share', stream_order: 'listed', labels: false } as Partial); + const ticks = qa(card, '.y-axis .axis-label').map((l) => l.textContent); + expect(ticks[0]).toBe('0'); + expect(ticks.at(-1)).toBe('100'); + expect(q(card, '.stream-label')).toBeNull(); + const zero = await mount({ baseline: 'zero', curve: 'step' } as Partial); + expect(qa(zero, '.y-axis .axis-label')[0].textContent).toBe('0'); + expect(q(zero, 'path.stream')!.getAttribute('d')).toMatch(/H/); + }); + + it('streams grouped readings one value per group', async () => { + const card = await mount({ group_by: 'hour', baseline: 'centre' } as Partial); + expect(qa(card, 'path.stream')).toHaveLength(3); + }); + + it('lists every value, its share and the total on hover', async () => { + const card = await mount({ baseline: 'share' } as Partial); + point(q(card, '.plot-area')!, 'pointermove', 0.3); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toHaveLength(4); + expect(rows.find((r) => r.startsWith('EV'))).toMatch(/W · \d+ %$/); + expect(rows.at(-1)).toMatch(/^Total /); + point(q(card, '.plot-area')!, 'pointerleave', 0.1); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + }); + + it('opens more-info from a stream', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const stream = q(card, 'path.stream[data-index="1"]')!; + stream.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + stream.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.ev']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + expect(q(card, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'streamgraph', entities: ['sensor.heat'], ...extra } as CardConfig); + expect(set({ baseline: 'flat' })).toThrow(/"baseline" must be one of: wiggle, centre, share, zero/); + expect(set({ stream_order: 'random' })).toThrow(/stream_order/); + expect(set({ labels: 'yes' })).toThrow(/labels/); + expect(set({ source: 'cloud' })).toThrow(/source/); + expect(set({ baseline: 'share', stream_order: 'listed', labels: false })).not.toThrow(); + }); +}); + +describe('streamgraph editor', () => { + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { + type: 'custom:chartdeck-card', + style: 'streamgraph', + entities: ['sensor.heat'], + ...config, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw, form: ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub }; + } + + const names = (schema: unknown[]): string[] => + schema.flatMap((s) => { + const item = s as { name: string; schema?: unknown[] }; + return item.schema ? names(item.schema) : [item.name]; + }); + + it('writes the shape, order, edges and labels, and hides the value scale on a floating stream', async () => { + const { ed, events, raw, form } = await editor({}); + expect(form.data).toMatchObject({ + baseline: 'wiggle', + stream_order: 'inside_out', + stream_curve: 'smooth', + stream_labels: true, + }); + expect(names(form.schema as unknown[])).not.toContain('y_axis'); + expect(names(form.schema as unknown[])).not.toContain('y_min'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + baseline: 'share', + stream_order: 'listed', + stream_curve: 'straight', + stream_labels: false, + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ + ...raw, + baseline: 'share', + stream_order: 'listed', + curve: 'straight', + labels: false, + }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); + + it('shows value labels for a stack from zero', async () => { + const { form } = await editor({ baseline: 'zero' } as Partial); + expect(names(form.schema as unknown[])).toContain('y_axis'); + }); +}); From cffb4c836a2cd9203328f4546ed79197249285ea Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:17:05 +0000 Subject: [PATCH 18/41] feat: candlestick chart of open, high, low and close per period A new "Candlestick" style (History charts in the style picker) draws one candle per hour, day, week or month for the card's first entity: the box runs from where the period opened to where it closed, the wick from its highest to its lowest. A candle that closed at or above its open is drawn in the "rose" colour (green unless set), one that closed lower in the "fell" colour (red). It suits an electricity price per day, or a day's temperature. Candles come from long-term statistics by default (Home Assistant only keeps 10 days of full history), with a last-14-days period. Daily candles are built from hourly rows: the open is the first hour's average, the close the last hour's, and high and low the highest and lowest the statistics recorded. Hourly candles use five-minute rows; weeks and months use daily rows when Home Assistant's time zone is the card's. With history as the source, the open is the value held as the period began and high and low are every value that held in it. A period with no readings has no candle. Any other entities are drawn underneath the candles, in a strip at the bottom, as columns (default) or a line, each scaled to its own largest value and grey unless given a colour. They are combined per period like grouped readings on the line chart ("combine readings with"), so a meter set to "change" shows how much was used each day. Hovering shows the period, its open, high, low and close, and the values underneath. Candles open the first entity's more-info (or its tap action); the strip opens its own entity. Period navigation, axes, gridlines, the hover box and compact layout work as on the line chart. Everything is in the editor's Chart section: the period, "One candle per", how the entities underneath are combined, the rise and fall colours, columns or a line underneath, and the shared axes and layout fields. Candle maths is in src/chart/candles.ts. Closes #31 --- src/chart/candles.ts | 91 ++++++++ src/styles/candlestick.ts | 439 ++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/candlestick.test.ts | 311 +++++++++++++++++++++++++++ 5 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 src/chart/candles.ts create mode 100644 src/styles/candlestick.ts create mode 100644 test/candlestick.test.ts diff --git a/src/chart/candles.ts b/src/chart/candles.ts new file mode 100644 index 0000000..9dc49ce --- /dev/null +++ b/src/chart/candles.ts @@ -0,0 +1,91 @@ +import { groupHistory, groupStatistics } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Source } from '../history/types'; + +/** + * Open, high, low and close for each period, from history (a reading holds until the next) or + * from long-term statistics rows (each an hour or five minutes, with its own mean, min and max). + */ + +export interface Candle { + /** Where the period starts and ends. */ + t: number; + end: number; + open: number; + high: number; + low: number; + close: number; +} + +/** + * Candles from history. Open is the value held as the period began (carried in from before it), + * close the last value in it, high and low every value that held in it. A period with nothing + * known, or one that starts after `until` (now), has no candle (null). + */ +export function historyCandles(points: Point[], bounds: number[], until: number): (Candle | null)[] { + const of = (aggregate: Aggregate) => groupHistory(points, bounds, aggregate, until); + const [open, high, low, close] = (['first', 'max', 'min', 'last'] as const).map(of); + return open.map((g, i) => { + if (g.v === null || high[i].v === null || low[i].v === null || close[i].v === null) return null; + return { t: g.t, end: g.end ?? g.t, open: g.v, high: high[i].v!, low: low[i].v!, close: close[i].v! }; + }); +} + +/** + * Candles from statistics rows: open is the first row's value (its mean, or its state for a + * sensor without one), close the last row's, high and low the highest max and lowest min (each + * row's value where it has none). A row moved to the range's start belongs to the period before. + */ +export function statisticsCandles(rows: Point[], bounds: number[], until: number): (Candle | null)[] { + const out: (Candle | null)[] = []; + let j = 0; + for (let i = 0; i + 1 < bounds.length; i++) { + const start = bounds[i]; + const end = bounds[i + 1]; + while (j < rows.length && rows[j].t < start) j++; + let candle: Candle | null = null; + for (let k = j; k < rows.length && rows[k].t < end; k++) { + const r = rows[k]; + if (r.v === null || r.carried || start >= until) continue; + const hi = typeof r.max === 'number' ? Math.max(r.max, r.v) : r.v; + const lo = typeof r.min === 'number' ? Math.min(r.min, r.v) : r.v; + if (!candle) candle = { t: start, end, open: r.v, high: hi, low: lo, close: r.v }; + else { + candle.high = Math.max(candle.high, hi); + candle.low = Math.min(candle.low, lo); + candle.close = r.v; + } + } + out.push(candle); + } + return out; +} + +/** + * One value per period for a series drawn under the candles (usage, a second price): grouped + * with `aggregate` as the line chart would. Statistics rows of a meter carry its reading, so its + * sum or change is how far that reading rose (resets handled), not the rows added up. + */ +export function underValues( + points: Point[], + bounds: number[], + aggregate: Aggregate, + source: Source, + until: number, +): Point[] { + if (source === 'history') return groupHistory(points, bounds, aggregate, until); + if (aggregate === 'sum' || aggregate === 'change') return groupHistory(points, bounds, 'change', until); + return groupStatistics(points, bounds, aggregate, until); +} + +/** Lowest low and highest high, or undefined with no candles. */ +export function candleExtent(candles: (Candle | null)[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const c of candles) { + if (!c) continue; + lo = Math.min(lo, c.low); + hi = Math.max(hi, c.high); + } + return lo === Infinity ? undefined : [lo, hi]; +} diff --git a/src/styles/candlestick.ts b/src/styles/candlestick.ts new file mode 100644 index 0000000..a862bca --- /dev/null +++ b/src/styles/candlestick.ts @@ -0,0 +1,439 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { candleExtent, historyCandles, statisticsCandles, underValues } from '../chart/candles'; +import type { Candle } from '../chart/candles'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath, runs } from '../chart/paths'; +import { linear } from '../chart/scale'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { key } from '../history/fetch'; +import type { StatisticsPeriod, StatisticsQuery } from '../history/fetch'; +import { AGGREGATES, GROUP_UNITS } from '../history/group'; +import type { Aggregate, GroupUnit } from '../history/group'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import { storeFor } from '../history/store'; +import { boundaries, startOf, zoneFor } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Point, Range, Source } from '../history/types'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const UNDERNEATH = ['columns', 'line'] as const; +export type Underneath = (typeof UNDERNEATH)[number]; + +/** A candle's body is this share of its period's width. */ +const BODY = 0.7; +/** With series underneath, the candles take the top of the plot and they take this much of the bottom. */ +const STRIP = 0.22; +/** The gap between the candles and the strip, as a share of the plot's height. */ +const STRIP_GAP = 0.05; +/** The thinnest body (a period that closed where it opened), in plot units. */ +const MIN_BODY = 1.5; + +registerStrings('en', { + editor_style_candlestick: 'Candlestick', + editor_candle_by: 'One candle per', + editor_under_aggregate: 'Entities underneath: combine readings with', + editor_up_color: 'Colour when it rose', + editor_down_color: 'Colour when it fell', + editor_underneath: 'Draw the other entities underneath as', + editor_underneath_columns: 'Columns', + editor_underneath_line: 'A line', + candle_open: 'Open', + candle_high: 'High', + candle_low: 'Low', + candle_close: 'Close', + candle_week_of: 'Week of {date}', +}); + +/** The card's period, with a candlestick's defaults: the last 14 days. */ +export function candlePeriod(raw: CardConfig): CardConfig['period'] { + const own = raw.period as Record | undefined; + return { last: 14, unit: 'day', ...(own ?? {}) } as CardConfig['period']; +} + +function unitOf(raw: CardConfig): GroupUnit { + return (raw.group_by as GroupUnit | undefined) ?? 'day'; +} + +function sourceOf(raw: CardConfig): Source { + return (raw.source as Source | undefined) ?? 'statistics'; +} + +/** + * Statistics rows to build candles from: five-minute rows for hourly candles, hourly rows for + * daily ones (an open and close within the day), and daily rows for weeks and months when Home + * Assistant's days are the card's own (otherwise hourly, cut up here). Mean, min, max and state, + * so a measurement and a meter both have something. + */ +export function candleQuery(unit: GroupUnit, zone: Zone, serverTimeZone: string | undefined): StatisticsQuery { + let period: StatisticsPeriod = 'hour'; + if (unit === 'hour') period = '5minute'; + else if (unit !== 'day' && zone.timeZone === serverTimeZone) period = 'day'; + return { period, types: ['mean', 'min', 'max', 'state'], value: 'mean' }; +} + +interface CandleFrame extends HistoryFrame { + /** The first entity's candles, one per period (null where there is no reading). */ + candles: (Candle | null)[]; + unit: GroupUnit; +} + +/** + * Load the card's readings and turn them into candles (the first entity) and one value per + * period for the rest. Like the line chart's history frame, but the candles need every reading + * in a period, not one combined value, and the range always runs to the end of the last period. + */ +function candleFrame(ctx: ChartContext, now = Date.now()): CandleFrame { + const period = parsePeriod(candlePeriod(ctx.raw)); + const source = sourceOf(ctx.raw); + const unit = unitOf(ctx.raw); + const aggregate = (ctx.raw.aggregate as Aggregate | undefined) ?? 'mean'; + const zone = zoneFor(ctx.hass); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const start = startOf(unit, span.start, zone); + const bounds = boundaries(unit, start, span.end, zone); + const range: Range = { start, end: bounds[bounds.length - 1] }; + const until = Math.min(span.end, now); + const requests = ctx.items.map((e) => ({ entity: e.entityId, attribute: e.config.attribute })); + const store = storeFor(ctx.host, ctx.requestUpdate); + const statistics = source === 'statistics' ? candleQuery(unit, zone, ctx.hass.config?.time_zone) : undefined; + const loaded = store.get(ctx.hass, requests, { start, end: span.end }, source, { live: span.live, statistics }); + let candles: (Candle | null)[] = []; + const series = ctx.items.map((entity, index): ChartSeries => { + const own = entity.config as typeof entity.config & { color?: string }; + const points = loaded.data.get(key(requests[index])) ?? []; + let grouped: Point[]; + if (index === 0) { + candles = + source === 'statistics' ? statisticsCandles(points, bounds, until) : historyCandles(points, bounds, until); + grouped = candles.map((c, i) => ({ t: bounds[i], end: bounds[i + 1], v: c ? c.close : null })); + } else { + grouped = underValues(points, bounds, aggregate, source, until); + } + return { + index, + entity, + points: grouped, + color: baseColor(own.color ?? (index === 0 ? SERIES_COLORS[0] : 'grey')), + curve: 'smooth', + dash: false, + }; + }); + return { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series, + live: span.live, + back: span.back, + zone, + group: { unit, aggregate }, + candles, + unit, + }; +} + +/** A period's name in the tooltip: a time for hours, a date for days, "Week of …", a month. */ +export function periodTitle(t: number, unit: GroupUnit, hass: HomeAssistant | undefined, zone: Zone): string { + if (unit === 'hour') return formatDateTime(t, hass); + const options: Intl.DateTimeFormatOptions = + unit === 'month' + ? { month: 'long', year: 'numeric', timeZone: zone.timeZone } + : { weekday: unit === 'day' ? 'short' : undefined, day: 'numeric', month: 'short', timeZone: zone.timeZone }; + let format: Intl.DateTimeFormat; + try { + format = new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options); + } catch { + format = new Intl.DateTimeFormat('en', options); + } + const date = format.format(t); + return unit === 'week' ? localize(hass, 'candle_week_of', { date }) : date; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function candleTooltip(ctx: ChartContext, frame: CandleFrame, x: (t: number) => number, t: number | undefined) { + if (t === undefined) return nothing; + const mids = frame.series[0].points.map((p) => ({ t: (p.t + (p.end ?? p.t)) / 2 })); + const i = nearestIndex(mids, t); + if (i < 0) return nothing; + const c = frame.candles[i]; + const at = x(mids[i].t) / VIEW_W; + const side = tooltipSide(at, plotWidth(ctx)); + const first = frame.series[0].entity; + const fmt = (v: number, s = first) => withUnit(formatNumber(v, s.decimals, ctx.hass), s.unit); + const rows: [string, string, string?][] = c + ? [ + [localize(ctx.hass, 'candle_open'), fmt(c.open)], + [localize(ctx.hass, 'candle_high'), fmt(c.high)], + [localize(ctx.hass, 'candle_low'), fmt(c.low)], + [localize(ctx.hass, 'candle_close'), fmt(c.close)], + ] + : [[first.name, '—']]; + for (const s of frame.series.slice(1)) { + const v = s.points[i]?.v; + rows.push([s.entity.name, v === null || v === undefined ? '—' : fmt(v, s.entity), s.color]); + } + return html``; +} + +/** The series under the candles, each scaled to its own largest value, in the strip at the bottom. */ +function underneath(frame: CandleFrame, x: (t: number) => number, kind: Underneath): SVGTemplateResult { + const under = frame.series.slice(1); + const top = VIEW_H * (1 - STRIP); + return svg`${under.map((s, n) => { + const values = s.points.map((p) => p.v).filter((v): v is number => v !== null); + const lo = Math.min(0, ...values); + const hi = Math.max(0, ...values); + const y = linear([lo, hi === lo ? lo + 1 : hi], [VIEW_H, top]); + if (kind === 'line') { + const d = runs(s.points) + .map((run) => + runPath( + run.map((p) => [x((p.t + (p.end ?? p.t)) / 2), y(p.v as number)]), + 'straight', + ), + ) + .join(''); + return svg``; + } + return svg` + ${s.points.map((p) => { + if (p.v === null) return nothing; + const x0 = x(p.t); + const w = x(p.end ?? p.t) - x0; + const each = (w * BODY) / under.length; + const left = x0 + (w * (1 - BODY)) / 2 + n * each; + const y0 = y(Math.max(0, p.v)); + return svg``; + })} + `; + })}`; +} + +const CANDLE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.map((f) => { + if (f.name === 'period_last') return { ...f, default: 14 }; + if (f.name === 'period_unit') return { ...f, default: 'day' }; + if (f.name === 'source') return { ...f, default: 'statistics' }; + if (f.name === 'group_by') return { name: 'candle_by', path: ['group_by'], default: 'day' }; + if (f.name === 'aggregate') return { name: 'under_aggregate', path: ['aggregate'], default: 'mean' }; + return f; + }), + { name: 'up_color', path: ['up_color'], default: 'green' }, + { name: 'down_color', path: ['down_color'], default: 'red' }, + { name: 'underneath', path: ['underneath'], default: 'columns' }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The history fields, with "one candle per" in place of grouping (candles are always grouped). */ +function candleHistorySchema(hass: HomeAssistant | undefined, config: CardConfig): unknown[] { + return historySchema(hass, { ...config, group_by: unitOf(config) }).map((item) => { + const grid = item as { schema?: { name: string }[] }; + if (!grid.schema?.some((s) => s.name === 'group_by')) return item; + return { + ...grid, + schema: [ + { name: 'candle_by', selector: choice(hass, 'group_by', GROUP_UNITS) }, + { name: 'under_aggregate', selector: choice(hass, 'aggregate', AGGREGATES) }, + ], + }; + }); +} + +export function candleEditor(ctx: ChartEditorContext): TemplateResult { + const calendar = (ctx.config.period as { calendar?: unknown } | undefined)?.calendar ? 'calendar' : 'rolling'; + const schema = cachedSchema(`candlestick|${calendar}`, ctx.hass, () => [ + ...candleHistorySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'up_color', selector: { text: {} } }, + { name: 'down_color', selector: { text: {} } }, + ], + }, + { name: 'underneath', selector: choice(ctx.hass, 'underneath', UNDERNEATH) }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, CANDLE_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const candlestick: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .candle { + cursor: pointer; + } + .candle .wick { + stroke-width: 1.5px; + } + .under-columns rect { + opacity: 0.45; + } + .under-line { + stroke-width: 2px; + stroke-linejoin: round; + opacity: 0.7; + } + `, + thumbnail: () => svg` + + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + parsePeriod(candlePeriod(raw)); + for (const k of ['up_color', 'down_color']) { + if (raw[k] !== undefined && (typeof raw[k] !== 'string' || raw[k] === '')) { + throw new Error(`"${k}" must be a colour name or CSS colour`); + } + } + if (raw.underneath !== undefined && !UNDERNEATH.includes(raw.underneath as Underneath)) { + throw new Error(`"underneath" must be one of: ${UNDERNEATH.join(', ')}`); + } + }, + editor: candleEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const frame = candleFrame(ctx); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const message = frameMessage(frame, ctx); + const layout = chartLayout(ctx.raw); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const axes = plotAxes(ctx, frame.range, candleExtent(frame.candles)); + const strip = frame.series.length > 1; + if (strip) { + // Candles in the top of the plot, the other entities in a strip at the bottom. + axes.scales.y = linear(axes.value.domain, [VIEW_H * (1 - STRIP - STRIP_GAP), 0]); + } + const { x, y } = axes.scales; + const up = baseColor((ctx.raw.up_color as string | undefined) ?? 'green'); + const down = baseColor((ctx.raw.down_color as string | undefined) ?? 'red'); + const first = frame.series[0]; + const candles = frame.candles.map((c) => { + if (!c) return nothing; + const x0 = x(c.t); + const w = x(c.end) - x0; + const mid = x0 + w / 2; + const color = c.close >= c.open ? up : down; + const top = y(Math.max(c.open, c.close)); + const height = Math.max(MIN_BODY, y(Math.min(c.open, c.close)) - top); + return svg` + + + `; + }); + const kind = (ctx.raw.underneath as Underneath | undefined) ?? 'columns'; + const content = svg`${strip ? underneath(frame, x, kind) : nothing}${candles}`; + const tooltip = tooltipEnabled(ctx.raw); + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_candlestick'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const mids = first.points.map((p) => ({ t: (p.t + (p.end ?? p.t)) / 2, v: p.v })); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${tooltip ? candleTooltip(ctx, frame, x, hoverTime(ctx.host)) : nothing}`, + hover: tooltip ? hoverHandlers(ctx, frame.range, [mids]) : undefined, + }), + ); + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 9001cf2..f69fdbf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -35,6 +35,7 @@ import { dumbbell } from './dumbbell'; import { rangeArea } from './range-area'; import { sparklineStyle } from './sparkline'; import { streamgraph } from './streamgraph'; +import { candlestick } from './candlestick'; export interface RenderContext { show: ShowOptions; @@ -159,4 +160,5 @@ export const STYLES: Record = { range_area: rangeArea, sparkline: sparklineStyle, streamgraph, + candlestick, }; diff --git a/src/types.ts b/src/types.ts index ca4d7bc..5008ccb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/candlestick.test.ts b/test/candlestick.test.ts new file mode 100644 index 0000000..2853c37 --- /dev/null +++ b/test/candlestick.test.ts @@ -0,0 +1,311 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { candleExtent, historyCandles, statisticsCandles, underValues } from '../src/chart/candles'; +import { candlePeriod, candleQuery, periodTitle } from '../src/styles/candlestick'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +describe('candle maths', () => { + const bounds = [0, 100, 200, 300]; + + it('takes open, high, low and close from history, carrying the value in at each start', () => { + const points = [ + { t: -10, v: 5, carried: true }, + { t: 20, v: 9 }, + { t: 60, v: 3 }, + { t: 80, v: 4 }, + { t: 150, v: null }, + ]; + const candles = historyCandles(points, bounds, 250); + expect(candles[0]).toEqual({ t: 0, end: 100, open: 5, high: 9, low: 3, close: 4 }); + // Unavailable from 150: the second period still had 4 until then. + expect(candles[1]).toEqual({ t: 100, end: 200, open: 4, high: 4, low: 4, close: 4 }); + // Nothing known in the third, which also starts after now. + expect(candles[2]).toBeNull(); + }); + + it('takes candles from statistics rows, with their min and max', () => { + const rows = [ + { t: -50, v: 1, carried: true }, + { t: 0, v: 10, min: 8, max: 12 }, + { t: 50, v: 11, min: 7, max: 13 }, + { t: 100, v: 20 }, + { t: 150, v: null }, + { t: 210, v: 5 }, + ]; + const candles = statisticsCandles(rows, bounds, 195); + expect(candles[0]).toEqual({ t: 0, end: 100, open: 10, high: 13, low: 7, close: 11 }); + expect(candles[1]).toEqual({ t: 100, end: 200, open: 20, high: 20, low: 20, close: 20 }); + expect(candles[2]).toBeNull(); + expect(candleExtent(candles)).toEqual([7, 20]); + expect(candleExtent([null])).toBeUndefined(); + }); + + it('groups the series underneath like the line chart, meters by how far they rose', () => { + const meter = [ + { t: 0, v: 100 }, + { t: 50, v: 103 }, + { t: 150, v: 110 }, + ]; + expect(underValues(meter, [0, 100, 200], 'change', 'statistics', 300).map((p) => p.v)).toEqual([3, 7]); + expect(underValues(meter, [0, 100, 200], 'max', 'statistics', 300).map((p) => p.v)).toEqual([103, 110]); + expect(underValues(meter, [0, 100, 200], 'last', 'history', 300).map((p) => p.v)).toEqual([103, 110]); + }); + + it('asks for rows fine enough to have an open and a close', () => { + const zone = { timeZone: 'Europe/London', firstDay: 1 }; + expect(candleQuery('hour', zone, 'Europe/London').period).toBe('5minute'); + expect(candleQuery('day', zone, 'Europe/London').period).toBe('hour'); + expect(candleQuery('week', zone, 'Europe/London').period).toBe('day'); + expect(candleQuery('month', zone, 'America/New_York').period).toBe('hour'); + expect(candleQuery('day', zone, undefined).types).toEqual(['mean', 'min', 'max', 'state']); + }); + + it('defaults to the last 14 days, keeping what the card sets', () => { + expect(candlePeriod({} as CardConfig)).toEqual({ last: 14, unit: 'day' }); + expect(candlePeriod({ period: { last: 30 } } as CardConfig)).toEqual({ last: 30, unit: 'day' }); + expect(candlePeriod({ period: { calendar: 'month' } } as CardConfig)).toMatchObject({ calendar: 'month' }); + }); + + it('names a period for the tooltip', () => { + const zone = { timeZone: 'UTC', firstDay: 1 }; + const t = Date.UTC(2026, 9, 26); + const gb = { locale: { language: 'en-GB' } } as unknown as HomeAssistant; + expect(periodTitle(t, 'day', gb, zone)).toBe('Mon 26 Oct'); + expect(periodTitle(t, 'week', gb, zone)).toBe('Week of 26 Oct'); + expect(periodTitle(t, 'month', gb, zone)).toBe('October 2026'); + expect(periodTitle(t, 'hour', undefined, zone)).toMatch(/26/); + expect(periodTitle(t, 'day', { language: 'not a language!' } as unknown as HomeAssistant, zone)).toMatch(/26/); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const H = 3_600_000; +const DAY = 24 * H; + +/** Statistics rows: a price that rises on even days and falls on odd ones, and a usage meter. */ +function hassWith(fail = false) { + const hass = makeHass({ + 'sensor.price': ['25.3', { friendly_name: 'Price', unit_of_measurement: 'p' }], + 'sensor.usage': ['1200', { friendly_name: 'Usage', unit_of_measurement: 'kWh' }], + }); + hass.config = { time_zone: 'UTC' } as HomeAssistant['config']; + hass.callWS = vi.fn(async (msg: { type: string; start_time: string; statistic_ids?: string[] }) => { + if (fail) throw new Error('boom'); + const start = Date.parse(msg.start_time); + const out: Record = {}; + if (msg.type === 'recorder/statistics_during_period') { + const price = []; + const usage = []; + for (let t = start; t < Date.now(); t += H) { + const day = Math.floor(t / DAY); + const hour = (t % DAY) / H; + const v = 20 + (day % 2 === 0 ? hour : 24 - hour) / 4; + price.push({ start: t, mean: v, min: v - 1, max: v + 1 }); + usage.push({ start: t, state: (t - start) / H }); + } + out['sensor.price'] = price; + out['sensor.usage'] = usage; + } else { + out['sensor.price'] = [ + { s: '20', lu: (Date.now() - 3 * DAY) / 1000 }, + { s: '24', lu: (Date.now() - 2 * DAY) / 1000 }, + ]; + } + return out; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'candlestick', + entities: ['sensor.price'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('candlestick chart', () => { + it('draws a candle per day for the last 14 days from hourly statistics, rising and falling', async () => { + const card = await mount({}); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call).toMatchObject({ type: 'recorder/statistics_during_period', period: 'hour' }); + const candles = qa(card, 'g.candle'); + expect(candles.length).toBeGreaterThanOrEqual(14); + expect(candles.length).toBeLessThanOrEqual(15); + const ups = qa(card, 'g.candle.up'); + const downs = qa(card, 'g.candle.down'); + expect(ups.length).toBeGreaterThan(0); + expect(downs.length).toBeGreaterThan(0); + expect(ups[0].querySelector('rect')!.getAttribute('fill')).toContain('--success-color'); + expect(downs[0].querySelector('line')!.getAttribute('stroke')).toContain('--error-color'); + expect(q(card, '.under-columns')).toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Candlestick chart of Price'); + }); + + it('draws the other entities underneath as columns or a line, in their own colours', async () => { + const card = await mount({ + entities: ['sensor.price', 'sensor.usage'], + aggregate: 'change', + up_color: 'blue', + } as Partial); + const columns = q(card, '.under-columns')!; + expect(columns.getAttribute('fill')).toContain('--grey-color'); + expect(columns.getAttribute('data-index')).toBe('1'); + expect(columns.querySelectorAll('rect').length).toBeGreaterThanOrEqual(14); + expect(q(card, 'g.candle.up rect')!.getAttribute('fill')).toContain('--info-color'); + const line = await mount({ entities: ['sensor.price', 'sensor.usage'], underneath: 'line' } as Partial); + expect(q(line, 'path.under-line')!.getAttribute('d')).toMatch(/^M.*L/); + }); + + it('makes hourly candles from history', async () => { + const card = await mount({ + source: 'history', + group_by: 'hour', + period: { last: 4, unit: 'day' }, + } as Partial); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('history/history_during_period'); + // 20 until two days ago, then 24: hours before the first reading have no candle. + expect(qa(card, 'g.candle').length).toBeGreaterThan(40); + expect(qa(card, 'g.candle.up').length).toBe(qa(card, 'g.candle').length); + }); + + it('lists open, high, low, close and the values underneath on hover', async () => { + const card = await mount({ + entities: ['sensor.price', 'sensor.usage'], + aggregate: 'change', + } as Partial); + point(q(card, '.plot-area')!, 'pointermove', 0.5); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows.map((r) => r.split(' ')[0])).toEqual(['Open', 'High', 'Low', 'Close', 'Usage']); + expect(rows[1]).toMatch(/ p$/); + expect(rows[4]).toMatch(/kWh$/); + expect(q(card, '.tooltip-time')!.textContent).toMatch(/\d/); + }); + + it('opens the price entity from a candle', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const candle = q(card, 'g.candle')!; + candle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + candle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.price']); + }); + + it('says when history failed or is empty', async () => { + const failed = await mount({}, hassWith(true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + const hass = hassWith(); + hass.callWS = vi.fn(async () => ({})) as HomeAssistant['callWS']; + const empty = await mount({}, hass); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'candlestick', entities: ['sensor.price'], ...extra } as CardConfig); + expect(set({ underneath: 'area' })).toThrow(/"underneath" must be one of: columns, line/); + expect(set({ up_color: 3 })).toThrow(/up_color/); + expect(set({ down_color: '' })).toThrow(/down_color/); + expect(set({ group_by: 'year' })).toThrow(/group_by/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ group_by: 'week', underneath: 'line', up_color: '#0f0' })).not.toThrow(); + }); +}); + +describe('candlestick editor', () => { + it('shows candle fields with their own defaults and writes them', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'candlestick', entities: ['sensor.price'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ + period_last: 14, + period_unit: 'day', + source: 'statistics', + candle_by: 'day', + under_aggregate: 'mean', + up_color: 'green', + down_color: 'red', + underneath: 'columns', + }); + const names = JSON.stringify(form.schema); + expect(names).toContain('candle_by'); + expect(names).not.toContain('"group_by"'); + expect(names).not.toContain('"none"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + period_last: 30, + candle_by: 'week', + underneath: 'line', + down_color: 'amber', + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ + ...raw, + period: { last: 30 }, + group_by: 'week', + underneath: 'line', + down_color: 'amber', + }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); From ed7d6cf0e1c5b0f8e64e293ee6d82adeabcb684a Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:34:21 +0000 Subject: [PATCH 19/41] feat: violin and raincloud charts of how readings are spread A new "Violin" style (Distributions in the style picker) draws the shape of each entity's readings over the period: wide where the readings spend most of their time, narrow where they rarely go, with a box marking the median and the middle half. It answers "how warm does the bedroom usually get" better than a line through every reading. Shape "raincloud" draws half a violin as a cloud, the box just beneath it, and every reading as a dot falling below. A plain violin can scatter its readings over the shape too ("Every reading as a dot"), and the box can be turned off. Direction "Up" stands the shapes up side by side instead of one row each. "One shape per" can split each entity by day, week or month, so a month of nights becomes a row of violins, one per day. The shape is a kernel density estimate: a smooth bump for each reading, added up. How wide the bumps are (the smoothing) follows Silverman's rule of thumb from the readings' spread and count, never finer than the sensor's own step so a sensor that reports in 0.5 degree steps doesn't make a comb. It can be set by hand in the entity's unit. History records changes rather than samples, so each value counts for as long as it held: a room that sat at 20 degrees all night and flickered to 21 a few times was at 20. Grouped readings and statistics rows count once each. The median and quartiles are weighted the same way. Each row is named with its median; hovering shows the median, the middle half, the lowest and highest and how many readings there were. Names open their entity. An entity with no readings keeps its row, marked "No readings". Value labels, lines and fixed lowest/highest values are options, and the usual period, source, grouping and compact settings apply. Everything is in the editor's Chart section. The distribution maths (weighted readings, quantiles, bandwidth, density) is in src/chart/density.ts and the shared category layout (rows or columns along a value axis, the hover box) in src/chart/category-plot.ts, both used by the beeswarm chart too. Closes #36 --- src/chart/category-plot.ts | 575 +++++++++++++++++++++++++++++++++++++ src/chart/density.ts | 219 ++++++++++++++ src/styles/index.ts | 2 + src/styles/violin.ts | 448 +++++++++++++++++++++++++++++ src/types.ts | 4 +- test/density.test.ts | 220 ++++++++++++++ test/violin.test.ts | 282 ++++++++++++++++++ 7 files changed, 1748 insertions(+), 2 deletions(-) create mode 100644 src/chart/category-plot.ts create mode 100644 src/chart/density.ts create mode 100644 src/styles/violin.ts create mode 100644 test/density.test.ts create mode 100644 test/violin.test.ts diff --git a/src/chart/category-plot.ts b/src/chart/category-plot.ts new file mode 100644 index 0000000..9b53946 --- /dev/null +++ b/src/chart/category-plot.ts @@ -0,0 +1,575 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { hour12Of, niceStep, valueAxis } from './axes'; +import { chartWidth } from './grid'; +import type { XY } from './paths'; +import { linear } from './scale'; +import { choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber } from '../format'; +import { registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** + * A plot of one band per category (an entity, or an entity's day) along a value axis, across + * (horizontal: one row each, values left to right) or up (vertical: one column each, values + * bottom to top). Shared by the distribution charts (violin, raincloud, beeswarm): they draw into + * a band with `at(value, band, offset)` and never need to know which way round it is. + */ + +export const ORIENTATIONS = ['horizontal', 'vertical'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +/** Plot units along the value axis; each band is BAND units across. */ +export const VALUE_LEN = 1000; +export const BAND = 100; + +/** Row height (px) per category across, and how much of it a compact card keeps. */ +const ROW_PX = 64; +const COMPACT_ROW = 0.69; +/** Plot height (px) when the categories stand up, normal and compact. */ +const COLUMN_PX = 180; +const COMPACT_COLUMN_PX = 110; +/** Room a value label needs along the axis (px), when choosing how many fit. */ +const LABEL_PX = 64; + +export interface Category { + /** Index into the card's items, for its tap action. */ + index: number; + name: string; + /** A second line under the name: a median, a count. */ + detail: string; + color: string; + /** Read out for the category. */ + label: string; +} + +/** The value axis: its ends, where the labels go and how they read. */ +export interface ValueScale { + domain: [number, number]; + ticks: number[]; + format(v: number): string; +} + +export interface CategoryGeometry { + orientation: Orientation; + count: number; + /** The SVG's coordinate box. */ + width: number; + height: number; + /** Plot position of value `v` in band `i`, `f` across the band (−0.5…0.5, 0 is its middle). */ + at(v: number, i: number, f?: number): XY; + /** Roughly how many px the value axis and one band are on screen. */ + valuePx: number; + bandPx: number; +} + +/** The distribution charts' shared axis options: orientation, value labels, lines, fixed ends. */ +export interface CategoryOptions { + orientation: Orientation; + axis: boolean; + grid: boolean; + min?: number; + max?: number; +} + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +registerStrings('en', { + editor_orientation: 'Direction', + editor_orientation_horizontal: 'Across', + editor_orientation_vertical: 'Up', + editor_value_axis: 'Value labels', + editor_value_grid: 'Lines at each value label', + editor_value_min: 'Lowest value shown', + editor_value_max: 'Highest value shown', + dist_median: 'Median', + dist_middle: 'Middle half', + dist_range: 'Lowest – highest', + dist_count: 'Readings', +}); + +/** The editor fields every distribution chart shares (see `categorySchema`). */ +export const CATEGORY_FIELDS: FieldBinding[] = [ + { name: 'orientation', path: ['orientation'], default: 'horizontal' }, + { name: 'value_axis', path: ['value_axis'], default: true }, + { name: 'value_grid', path: ['value_grid'], default: true }, + { name: 'value_min', path: ['value_min'] }, + { name: 'value_max', path: ['value_max'] }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'compact', path: ['compact'], default: false }, +]; + +/** Direction, the value axis, the hover box and compact spacing, as ha-form schema. */ +export function categorySchema(hass: HomeAssistant | undefined): unknown[] { + return [ + { name: 'orientation', selector: choice(hass, 'orientation', ORIENTATIONS) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'value_axis', selector: { boolean: {} } }, + { name: 'value_grid', selector: { boolean: {} } }, + { name: 'value_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'value_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + ]; +} + +/** Checks the shared options; `compact` and `tooltip` too, as these charts don't use the line chart's checks. */ +export function validateCategoryOptions(raw: CardConfig): void { + for (const k of ['compact', 'tooltip']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + if (raw.orientation !== undefined && !ORIENTATIONS.includes(raw.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + for (const k of ['value_axis', 'value_grid']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + for (const k of ['value_min', 'value_max']) { + if (raw[k] !== undefined && !isNumber(raw[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(raw.value_min) && isNumber(raw.value_max) && raw.value_min >= raw.value_max) { + throw new Error('"value_min" must be below "value_max"'); + } +} + +export function categoryOptions(raw: CardConfig): CategoryOptions { + return { + orientation: (raw.orientation as Orientation | undefined) ?? 'horizontal', + axis: raw.value_axis !== false, + grid: raw.value_grid !== false, + min: isNumber(raw.value_min) ? raw.value_min : undefined, + max: isNumber(raw.value_max) ? raw.value_max : undefined, + }; +} + +/** Width of the name column across (px) for a card `width` wide: 30 %, between 64 and 140 px. */ +export function labelColumn(width: number): number { + return Math.min(140, Math.max(64, Math.round((width - 32) * 0.3))); +} + +/** Where everything goes for `count` categories on this card; `rowPx` is a row's height across. */ +export function categoryGeometry( + ctx: Pick, + count: number, + scale: Pick, + rowPx = ROW_PX, +): CategoryGeometry { + const o = categoryOptions(ctx.raw); + const compact = ctx.raw.compact === true; + const width = chartWidth(ctx); + const n = Math.max(1, count); + if (o.orientation === 'horizontal') { + const x = linear(scale.domain, [0, VALUE_LEN]); + return { + orientation: o.orientation, + count: n, + width: VALUE_LEN, + height: n * BAND, + at: (v, i, f = 0) => [x(v), (i + 0.5 + f) * BAND], + valuePx: Math.max(40, width - 32 - labelColumn(width) - 8), + // Many rows (a violin per day for a month) get thinner, so the card stays a sensible height. + bandPx: Math.min(compact ? Math.round(rowPx * COMPACT_ROW) : rowPx, n > 12 ? 28 : n > 6 ? 40 : Infinity), + }; + } + const y = linear(scale.domain, [VALUE_LEN, 0]); + const plotPx = Math.max(40, width - 32 - (o.axis ? 40 : 0)); + return { + orientation: o.orientation, + count: n, + width: n * BAND, + height: VALUE_LEN, + at: (v, i, f = 0) => [(i + 0.5 + f) * BAND, y(v)], + valuePx: compact ? COMPACT_COLUMN_PX : COLUMN_PX, + bandPx: plotPx / n, + }; +} + +/** How many value labels fit along an axis `px` long. */ +export function labelCount(px: number, orientation: Orientation): number { + return orientation === 'horizontal' ? Math.max(2, Math.min(8, Math.floor(px / LABEL_PX))) : px > 140 ? 4 : 3; +} + +/** A value axis over readings spanning `data`, labelled as the entity reads. */ +export function numberScale( + data: [number, number] | undefined, + raw: CardConfig, + count: number, + hass: HomeAssistant | undefined, +): ValueScale { + const o = categoryOptions(raw); + const axis = valueAxis(data, { min: o.min, max: o.max }, count); + return { domain: axis.domain, ticks: axis.ticks, format: (v) => formatNumber(v, axis.decimals, hass) }; +} + +/** Hours of the day, 0 to 24, labelled as clock times in the user's clock (24 or 12 hour). */ +export function clockScale(count: number, hass: HomeAssistant | undefined): ValueScale { + const step = niceHours(24 / Math.max(1, count)); + const ticks: number[] = []; + for (let h = 0; h <= 24; h += step) ticks.push(h); + let format: Intl.DateTimeFormat; + const options: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + timeZone: 'UTC', + hour12: hour12Of(hass), + }; + try { + format = new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options); + } catch { + format = new Intl.DateTimeFormat('en', options); + } + return { domain: [0, 24], ticks, format: (v) => format.format(Date.UTC(2000, 0, 1, 0, Math.round(v * 60))) }; +} + +/** 1, 2, 3, 4, 6 or 12 hours: steps that divide a day evenly. */ +function niceHours(span: number): number { + for (const h of [1, 2, 3, 4, 6, 12]) if (h >= span) return h; + return Math.max(12, niceStep(span, 1)); +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** Tooltip state: which band the pointer is over, per card. */ +const hovered = new WeakMap(); + +export function hoveredBand(host: HTMLElement): number | undefined { + return hovered.get(host); +} + +export function stopCategoryHover(host: HTMLElement): void { + hovered.delete(host); +} + +/** Pointer handlers that track which band is under the pointer and redraw when it changes. */ +function hoverHandlers(ctx: Pick, geometry: CategoryGeometry) { + const set = (i: number | undefined) => { + if (hovered.get(ctx.host) === i) return; + if (i === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, i); + ctx.requestUpdate(); + }; + return { + move(ev: PointerEvent) { + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + const across = geometry.orientation === 'horizontal'; + const size = across ? rect.height : rect.width; + const at = across ? ev.clientY - rect.top : ev.clientX - rect.left; + if (!(size > 0)) return; + set(Math.min(geometry.count - 1, Math.max(0, Math.floor((at / size) * geometry.count)))); + }, + leave() { + set(undefined); + }, + }; +} + +/** A box of name–value rows beside band `i`, for the band under the pointer. */ +export function bandTooltip( + geometry: CategoryGeometry, + i: number, + title: string, + rows: [string, string][], + color: string, +): TemplateResult { + const middle = (i + 0.5) / geometry.count; + const across = geometry.orientation === 'horizontal'; + // Across, the box sits below its row (above it in the bottom half); up, beside its column. + const place = across + ? middle > 0.5 + ? `top:auto;bottom:${pct(1 - i / geometry.count)};right:0` + : `top:${pct((i + 1) / geometry.count)};right:0` + : middle > 0.5 + ? `top:0;right:${pct(1 - i / geometry.count)}` + : `top:0;left:${pct((i + 1) / geometry.count)}`; + return html``; +} + +export interface CategoryPlotOptions { + ctx: ChartContext; + geometry: CategoryGeometry; + categories: Category[]; + scale: ValueScale; + /** The drawing, in the geometry's coordinates. */ + content: SVGTemplateResult; + label: string; + /** HTML over the plot (a tooltip). */ + overlay?: TemplateResult | typeof nothing; + /** Whether to follow the pointer for a tooltip. */ + hover?: boolean; +} + +/** The plot, its value labels and gridlines, and a name (and detail) for each category. */ +export function categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + overlay = nothing, + hover = false, +}: CategoryPlotOptions): TemplateResult { + const o = categoryOptions(ctx.raw); + const across = geometry.orientation === 'horizontal'; + const position = (v: number) => { + const [x, y] = geometry.at(v, 0); + return across ? x / geometry.width : y / geometry.height; + }; + const ticks = scale.ticks.filter((v) => v >= scale.domain[0] && v <= scale.domain[1]); + const grid = o.grid + ? svg`${ticks.map((v) => { + const [x, y] = geometry.at(v, 0); + return across + ? svg`` + : svg``; + })}` + : nothing; + const values = + o.axis && ticks.length + ? html`
+ ${ + across + ? nothing + : html`` + } + ${ticks.map((v) => { + const at = position(v); + const edge = across ? (at < 0.04 ? 'start' : at > 0.96 ? 'end' : '') : at < 0.08 ? 'top' : ''; + return html`${scale.format(v)}`; + })} +
` + : nothing; + const handlers = hover ? hoverHandlers(ctx, geometry) : undefined; + const names = html`
+ ${categories.map( + (c) => + html`
+ ${c.name} + ${c.detail ? html`${c.detail}` : nothing} +
`, + )} +
`; + const size = across ? `height:${geometry.count * geometry.bandPx}px` : `height:${geometry.valuePx}px`; + const dense = across && geometry.bandPx < 40; + return html`
+ ${across ? names : values} +
+ + ${grid} + ${content} + + ${overlay} +
+ ${across ? values : names} +
`; +} + +export const categoryStyles = css` + .cat-plot { + display: grid; + gap: 0 8px; + } + .cat-plot.horizontal { + grid-template-columns: clamp(64px, 30%, 140px) minmax(0, 1fr); + } + .cat-plot.vertical { + grid-template-columns: auto minmax(0, 1fr); + } + .cat-plot .plot-area { + position: relative; + min-width: 0; + } + .cat-plot.horizontal .plot-area, + .cat-plot.vertical .plot-area { + grid-column: 2; + grid-row: 1; + } + .cat-plot .plot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .cat-plot .plot path, + .cat-plot .plot line, + .cat-plot .plot rect { + vector-effect: non-scaling-stroke; + } + .cat-plot .grid line { + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .cat-content { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .cat-content { + opacity: 1; + } + :host([static]) .cat-content { + transition: none; + } + .cat-labels { + display: flex; + min-width: 0; + } + .horizontal .cat-labels { + grid-column: 1; + grid-row: 1; + flex-direction: column; + } + .vertical .cat-labels { + grid-column: 2; + grid-row: 2; + padding-top: 4px; + } + .cat-label { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1 1 0; + cursor: pointer; + border-radius: 4px; + } + .vertical .cat-label { + align-items: center; + text-align: center; + } + .cat-label:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .cat-name, + .cat-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .cat-name { + font-size: 0.875em; + } + .cat-name .swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + vertical-align: middle; + } + .cat-detail { + font-size: 0.75em; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .frame.compact .cat-detail, + .cat-plot.dense .cat-detail { + display: none; + } + .cat-axis { + position: relative; + } + .horizontal .cat-axis { + grid-column: 2; + grid-row: 2; + height: 18px; + } + .horizontal .cat-axis .axis-label { + top: 5px; + transform: translateX(-50%); + } + .horizontal .cat-axis .axis-label.start { + transform: none; + } + .horizontal .cat-axis .axis-label.end { + transform: translateX(-100%); + } + .vertical .cat-axis { + grid-column: 1; + grid-row: 1; + min-width: 24px; + } + .vertical .cat-axis .axis-label { + right: 0; + transform: translateY(-50%); + } + .vertical .cat-axis .axis-label.top { + transform: none; + } + .cat-plot .axis-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + font-variant-numeric: tabular-nums; + } + .cat-sizer { + display: block; + height: 0; + overflow: hidden; + visibility: hidden; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .band-tooltip { + position: absolute; + min-width: 120px; + } + .band-tooltip .tooltip-time { + display: flex; + align-items: center; + gap: 6px; + } + .band-tooltip .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + } +`; diff --git a/src/chart/density.ts b/src/chart/density.ts new file mode 100644 index 0000000..8e7daf0 --- /dev/null +++ b/src/chart/density.ts @@ -0,0 +1,219 @@ +import type { Point } from '../history/types'; + +/** + * Distribution maths shared by the violin, raincloud and beeswarm charts: the readings in a + * series with how much each counts, weighted quantiles, the box-plot summary, and a kernel + * density estimate with its bandwidth. + */ + +/** One value in a distribution. */ +export interface Reading { + t: number; + v: number; + /** How much it counts: how long it held (ms) for history, 1 for a group or statistics row. */ + w: number; + /** A reading taken in the range, not one carried in from before it (dots show only these). */ + real: boolean; +} + +/** + * The readings in a series. History is a step function (a sensor reports when it changes), so + * each value counts for as long as it held, up to the next reading or a gap, and the value at the + * left edge counts from there: a room that sat at 20 °C all night and flickered to 21 ten times + * in a minute was at 20, not mostly at 21. Grouped values and statistics rows are already one per + * period and count once each. The point the chart adds at `until` (now) to carry the last value + * to the edge is not a reading. + */ +export function readingsOf(points: Point[], weighted: boolean, until: number): Reading[] { + const out: Reading[] = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null || !Number.isFinite(p.v)) continue; + const next = i + 1 < points.length ? points[i + 1].t : until; + if (weighted && i > 0 && i === points.length - 1 && p.t >= until) continue; + // A reading that held for no time at all (two in the same instant) still counts, barely. + const w = weighted ? Math.max(1, Math.min(next, until) - p.t) : 1; + out.push({ t: p.t, v: p.v, w, real: p.carried !== true }); + } + return out; +} + +/** Readings sorted by value. */ +export function byValue(readings: Reading[]): Reading[] { + return [...readings].sort((a, b) => a.v - b.v); +} + +/** + * The value below which `p` (0–1) of the weight lies, interpolating between readings placed at + * the middle of their own weight. With equal weights this is the usual "midpoint" quantile + * (Hazen): the median of 1, 2, 3, 4 is 2.5. `sorted` must be sorted by value. + */ +export function quantile(sorted: Reading[], p: number): number { + const n = sorted.length; + if (n === 0) return Number.NaN; + if (n === 1) return sorted[0].v; + const total = sorted.reduce((a, r) => a + r.w, 0); + const target = Math.min(1, Math.max(0, p)) * total; + let before = 0; + let prevAt = -Infinity; + let prevV = sorted[0].v; + for (const r of sorted) { + const at = before + r.w / 2; + if (target <= at) { + if (prevAt === -Infinity) return r.v; + return prevV + ((r.v - prevV) * (target - prevAt)) / (at - prevAt || 1); + } + before += r.w; + prevAt = at; + prevV = r.v; + } + return sorted[n - 1].v; +} + +/** What a box plot shows. Whiskers reach the furthest readings within 1.5 box-lengths of the box. */ +export interface Summary { + n: number; + min: number; + q1: number; + median: number; + q3: number; + max: number; + low: number; + high: number; + mean: number; +} + +export function summarize(readings: Reading[]): Summary | undefined { + if (readings.length === 0) return undefined; + const sorted = byValue(readings); + const q1 = quantile(sorted, 0.25); + const median = quantile(sorted, 0.5); + const q3 = quantile(sorted, 0.75); + const reach = 1.5 * (q3 - q1); + const inside = sorted.filter((r) => r.v >= q1 - reach && r.v <= q3 + reach); + const total = sorted.reduce((a, r) => a + r.w, 0); + return { + n: readings.length, + min: sorted[0].v, + max: sorted[sorted.length - 1].v, + q1, + median, + q3, + low: inside.length ? inside[0].v : q1, + high: inside.length ? inside[inside.length - 1].v : q3, + mean: total > 0 ? sorted.reduce((a, r) => a + r.v * r.w, 0) / total : median, + }; +} + +/** + * How wide each reading's bump is in the density estimate: Silverman's rule of thumb, + * 0.9 × min(standard deviation, IQR / 1.34) × n^(−1/5). + * + * - The smaller of the spread measures keeps a few outliers from blurring the main shape, and the + * n^(−1/5) narrows the bumps as there are more readings to show detail with. + * - Weighted readings count as Kish's effective number, (Σw)² / Σw²: a value held for twelve + * hours is not twelve hundred readings. + * - Never narrower than the sensor's own step (the smallest gap between two different values), so + * a sensor that reports in 0.5 °C steps draws one smooth shape, not a comb of spikes. + * - Readings that are all the same give a bump 1 % of the value wide (or 1 for zero). + */ +export function bandwidth(readings: Reading[]): number { + const n = readings.length; + if (n === 0) return 1; + const sorted = byValue(readings); + const total = sorted.reduce((a, r) => a + r.w, 0); + const mean = sorted.reduce((a, r) => a + r.v * r.w, 0) / total; + const sd = Math.sqrt(sorted.reduce((a, r) => a + r.w * (r.v - mean) ** 2, 0) / total); + const iqr = quantile(sorted, 0.75) - quantile(sorted, 0.25); + const spread = iqr > 0 ? Math.min(sd, iqr / 1.34) : sd; + const squares = sorted.reduce((a, r) => a + r.w * r.w, 0); + const effective = squares > 0 ? (total * total) / squares : n; + let step = Infinity; + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i].v - sorted[i - 1].v; + if (gap > 0 && gap < step) step = gap; + } + const h = 0.9 * spread * Math.max(1, effective) ** -0.2; + if (h > 0) return Math.max(h, step === Infinity ? 0 : step); + return step !== Infinity ? step : Math.abs(mean) * 0.01 || 1; +} + +/** Density at each of `xs`: the weighted sum of a Gaussian bump of width `h` at each reading. */ +export function kde(readings: Reading[], h: number, xs: number[]): number[] { + const total = readings.reduce((a, r) => a + r.w, 0); + if (total === 0 || h <= 0) return xs.map(() => 0); + const k = 1 / (total * h * Math.sqrt(2 * Math.PI)); + return xs.map((x) => { + let sum = 0; + for (const r of readings) { + const u = (x - r.v) / h; + if (u > -6 && u < 6) sum += r.w * Math.exp(-0.5 * u * u); + } + return sum * k; + }); +} + +/** + * The density's outline: `steps` + 1 points from two bandwidths below the lowest reading to two + * above the highest (where it has all but faded), kept inside `clip` (the axis). Each `d` is + * scaled so the widest point is 1. + */ +export function densityCurve( + readings: Reading[], + h: number, + clip: [number, number], + steps = 64, +): { x: number; d: number }[] { + if (readings.length === 0) return []; + let lo = Infinity; + let hi = -Infinity; + for (const r of readings) { + lo = Math.min(lo, r.v); + hi = Math.max(hi, r.v); + } + const from = Math.max(clip[0], lo - 2 * h); + const to = Math.min(clip[1], hi + 2 * h); + if (!(to > from)) return []; + const xs = Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); + const ds = kde(readings, h, xs); + const peak = Math.max(...ds); + return xs.map((x, i) => ({ x, d: peak > 0 ? ds[i] / peak : 0 })); +} + +/** A steady pseudo-random offset in −0.5…0.5 for item `i`, so jittered dots don't move on redraw. */ +export function jitter(i: number): number { + const s = Math.sin((i + 1) * 12.9898) * 43758.5453; + return s - Math.floor(s) - 0.5; +} + +/** At most `max` of the readings taken in the range, evenly spread through them (oldest first). */ +export function sampleReadings(readings: Reading[], max: number): Reading[] { + const real = readings.filter((r) => r.real); + if (real.length <= max) return real; + const out: Reading[] = []; + for (let i = 0; i < max; i++) out.push(real[Math.floor((i * real.length) / max)]); + return out; +} + +/** + * Readings split at `bounds` (period starts, then the last end): one list per period. A value + * that held across a boundary counts in each period for the time it held there; it is a reading + * taken in a period (`real`) only in the one it was taken in. Unweighted readings go by time. + */ +export function splitReadings(readings: Reading[], bounds: number[], weighted: boolean): Reading[][] { + const out: Reading[][] = bounds.slice(0, -1).map(() => []); + for (const r of readings) { + for (let i = 0; i + 1 < bounds.length; i++) { + const start = bounds[i]; + const end = bounds[i + 1]; + if (!weighted) { + if (r.t >= start && r.t < end) out[i].push(r); + continue; + } + const overlap = Math.min(end, r.t + r.w) - Math.max(start, r.t); + if (overlap <= 0) continue; + out[i].push({ t: Math.max(start, r.t), v: r.v, w: overlap, real: r.real && r.t >= start }); + } + } + return out; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index f69fdbf..c3a26bb 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -36,6 +36,7 @@ import { rangeArea } from './range-area'; import { sparklineStyle } from './sparkline'; import { streamgraph } from './streamgraph'; import { candlestick } from './candlestick'; +import { violin } from './violin'; export interface RenderContext { show: ShowOptions; @@ -161,4 +162,5 @@ export const STYLES: Record = { sparkline: sparklineStyle, streamgraph, candlestick, + violin, }; diff --git a/src/styles/violin.ts b/src/styles/violin.ts new file mode 100644 index 0000000..8d8cdc3 --- /dev/null +++ b/src/styles/violin.ts @@ -0,0 +1,448 @@ +import { css, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { + CATEGORY_FIELDS, + bandTooltip, + categoryGeometry, + categoryOptions, + categoryPlot, + categorySchema, + categoryStyles, + hoveredBand, + labelCount, + numberScale, + stopCategoryHover, + validateCategoryOptions, +} from '../chart/category-plot'; +import type { Category, CategoryGeometry } from '../chart/category-plot'; +import { + bandwidth, + densityCurve, + jitter, + readingsOf, + sampleReadings, + splitReadings, + summarize, +} from '../chart/density'; +import type { Reading, Summary } from '../chart/density'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, layoutStyles } from '../chart/layout'; +import type { XY } from '../chart/paths'; +import { dotsPath } from '../chart/series-style'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { boundaries } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const SHAPES = ['violin', 'raincloud'] as const; +export type Shape = (typeof SHAPES)[number]; + +export const SPLITS = ['none', 'day', 'week', 'month'] as const; +export type Split = (typeof SPLITS)[number]; + +/** Half a violin's widest point, as a share of its band. */ +const HALF = 0.42; +/** At most this many readings are drawn as dots per shape; the shape itself uses them all. */ +const MAX_DOTS = 300; +/** A band wider than this (px) draws its shape as if it were this wide. */ +const MAX_BAND_PX = 110; + +registerStrings('en', { + editor_style_violin: 'Violin', + editor_shape: 'Shape', + editor_shape_violin: 'Violin', + editor_shape_raincloud: 'Raincloud (half violin, box and readings)', + editor_box: 'Box with the median and middle half', + editor_readings: 'Every reading as a dot', + editor_bandwidth: 'Smoothing (in the entity’s unit, empty for automatic)', + editor_split_by: 'One shape per', + editor_split_by_none: 'Entity', + editor_split_by_day: 'Entity and day', + editor_split_by_week: 'Entity and week', + editor_split_by_month: 'Entity and month', + violin_median: 'median {value}', + violin_none: 'No readings', + violin_raincloud: 'Raincloud', +}); + +/** One shape to draw: an entity, or an entity's day / week / month. */ +interface Group { + series: ChartSeries; + readings: Reading[]; + summary?: Summary; + name: string; + detail: string; +} + +export interface ViolinOptions { + shape: Shape; + box: boolean; + readings: boolean; + bandwidth?: number; + split: Split; +} + +export function violinOptions(raw: CardConfig): ViolinOptions { + const shape = (raw.shape as Shape | undefined) ?? 'violin'; + return { + shape, + box: raw.box !== false, + readings: (raw.readings as boolean | undefined) ?? shape === 'raincloud', + bandwidth: typeof raw.bandwidth === 'number' ? raw.bandwidth : undefined, + split: (raw.split_by as Split | undefined) ?? 'none', + }; +} + +function dateName(t: number, split: Split, zone: Zone, hass: HomeAssistant | undefined): string { + const options: Intl.DateTimeFormatOptions = + split === 'month' + ? { month: 'short', year: 'numeric', timeZone: zone.timeZone } + : { weekday: split === 'day' ? 'short' : undefined, day: 'numeric', month: 'short', timeZone: zone.timeZone }; + try { + return new Intl.DateTimeFormat(hass?.locale?.language ?? hass?.language ?? 'en', options).format(t); + } catch { + return new Intl.DateTimeFormat('en', options).format(t); + } +} + +const median = (s: Summary | undefined, series: ChartSeries, hass: HomeAssistant | undefined) => + s ? withUnit(formatNumber(s.median, series.entity.decimals, hass), series.entity.unit) : ''; + +/** The shapes to draw: one per entity, or per entity and period (empty periods left out). */ +function groupsOf( + series: ChartSeries[], + readings: Reading[][], + o: ViolinOptions, + bounds: number[], + weighted: boolean, + zone: Zone, + hass: HomeAssistant | undefined, +): Group[] { + const detail = (s: Summary | undefined, ser: ChartSeries) => + s ? localize(hass, 'violin_median', { value: median(s, ser, hass) }) : localize(hass, 'violin_none'); + if (o.split === 'none') { + return series.map((ser, i) => { + const summary = summarize(readings[i]); + return { series: ser, readings: readings[i], summary, name: ser.entity.name, detail: detail(summary, ser) }; + }); + } + const parts = series.map((_, i) => splitReadings(readings[i], bounds, weighted)); + const out: Group[] = []; + for (let p = 0; p + 1 < bounds.length; p++) { + for (let i = 0; i < series.length; i++) { + const r = parts[i][p]; + if (r.length === 0) continue; + const summary = summarize(r); + const date = dateName(bounds[p], o.split, zone, hass); + out.push({ + series: series[i], + readings: r, + summary, + name: series.length > 1 ? `${date} · ${series[i].entity.name}` : date, + detail: detail(summary, series[i]), + }); + } + } + return out; +} + +/** A closed outline through `left` and back along `right`. */ +function outline(left: XY[], right: XY[]): string { + const f = (n: number) => Math.round(n * 100) / 100; + const pts = [...left, ...[...right].reverse()]; + return `${pts.map(([x, y], i) => `${i ? 'L' : 'M'}${f(x)},${f(y)}`).join('')}Z`; +} + +/** A rectangle between two corners, whichever way round the plot is. */ +function rectBetween([x0, y0]: XY, [x1, y1]: XY) { + return { x: Math.min(x0, x1), y: Math.min(y0, y1), width: Math.abs(x1 - x0), height: Math.abs(y1 - y0) }; +} + +/** The violin (or a raincloud's half) for one group. */ +export function shapePath( + curve: { x: number; d: number }[], + g: CategoryGeometry, + i: number, + half: boolean, + base = 0, + size = HALF, +): string { + if (curve.length === 0) return ''; + const left = curve.map((p) => g.at(p.x, i, base - p.d * size)); + const right = curve.map((p) => g.at(p.x, i, half ? base : base + p.d * size)); + return outline(left, right); +} + +/** Where a reading's dot sits across its band: scattered inside the violin, or in the rain. */ +function dotOffset(k: number, d: number, raincloud: boolean): number { + return raincloud ? 0.3 + jitter(k) * 0.22 : jitter(k) * 1.6 * HALF * d; +} + +function densityAt(curve: { x: number; d: number }[], v: number): number { + if (curve.length === 0) return 0; + let j = 0; + while (j < curve.length - 1 && curve[j + 1].x < v) j++; + const a = curve[j]; + const b = curve[Math.min(curve.length - 1, j + 1)]; + if (b.x === a.x) return a.d; + return a.d + ((b.d - a.d) * (v - a.x)) / (b.x - a.x); +} + +function drawGroup( + group: Group, + i: number, + g: CategoryGeometry, + o: ViolinOptions, + domain: [number, number], +): SVGTemplateResult { + const { summary: s, series } = group; + if (!s) return svg``; + const h = o.bandwidth ?? bandwidth(group.readings); + const curve = densityCurve(group.readings, h, domain); + const rain = o.shape === 'raincloud'; + // Wide columns (few entities standing up) keep a violin's width, rather than growing fat. + const k = Math.min(1, MAX_BAND_PX / g.bandPx); + // A raincloud: the cloud above its line, the box just under it, the readings falling below. + const shape = svg``; + const boxAt = rain ? 0.08 * k : 0; + const iqr = rectBetween(g.at(s.q1, i, boxAt - 0.045 * k), g.at(s.q3, i, boxAt + 0.045 * k)); + const [lx, ly] = g.at(s.low, i, boxAt); + const [hx, hy] = g.at(s.high, i, boxAt); + const box = o.box + ? svg` + + + + ` + : nothing; + const dots = o.readings + ? svg` + g.at(r.v, i, k * dotOffset(n, densityAt(curve, r.v), rain)), + ), + )}>` + : nothing; + return svg`${shape}${box}${dots}`; +} + +const pctRow = (s: Summary, ser: ChartSeries, hass: HomeAssistant | undefined): [string, string][] => { + const f = (v: number) => formatNumber(v, ser.entity.decimals, hass); + const u = (t: string) => withUnit(t, ser.entity.unit); + return [ + [localize(hass, 'dist_median'), u(f(s.median))], + [localize(hass, 'dist_middle'), u(`${f(s.q1)} – ${f(s.q3)}`)], + [localize(hass, 'dist_range'), u(`${f(s.min)} – ${f(s.max)}`)], + [localize(hass, 'dist_count'), formatNumber(s.n, 0, hass)], + ]; +}; + +const VIOLIN_FIELDS_BASE: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'shape', path: ['shape'], default: 'violin' }, + { name: 'box', path: ['box'], default: true }, + { name: 'split_by', path: ['split_by'], default: 'none' }, + { name: 'bandwidth', path: ['bandwidth'] }, + ...CATEGORY_FIELDS, +]; + +const fieldsByShape = new Map(); + +/** The fields, with "every reading as a dot" defaulting on for a raincloud and off for a violin. */ +function violinFields(shape: Shape): FieldBinding[] { + let fields = fieldsByShape.get(shape); + if (!fields) { + fields = [...VIOLIN_FIELDS_BASE, { name: 'readings', path: ['readings'], default: shape === 'raincloud' }]; + fieldsByShape.set(shape, fields); + } + return fields; +} + +export function violinEditor(ctx: ChartEditorContext): TemplateResult { + const o = violinOptions(ctx.config); + const schema = cachedSchema(`violin|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'shape', selector: choice(ctx.hass, 'shape', SHAPES) }, + { name: 'split_by', selector: choice(ctx.hass, 'split_by', SPLITS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'box', selector: { boolean: {} } }, + { name: 'readings', selector: { boolean: {} } }, + ], + }, + { name: 'bandwidth', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ...categorySchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, violinFields(o.shape)); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const violin: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + ${categoryStyles} + .style-violin .shape { + fill-opacity: 0.35; + stroke-width: 1.5px; + stroke-linejoin: round; + } + .style-violin .whisker { + stroke-width: 1.5px; + } + .style-violin .median { + stroke: var(--card-background-color, #fff); + stroke-width: 6px; + stroke-linecap: round; + } + .style-violin .dots { + fill: none; + stroke-width: 4px; + stroke-linecap: round; + stroke-opacity: 0.7; + } + `, + thumbnail: () => svg` + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateTooltipOptions(raw); + validateCategoryOptions(raw); + if (raw.shape !== undefined && !SHAPES.includes(raw.shape as Shape)) { + throw new Error(`"shape" must be one of: ${SHAPES.join(', ')}`); + } + if (raw.split_by !== undefined && !SPLITS.includes(raw.split_by as Split)) { + throw new Error(`"split_by" must be one of: ${SPLITS.join(', ')}`); + } + for (const k of ['box', 'readings']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + const bw = raw.bandwidth; + if (bw !== undefined && !(typeof bw === 'number' && Number.isFinite(bw) && bw > 0)) { + throw new Error('"bandwidth" must be a number above 0'); + } + }, + editor: violinEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopCategoryHover(host); + }, + render(ctx: ChartContext) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout, nav, message); + const o = violinOptions(ctx.raw); + const until = Math.min(frame.range.end, now); + const weighted = frame.source === 'history' && !frame.group; + const readings = frame.series.map((s) => readingsOf(s.points, weighted, until)); + const bounds = o.split === 'none' ? [] : boundaries(o.split, frame.range.start, until, frame.zone); + const groups = groupsOf(frame.series, readings, o, bounds, weighted, frame.zone, ctx.hass); + let lo = Infinity; + let hi = -Infinity; + for (const g of groups) { + if (!g.summary) continue; + // Room for the tails, which run a little past the lowest and highest readings. + const h = o.bandwidth ?? bandwidth(g.readings); + lo = Math.min(lo, g.summary.min - h); + hi = Math.max(hi, g.summary.max + h); + } + const options = categoryOptions(ctx.raw); + const probe = categoryGeometry(ctx, groups.length, { domain: [0, 1] }); + const scale = numberScale( + lo === Infinity ? undefined : [lo, hi], + ctx.raw, + labelCount(probe.valuePx, options.orientation), + ctx.hass, + ); + const geometry = categoryGeometry(ctx, groups.length, scale); + const content = svg`${groups.map((g, i) => drawGroup(g, i, geometry, o, scale.domain))}`; + const categories: Category[] = groups.map((g) => ({ + index: g.series.index, + name: g.name, + detail: g.detail, + color: g.series.color, + label: `${g.name}, ${g.detail}`, + })); + const tooltip = tooltipEnabled(ctx.raw); + const at = tooltip ? hoveredBand(ctx.host) : undefined; + const hovered = at === undefined ? undefined : groups[at]; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, o.shape === 'raincloud' ? 'violin_raincloud' : 'editor_style_violin'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + nav, + categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + hover: tooltip, + overlay: + hovered?.summary && at !== undefined + ? bandTooltip( + geometry, + at, + hovered.name, + pctRow(hovered.summary, hovered.series, ctx.hass), + hovered.series.color, + ) + : nothing, + }), + ); + }, +}; diff --git a/src/types.ts b/src/types.ts index 5008ccb..3f70fd7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/density.test.ts b/test/density.test.ts new file mode 100644 index 0000000..05846f6 --- /dev/null +++ b/test/density.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; +import { + bandwidth, + byValue, + densityCurve, + jitter, + kde, + quantile, + readingsOf, + sampleReadings, + splitReadings, + summarize, +} from '../src/chart/density'; +import type { Reading } from '../src/chart/density'; +import { + categoryGeometry, + categoryOptions, + clockScale, + labelColumn, + labelCount, + numberScale, + validateCategoryOptions, +} from '../src/chart/category-plot'; +import type { CardConfig } from '../src/types'; + +const plain = (values: number[]): Reading[] => values.map((v, i) => ({ t: i, v, w: 1, real: true })); + +describe('readings', () => { + it('weights history by how long each value held, leaving out gaps and the edge point', () => { + const points = [ + { t: 0, v: 20, carried: true }, + { t: 10, v: 21 }, + { t: 11, v: null }, + { t: 15, v: 20 }, + { t: 100, v: 20 }, + ]; + expect(readingsOf(points, true, 100)).toEqual([ + { t: 0, v: 20, w: 10, real: false }, + { t: 10, v: 21, w: 1, real: true }, + { t: 15, v: 20, w: 85, real: true }, + ]); + }); + + it('counts grouped values and statistics rows once each', () => { + const points = [ + { t: 0, v: 1 }, + { t: 10, v: null }, + { t: 20, v: 3 }, + ]; + expect(readingsOf(points, false, 30).map((r) => r.w)).toEqual([1, 1]); + }); + + it('keeps two readings in the same instant, barely', () => { + const r = readingsOf( + [ + { t: 5, v: 1 }, + { t: 5, v: 2 }, + ], + true, + 10, + ); + expect(r.map((x) => x.w)).toEqual([1, 5]); + }); + + it('splits held values across period boundaries', () => { + const r: Reading[] = [ + { t: 0, v: 1, w: 150, real: false }, + { t: 150, v: 2, w: 50, real: true }, + ]; + const [a, b] = splitReadings(r, [0, 100, 200], true); + expect(a).toEqual([{ t: 0, v: 1, w: 100, real: false }]); + expect(b).toEqual([ + { t: 100, v: 1, w: 50, real: false }, + { t: 150, v: 2, w: 50, real: true }, + ]); + const byTime = splitReadings(plain([5, 6, 7]), [0, 2, 4], false); + expect(byTime.map((p) => p.map((x) => x.v))).toEqual([[5, 6], [7]]); + }); + + it('samples readings taken in the range, evenly', () => { + const r = [...plain([1, 2, 3, 4, 5, 6]), { t: 9, v: 0, w: 1, real: false }]; + expect(sampleReadings(r, 3).map((x) => x.v)).toEqual([1, 3, 5]); + expect(sampleReadings(r, 10)).toHaveLength(6); + }); +}); + +describe('quantiles and summary', () => { + it('interpolates between readings at the middle of their weight', () => { + const s = byValue(plain([4, 1, 3, 2])); + expect(quantile(s, 0.5)).toBe(2.5); + expect(quantile(s, 0)).toBe(1); + expect(quantile(s, 1)).toBe(4); + expect(quantile(byValue(plain([7])), 0.3)).toBe(7); + expect(quantile([], 0.5)).toBeNaN(); + }); + + it('lets a value held longer pull the median to it', () => { + const s = byValue([ + { t: 0, v: 20, w: 100, real: true }, + { t: 1, v: 30, w: 1, real: true }, + { t: 2, v: 31, w: 1, real: true }, + ]); + expect(quantile(s, 0.5)).toBeLessThan(20.5); + }); + + it('summarises with whiskers inside 1.5 box-lengths', () => { + const s = summarize(plain([1, 2, 3, 4, 5, 6, 7, 8, 100]))!; + expect(s).toMatchObject({ n: 9, min: 1, max: 100, median: 5, low: 1, high: 8 }); + expect(s.q1).toBeCloseTo(2.75); + expect(s.mean).toBeCloseTo(136 / 9); + expect(summarize([])).toBeUndefined(); + }); +}); + +describe('density', () => { + it('picks a bandwidth by Silverman’s rule, never finer than the sensor’s step', () => { + const values = Array.from({ length: 100 }, (_, i) => Math.round(Math.sin(i) * 50) / 10); + const h = bandwidth(plain(values)); + expect(h).toBeGreaterThanOrEqual(0.1); + expect(h).toBeLessThan(2); + // Coarse steps: never narrower than a step. + expect(bandwidth(plain([0, 0, 0, 5, 5, 5, 10, 10]))).toBeGreaterThanOrEqual(5); + // All the same: 1 % of the value, or 1 at zero. + expect(bandwidth(plain([20, 20, 20]))).toBeCloseTo(0.2); + expect(bandwidth(plain([0, 0]))).toBe(1); + expect(bandwidth([])).toBe(1); + }); + + it('counts a long-held value as one reading, not many', () => { + const many = plain([1, 2, 3, 4, 5, 6, 7, 8]); + const held = many.map((r, i) => ({ ...r, w: i === 0 ? 1000 : 1 })); + expect(bandwidth(held)).not.toBe(bandwidth(many)); + }); + + it('estimates a density that integrates to about one', () => { + const r = plain([0, 0, 1, 2]); + const xs = Array.from({ length: 401 }, (_, i) => -4 + i * 0.025); + const ds = kde(r, 0.5, xs); + const area = ds.reduce((a, d) => a + d * 0.025, 0); + expect(area).toBeCloseTo(1, 2); + expect(kde([], 1, [0])).toEqual([0]); + }); + + it('outlines the density from two bandwidths below to two above, peak scaled to one', () => { + const curve = densityCurve(plain([10, 11, 12]), 1, [0, 100], 8); + expect(curve).toHaveLength(9); + expect(curve[0].x).toBe(8); + expect(curve[8].x).toBe(14); + expect(Math.max(...curve.map((p) => p.d))).toBe(1); + expect(densityCurve(plain([10]), 1, [9, 10.5], 2)[0].x).toBe(9); + expect(densityCurve([], 1, [0, 1])).toEqual([]); + expect(densityCurve(plain([10]), 1, [20, 30])).toEqual([]); + }); + + it('jitters steadily within half a unit', () => { + expect(jitter(3)).toBe(jitter(3)); + for (let i = 0; i < 50; i++) expect(Math.abs(jitter(i))).toBeLessThanOrEqual(0.5); + }); +}); + +describe('category plot', () => { + const host = document.createElement('div'); + const ctx = (raw: Record) => ({ host, requestUpdate: () => undefined, raw: raw as CardConfig }); + + it('reads and checks its options', () => { + expect(categoryOptions({} as CardConfig)).toEqual({ + orientation: 'horizontal', + axis: true, + grid: true, + min: undefined, + max: undefined, + }); + const check = (raw: Record) => () => validateCategoryOptions(raw as CardConfig); + expect(check({ orientation: 'sideways' })).toThrow(/"orientation" must be one of: horizontal, vertical/); + expect(check({ value_axis: 1 })).toThrow(/value_axis/); + expect(check({ value_min: 'a' })).toThrow(/value_min/); + expect(check({ value_min: 5, value_max: 1 })).toThrow(/below/); + expect(check({ compact: 'yes' })).toThrow(/compact/); + expect(check({ tooltip: 1 })).toThrow(/tooltip/); + expect(check({ orientation: 'vertical', value_grid: false, value_min: 0, value_max: 1 })).not.toThrow(); + }); + + it('lays bands across or up the plot', () => { + const across = categoryGeometry(ctx({}), 2, { domain: [0, 10] }); + expect(across.at(5, 1)).toEqual([500, 150]); + expect(across.at(0, 0, -0.5)).toEqual([0, 0]); + expect(across.bandPx).toBe(64); + expect(categoryGeometry(ctx({ compact: true }), 2, { domain: [0, 1] }).bandPx).toBe(44); + expect(categoryGeometry(ctx({}), 10, { domain: [0, 1] }).bandPx).toBe(40); + expect(categoryGeometry(ctx({}), 20, { domain: [0, 1] }).bandPx).toBe(28); + expect(categoryGeometry(ctx({}), 2, { domain: [0, 1] }, 96).bandPx).toBe(96); + expect(categoryGeometry(ctx({ compact: true }), 2, { domain: [0, 1] }, 96).bandPx).toBe(66); + const up = categoryGeometry(ctx({ orientation: 'vertical' }), 4, { domain: [0, 10] }); + expect(up.at(10, 3)).toEqual([350, 0]); + expect(up.width).toBe(400); + expect(up.valuePx).toBe(180); + expect(categoryGeometry(ctx({ orientation: 'vertical', compact: true }), 1, { domain: [0, 1] }).valuePx).toBe(110); + }); + + it('sizes the name column and the number of labels to the card', () => { + expect(labelColumn(200)).toBe(64); + expect(labelColumn(1000)).toBe(140); + expect(labelCount(330, 'horizontal')).toBe(5); + expect(labelCount(50, 'horizontal')).toBe(2); + expect(labelCount(180, 'vertical')).toBe(4); + expect(labelCount(110, 'vertical')).toBe(3); + }); + + it('labels values as numbers, or hours as clock times', () => { + const n = numberScale([1, 9], { value_max: 20 } as CardConfig, 4, undefined); + expect(n.domain[1]).toBe(20); + expect(n.format(5)).toBe('5'); + const clock = clockScale(4, { locale: { language: 'en-GB', time_format: '24' } } as never); + expect(clock.ticks).toEqual([0, 6, 12, 18, 24]); + expect(clock.format(6)).toMatch(/^0?6:00$/); + expect(clock.format(13.5)).toBe('13:30'); + expect(clockScale(8, undefined).ticks).toHaveLength(9); + expect(clockScale(30, { language: 'not a language!' } as never).ticks).toHaveLength(25); + }); +}); diff --git a/test/violin.test.ts b/test/violin.test.ts new file mode 100644 index 0000000..57e554c --- /dev/null +++ b/test/violin.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { shapePath, violinOptions } from '../src/styles/violin'; +import { categoryGeometry } from '../src/chart/category-plot'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; + +/** A reading every 20 minutes for two days: bedroom around 19, nursery around 21. */ +function rows(base: number) { + const out: { s: string; lu: number }[] = []; + for (let i = 0; i < 144; i++) { + const t = now - 48 * H + i * 20 * 60_000; + out.push({ s: String(base + Math.round(Math.sin(i / 5) * 15) / 10), lu: t / 1000 }); + } + return out; +} + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.bedroom': ['19', { friendly_name: 'Bedroom', unit_of_measurement: '°C' }], + 'sensor.nursery': ['21', { friendly_name: 'Nursery', unit_of_measurement: '°C' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.bedroom': rows(19), + 'sensor.nursery': rows(21), + 'sensor.gone': [{ s: 'unavailable', lu: (now - 40 * H) / 1000 }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'violin', + period: { last: 2, unit: 'day' }, + entities: ['sensor.bedroom', 'sensor.nursery', 'sensor.gone'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (e: Element) => e.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('violin options and shapes', () => { + it('defaults to a violin with a box, readings on for a raincloud', () => { + expect(violinOptions({} as CardConfig)).toEqual({ + shape: 'violin', + box: true, + readings: false, + bandwidth: undefined, + split: 'none', + }); + expect(violinOptions({ shape: 'raincloud' } as CardConfig).readings).toBe(true); + expect(violinOptions({ shape: 'raincloud', readings: false, bandwidth: 0.5 } as CardConfig)).toMatchObject({ + readings: false, + bandwidth: 0.5, + }); + }); + + it('outlines a whole violin, or a half for a raincloud', () => { + const host = document.createElement('div'); + const g = categoryGeometry({ host, requestUpdate: () => undefined, raw: {} as CardConfig }, 1, { domain: [0, 10] }); + const curve = [ + { x: 0, d: 0 }, + { x: 5, d: 1 }, + { x: 10, d: 0 }, + ]; + expect(shapePath(curve, g, 0, false, 0, 0.4)).toBe('M0,50L500,10L1000,50L1000,50L500,90L0,50Z'); + expect(shapePath(curve, g, 0, true, 0, 0.4)).toBe('M0,50L500,10L1000,50L1000,50L500,50L0,50Z'); + expect(shapePath([], g, 0, false)).toBe(''); + }); +}); + +describe('violin chart', () => { + it('draws a violin and box per entity, named with its median, the unavailable one empty', async () => { + const card = await mount({}); + expect(qa(card, 'path.shape')).toHaveLength(2); + expect(qa(card, '.box rect.iqr')).toHaveLength(2); + expect(q(card, 'path.dots')).toBeNull(); + const labels = qa(card, '.cat-label').map(text); + expect(labels[0]).toMatch(/^Bedroom median 1\d\.\d °C$/); + expect(labels[2]).toBe('Gone No readings'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Violin chart of Bedroom, Nursery, Gone'); + expect(qa(card, '.cat-axis .axis-label').length).toBeGreaterThan(1); + expect(qa(card, '.grid line').length).toBeGreaterThan(1); + }); + + it('scatters the readings, or makes a raincloud of half violins, boxes and readings', async () => { + const withDots = await mount({ readings: true, box: false } as Partial); + expect(qa(withDots, 'path.dots')).toHaveLength(2); + expect(q(withDots, '.box')).toBeNull(); + const rain = await mount({ shape: 'raincloud' } as Partial); + expect(qa(rain, 'path.dots')).toHaveLength(2); + expect(qa(rain, '.box')).toHaveLength(2); + expect(q(rain, 'svg.plot')!.getAttribute('aria-label')).toBe('Raincloud chart of Bedroom, Nursery, Gone'); + }); + + it('stands up, without value labels or lines when asked', async () => { + const card = await mount({ orientation: 'vertical', value_axis: false, value_grid: false } as Partial); + expect(q(card, '.cat-plot.vertical')).not.toBeNull(); + expect(q(card, '.cat-axis')).toBeNull(); + expect(q(card, '.grid')).toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('viewBox')).toBe('0 0 300 1000'); + const labelled = await mount({ orientation: 'vertical', value_min: 0, value_max: 40 } as Partial); + const ticks = qa(labelled, '.cat-axis .axis-label').map(text); + expect(ticks[0]).toBe('0'); + expect(ticks.at(-1)).toBe('40'); + expect(q(labelled, '.cat-sizer')!.textContent).toHaveLength(2); + }); + + it('draws one shape per entity and day', async () => { + const card = await mount({ split_by: 'day', entities: ['sensor.bedroom'] } as Partial); + // Two days back from now touches three calendar days. + expect(qa(card, 'path.shape')).toHaveLength(3); + const two = await mount({ split_by: 'day', entities: ['sensor.bedroom', 'sensor.nursery'] } as Partial); + expect(qa(two, '.cat-name').map(text)[1]).toMatch(/· Nursery$/); + }); + + it('works from grouped readings and a fixed bandwidth', async () => { + const card = await mount({ group_by: 'hour', bandwidth: 0.5 } as Partial); + expect(qa(card, 'path.shape')).toHaveLength(2); + }); + + it('shows the median, middle half, range and count for the band under the pointer', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 192, right: 300, bottom: 192 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 10 })); + await card.updateComplete; + expect(text(q(card, '.band-tooltip .tooltip-time')!)).toBe('Bedroom'); + const rows = qa(card, '.band-tooltip .tooltip-row').map(text); + expect(rows.map((r) => r.split(' ')[0])).toEqual(['Median', 'Middle', 'Lowest', 'Readings']); + expect(rows[3]).toMatch(/Readings 14\d/); + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 180 })); + await card.updateComplete; + // Over the unavailable entity: nothing to show. + expect(q(card, '.band-tooltip')).toBeNull(); + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 100 })); + await card.updateComplete; + expect(q(card, '.band-tooltip')!.getAttribute('style')).toBe('top:66.67%;right:0'); + area.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.band-tooltip')).toBeNull(); + }); + + it('puts the box above a band in the bottom half', async () => { + const card = await mount({ entities: ['sensor.gone', 'sensor.bedroom'] } as Partial); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 128, right: 300, bottom: 128 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 100 })); + await card.updateComplete; + expect(q(card, '.band-tooltip')!.getAttribute('style')).toMatch(/^top:auto;bottom:50%/); + }); + + it('opens an entity from its name', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const label = qa(card, '.cat-label')[1]; + label.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + label.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.nursery']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + expect(q(card, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'violin', entities: ['sensor.bedroom'], ...extra } as CardConfig); + expect(set({ shape: 'cello' })).toThrow(/"shape" must be one of: violin, raincloud/); + expect(set({ split_by: 'year' })).toThrow(/split_by/); + expect(set({ box: 'yes' })).toThrow(/box/); + expect(set({ readings: 1 })).toThrow(/readings/); + expect(set({ bandwidth: 0 })).toThrow(/"bandwidth" must be a number above 0/); + expect(set({ compact: 'yes' })).toThrow(/compact/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ shape: 'raincloud', split_by: 'week', bandwidth: 0.2, orientation: 'vertical' })).not.toThrow(); + }); +}); + +describe('violin editor', () => { + it('writes the shape, direction, box, readings, split, smoothing and value axis', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'violin', entities: ['sensor.bedroom'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ + shape: 'violin', + orientation: 'horizontal', + box: true, + readings: false, + split_by: 'none', + value_axis: true, + value_grid: true, + tooltip: true, + compact: false, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + shape: 'raincloud', + orientation: 'vertical', + split_by: 'day', + bandwidth: 0.3, + value_min: 10, + value_grid: false, + }, + }, + }), + ); + const next = events.at(-1)!; + expect(next).toEqual({ + ...raw, + shape: 'raincloud', + orientation: 'vertical', + split_by: 'day', + bandwidth: 0.3, + value_min: 10, + value_grid: false, + }); + // A raincloud shows its readings unless told not to. + ed.setConfig(next); + await ed.updateComplete; + const rain = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((rain.data as { readings: boolean }).readings).toBe(true); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); From 70122c66794bdc7f8142b3cb6907b01e2fc0ed11 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:34:53 +0000 Subject: [PATCH 20/41] feat: beeswarm chart of every reading, or every time something turned on A new "Beeswarm" style (Distributions in the style picker) draws each entity's readings as dots along a value axis, one row per entity, packed side by side so no two overlap. Where the dots pile up is where the readings were. "Place each dot by: the time of day it happened" puts each dot at its clock time on a midnight to midnight axis instead. For motion sensors, doors, switches and lights each dot is a time it turned on, so a week of hallway motion shows when the house is busy. To make that possible, history now reads "on" as 1 and "off" as 0 (they were gaps before); a line chart of a binary sensor therefore draws a 0/1 line where it said "No readings". Coming back on after being unavailable is not counted as a new event when it was on before, so restarts don't add dots. "Bigger dots for bigger values" sizes each dot by its reading, from small at the row's lowest value to full size at its highest, by area. Direction "Up" stands the rows up as columns. Dot size is set in px. Value labels, lines and fixed lowest/highest values work as on the violin. Dots are packed in screen pixels so they stay round: in order along the axis, each takes the free spot nearest its row's line. A row too busy for its height is packed again with smaller dots, and if that still doesn't fit the swarm is squeezed into its row (dots overlap rather than spill into the next one). Rows show at most 400 dots, evenly sampled; the count under the name is always the full one and the hover box says how many are drawn. Hovering also shows the median, middle half and range, as clock times on a time-of-day axis. Each row opens its entity. Everything is in the editor's Chart section. Packing is in src/chart/swarm.ts; readings and the shared row layout come from src/chart/density.ts and src/chart/category-plot.ts (also used by the violin chart). Closes #37 --- src/chart/swarm.ts | 105 ++++++++++++ src/history/fetch.ts | 7 +- src/styles/beeswarm.ts | 353 +++++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/beeswarm.test.ts | 294 ++++++++++++++++++++++++++++++++++ test/history.test.ts | 4 +- 7 files changed, 765 insertions(+), 4 deletions(-) create mode 100644 src/chart/swarm.ts create mode 100644 src/styles/beeswarm.ts create mode 100644 test/beeswarm.test.ts diff --git a/src/chart/swarm.ts b/src/chart/swarm.ts new file mode 100644 index 0000000..1814331 --- /dev/null +++ b/src/chart/swarm.ts @@ -0,0 +1,105 @@ +/** + * Beeswarm packing: dots placed along a value axis at their value, each nudged sideways as little + * as it can be so no two overlap. Everything here is in px, so dots stay round on screen. + */ + +export interface SwarmDot { + /** Position along the value axis (px). */ + at: number; + /** Radius (px). */ + r: number; +} + +/** Room left between neighbouring dots (px). */ +const GAP = 0.75; + +/** + * Sideways offsets (px, 0 is the axis line) for `dots`, in the order given. Dots are placed from + * one end of the axis to the other; each takes the free spot nearest the line, touching a dot + * already placed or on the line itself (the classic greedy beeswarm), the positive side first on a tie. + */ +export function swarm(dots: SwarmDot[]): number[] { + const order = dots.map((_, i) => i).sort((a, b) => dots[a].at - dots[b].at || a - b); + const out = new Array(dots.length).fill(0); + const placed: { at: number; r: number; y: number }[] = []; + let first = 0; + const largest = maxR(dots); + for (const i of order) { + const { at, r } = dots[i]; + // Placed dots are in order along the axis: skip those too far behind to touch this one. + while (first < placed.length && at - placed[first].at > r + largest + GAP) first++; + const near = placed.slice(first).filter((q) => Math.abs(q.at - at) < q.r + r + GAP); + const candidates = [0]; + for (const q of near) { + const reach = q.r + r + GAP; + const dy = Math.sqrt(Math.max(0, reach * reach - (q.at - at) ** 2)); + candidates.push(q.y + dy, q.y - dy); + } + const ok = candidates + .sort((a, b) => Math.abs(a) - Math.abs(b) || b - a) + .find((y) => near.every((q) => (q.at - at) ** 2 + (q.y - y) ** 2 >= (q.r + r + GAP) ** 2 - 1e-6)); + const y = ok ?? 0; + out[i] = y; + placed.push({ at, r, y }); + } + return out; +} + +/** The largest radius among `dots` (for how far back a neighbour might reach). */ +function maxR(dots: { r: number }[]): number { + let m = 0; + for (const d of dots) if (d.r > m) m = d.r; + return m; +} + +/** How far the swarm spreads from its line (px): the furthest dot's edge. */ +export function swarmReach(dots: SwarmDot[], offsets: number[]): number { + let m = 0; + dots.forEach((d, i) => { + m = Math.max(m, Math.abs(offsets[i]) + d.r); + }); + return m; +} + +/** + * Pack dots into a band `half` px either side of the line. A swarm too tall for its band is + * packed again with smaller dots (down to `minR`); one still too tall after that is squeezed + * into the band, letting its dots overlap rather than spill into the next row. + * Returns the offsets and the scale the radii were drawn at. + */ +export function fitSwarm(dots: SwarmDot[], half: number, minR = 1): { offsets: number[]; scale: number } { + let scale = 1; + let offsets = swarm(dots); + const reachAt = (s: number) => + swarmReach( + dots.map((d) => ({ ...d, r: d.r * s })), + offsets, + ); + const smallest = Math.min(...dots.map((d) => d.r)); + const floor = Math.min(1, minR / Math.max(smallest, 1e-9)); + for (let tries = 0; tries < 4; tries++) { + const reach = reachAt(scale); + if (reach <= half) return { offsets, scale }; + const next = Math.max(floor, scale * Math.sqrt(half / reach) * 0.95); + if (next >= scale) break; + scale = next; + offsets = swarm(dots.map((d) => ({ ...d, r: d.r * scale }))); + } + const reach = reachAt(scale); + if (reach > half) { + const room = Math.max(0, half - Math.max(...dots.map((d) => d.r * scale))); + const furthest = Math.max(...offsets.map(Math.abs)); + if (furthest > 0) offsets = offsets.map((y) => (y * room) / furthest); + } + return { offsets, scale }; +} + +/** + * Dot radius for a value when dots are sized by value: the dot's area grows from 12 % at the + * lowest value in the row (`lo`) to all of it at the highest (`hi`). + */ +export function sizeFor(v: number, lo: number, hi: number, r: number): number { + if (!(hi > lo)) return r; + const share = Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + return r * Math.sqrt(0.12 + 0.88 * share); +} diff --git a/src/history/fetch.ts b/src/history/fetch.ts index 92c8d0f..1190eb3 100644 --- a/src/history/fetch.ts +++ b/src/history/fetch.ts @@ -1,9 +1,14 @@ import type { HomeAssistant } from '../types'; import type { Point, Range, SeriesRequest } from './types'; -/** A state string or attribute value as a number, or null for a gap. */ +/** + * A state string or attribute value as a number, or null for a gap. On and off (binary sensors, + * switches, lights) read as 1 and 0, so their changes are readings too. + */ export function toReading(raw: unknown): number | null { if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null; + if (raw === 'on') return 1; + if (raw === 'off') return 0; if (typeof raw !== 'string' || raw.trim() === '') return null; const n = Number(raw); return Number.isFinite(n) ? n : null; diff --git a/src/styles/beeswarm.ts b/src/styles/beeswarm.ts new file mode 100644 index 0000000..3d8461a --- /dev/null +++ b/src/styles/beeswarm.ts @@ -0,0 +1,353 @@ +import { css, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { + CATEGORY_FIELDS, + bandTooltip, + categoryGeometry, + categoryOptions, + categoryPlot, + categorySchema, + categoryStyles, + clockScale, + hoveredBand, + labelCount, + numberScale, + stopCategoryHover, + validateCategoryOptions, +} from '../chart/category-plot'; +import type { Category, CategoryGeometry, ValueScale } from '../chart/category-plot'; +import { readingsOf, sampleReadings, summarize } from '../chart/density'; +import type { Reading } from '../chart/density'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { chartFrame, chartLayout, layoutStyles } from '../chart/layout'; +import { dotsPath } from '../chart/series-style'; +import { fitSwarm, sizeFor } from '../chart/swarm'; +import { tooltipEnabled, tooltipStyles } from '../chart/tooltip'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { wallTime } from '../history/timezone'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const PLACE_BY = ['value', 'time_of_day'] as const; +export type PlaceBy = (typeof PLACE_BY)[number]; + +export const DEFAULT_DOT = 6; +export const MIN_DOT = 2; +export const MAX_DOT = 16; +/** At most this many dots per row; more are evenly sampled (the count still says how many). */ +const MAX_DOTS = 400; +/** A row's height across, before compact: swarms need more room than a violin. */ +const ROW_PX = 96; +/** Dot sizes when sized by value are rounded to this many steps, one path each. */ +const SIZE_STEPS = 6; + +registerStrings('en', { + editor_style_beeswarm: 'Beeswarm', + editor_place_by: 'Place each dot by', + editor_place_by_value: 'Its value', + editor_place_by_time_of_day: 'The time of day it happened', + editor_dot_size: 'Dot size (px)', + editor_size_by_value: 'Bigger dots for bigger values', + swarm_readings: '{n} readings', + swarm_events: '{n} times on', + swarm_shown: 'Dots shown', + swarm_none: 'No readings', + swarm_earliest_latest: 'Earliest – latest', +}); + +export interface SwarmOptions { + placeBy: PlaceBy; + dot: number; + sized: boolean; +} + +export function swarmOptions(raw: CardConfig): SwarmOptions { + return { + placeBy: (raw.place_by as PlaceBy | undefined) ?? 'value', + dot: typeof raw.dot_size === 'number' ? raw.dot_size : DEFAULT_DOT, + sized: raw.size_by_value === true, + }; +} + +/** Domains whose states are on and off: their dots are the times they turned on. */ +const ON_OFF = /^(binary_sensor|switch|input_boolean|light|fan|siren|automation)\./; + +/** + * The times something turned on: readings of 1 (on) after anything but 1. The state carried in + * from before the period is not an event, and nor is coming back on after being unavailable (a + * restart), when it was on before. + */ +export function onEvents(points: Point[]): Point[] { + const out: Point[] = []; + let prev: number | null = null; + for (const p of points) { + if (p.v === null) continue; + if (p.v === 1 && prev !== 1 && !p.carried) out.push(p); + prev = p.v; + } + return out; +} + +/** Hours since midnight (0–24) in the card's time zone. */ +export function hourOfDay(t: number, timeZone: string): number { + const w = wallTime(t, timeZone); + return w.hour + w.minute / 60 + w.second / 3600; +} + +/** One row of dots: the readings (or events), placed and sized. */ +interface Row { + series: ChartSeries; + /** Where along the axis, in axis units (a value, or hours). */ + positions: number[]; + /** The reading behind each dot, for sizing. */ + values: number[]; + total: number; + events: boolean; +} + +function rowsOf(frame: ReturnType, o: SwarmOptions, now: number): Row[] { + const until = Math.min(frame.range.end, now); + return frame.series.map((series) => { + const events = o.placeBy === 'time_of_day' && ON_OFF.test(series.entity.entityId); + let readings: Reading[]; + if (events) { + readings = onEvents(series.points).map((p) => ({ t: p.t, v: 1, w: 1, real: true })); + } else { + // Dots are readings taken in the period: not the value carried in, nor the edge copy. + readings = readingsOf(series.points, frame.source === 'history' && !frame.group, until).filter((r) => r.real); + } + const shown = sampleReadings(readings, MAX_DOTS); + return { + series, + positions: shown.map((r) => (o.placeBy === 'time_of_day' ? hourOfDay(r.t, frame.zone.timeZone) : r.v)), + values: shown.map((r) => r.v), + total: readings.length, + events, + }; + }); +} + +function detail(row: Row, hass: HomeAssistant | undefined): string { + if (row.total === 0) return localize(hass, 'swarm_none'); + const n = formatNumber(row.total, 0, hass); + const count = localize(hass, row.events ? 'swarm_events' : 'swarm_readings', { n }); + return count; +} + +/** One row's swarm: dots packed in px so they stay round and apart, then into plot units. */ +function drawRow(row: Row, i: number, g: CategoryGeometry, scale: ValueScale, o: SwarmOptions): SVGTemplateResult { + if (row.positions.length === 0) return svg``; + const r = o.dot / 2; + const span = scale.domain[1] - scale.domain[0] || 1; + const lo = Math.min(...row.values); + const hi = Math.max(...row.values); + const radii = row.values.map((v) => { + if (!o.sized || row.events) return r; + // Rounded to a few steps, so a row is a handful of paths rather than one per dot. + const exact = sizeFor(v, lo, hi, r); + return Math.max(0.5, Math.round((exact / r) * SIZE_STEPS) / SIZE_STEPS) * r; + }); + const dots = row.positions.map((p, k) => ({ at: ((p - scale.domain[0]) / span) * g.valuePx, r: radii[k] })); + const { offsets, scale: shrink } = fitSwarm(dots, g.bandPx / 2 - 1, 1); + const bySize = new Map(); + dots.forEach((d, k) => { + const size = Math.round(d.r * shrink * 2 * 100) / 100; + const list = bySize.get(size) ?? []; + list.push(g.at(row.positions[k], i, offsets[k] / g.bandPx)); + bySize.set(size, list); + }); + return svg` + ${[...bySize].map(([size, pts]) => svg``)} + `; +} + +function tooltipRows(row: Row, scale: ValueScale, clock: boolean, hass: HomeAssistant | undefined): [string, string][] { + const s = summarize(row.positions.map((v, i) => ({ t: i, v, w: 1, real: true }))); + if (!s) return []; + const f = (v: number) => + clock ? scale.format(v) : withUnit(formatNumber(v, row.series.entity.decimals, hass), row.series.entity.unit); + return [ + [localize(hass, 'dist_count'), formatNumber(row.total, 0, hass)], + ...(row.positions.length < row.total + ? ([[localize(hass, 'swarm_shown'), formatNumber(row.positions.length, 0, hass)]] as [string, string][]) + : []), + [localize(hass, 'dist_median'), f(s.median)], + [localize(hass, 'dist_middle'), `${f(s.q1)} – ${f(s.q3)}`], + [localize(hass, 'swarm_earliest_latest'), `${f(s.min)} – ${f(s.max)}`], + ]; +} + +const SWARM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'place_by', path: ['place_by'], default: 'value' }, + { name: 'dot_size', path: ['dot_size'], default: DEFAULT_DOT }, + { name: 'size_by_value', path: ['size_by_value'], default: false }, + ...CATEGORY_FIELDS, +]; + +/** Fixed value ends only mean something when dots are placed by value. */ +function withoutValueRange(schema: unknown[]): unknown[] { + return schema.map((item) => { + const grid = item as { schema?: { name: string }[] }; + if (!grid.schema) return item; + return { ...grid, schema: grid.schema.filter((s) => s.name !== 'value_min' && s.name !== 'value_max') }; + }); +} + +export function swarmEditor(ctx: ChartEditorContext): TemplateResult { + const o = swarmOptions(ctx.config); + const schema = cachedSchema(`beeswarm|${historyVariant(ctx.config)}|${o.placeBy}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + { name: 'place_by', selector: choice(ctx.hass, 'place_by', PLACE_BY) }, + { + type: 'grid', + name: '', + schema: [ + { + name: 'dot_size', + selector: { number: { min: MIN_DOT, max: MAX_DOT, step: 1, mode: 'box', unit_of_measurement: 'px' } }, + }, + { name: 'size_by_value', selector: { boolean: {} } }, + ], + }, + ...(o.placeBy === 'value' ? categorySchema(ctx.hass) : withoutValueRange(categorySchema(ctx.hass))), + ]); + return chartForm(ctx, 'chart', schema, SWARM_FIELDS); +} + +const ENTITY_FIELDS = { + schema: [{ name: 'fill_color', selector: { text: {} } }], + fields: [{ name: 'fill_color', path: ['color'] }], +}; + +registerStrings('en', { editor_fill_color: 'Colour' }); + +export const beeswarm: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 260, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + ${categoryStyles} + .swarm .dots { + fill: none; + stroke-linecap: round; + stroke-opacity: 0.85; + } + `, + thumbnail: () => svg` + + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateCategoryOptions(raw); + if (raw.place_by !== undefined && !PLACE_BY.includes(raw.place_by as PlaceBy)) { + throw new Error(`"place_by" must be one of: ${PLACE_BY.join(', ')}`); + } + const dot = raw.dot_size; + if (dot !== undefined && !(typeof dot === 'number' && dot >= MIN_DOT && dot <= MAX_DOT)) { + throw new Error(`"dot_size" must be a number from ${MIN_DOT} to ${MAX_DOT}`); + } + if (raw.size_by_value !== undefined && typeof raw.size_by_value !== 'boolean') { + throw new Error('"size_by_value" must be true or false'); + } + }, + editor: swarmEditor, + entityFields: () => ENTITY_FIELDS, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopCategoryHover(host); + }, + render(ctx: ChartContext) { + const now = Date.now(); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout, nav, message); + const o = swarmOptions(ctx.raw); + const rows = rowsOf(frame, o, now); + const options = categoryOptions(ctx.raw); + const probe = categoryGeometry(ctx, rows.length, { domain: [0, 1] }, ROW_PX); + const count = labelCount(probe.valuePx, options.orientation); + let scale: ValueScale; + if (o.placeBy === 'time_of_day') scale = clockScale(count, ctx.hass); + else { + const all = rows.flatMap((r) => r.positions); + scale = numberScale(all.length ? [Math.min(...all), Math.max(...all)] : undefined, ctx.raw, count, ctx.hass); + } + const geometry = categoryGeometry(ctx, rows.length, scale, ROW_PX); + const content = svg`${rows.map((row, i) => drawRow(row, i, geometry, scale, o))}`; + const categories: Category[] = rows.map((row) => { + const d = detail(row, ctx.hass); + return { + index: row.series.index, + name: row.series.entity.name, + detail: d, + color: row.series.color, + label: `${row.series.entity.name}, ${d}`, + }; + }); + const tooltip = tooltipEnabled(ctx.raw); + const at = tooltip ? hoveredBand(ctx.host) : undefined; + const hovered = at === undefined ? undefined : rows[at]; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_beeswarm'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + nav, + categoryPlot({ + ctx, + geometry, + categories, + scale, + content, + label, + hover: tooltip, + overlay: + hovered && hovered.total > 0 && at !== undefined + ? bandTooltip( + geometry, + at, + hovered.series.entity.name, + tooltipRows(hovered, scale, o.placeBy === 'time_of_day', ctx.hass), + hovered.series.color, + ) + : nothing, + }), + ); + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index c3a26bb..134751a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -37,6 +37,7 @@ import { sparklineStyle } from './sparkline'; import { streamgraph } from './streamgraph'; import { candlestick } from './candlestick'; import { violin } from './violin'; +import { beeswarm } from './beeswarm'; export interface RenderContext { show: ShowOptions; @@ -163,4 +164,5 @@ export const STYLES: Record = { streamgraph, candlestick, violin, + beeswarm, }; diff --git a/src/types.ts b/src/types.ts index 3f70fd7..cc98df2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/beeswarm.test.ts b/test/beeswarm.test.ts new file mode 100644 index 0000000..546f1e8 --- /dev/null +++ b/test/beeswarm.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { fitSwarm, sizeFor, swarm, swarmReach } from '../src/chart/swarm'; +import { hourOfDay, onEvents, swarmOptions } from '../src/styles/beeswarm'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const overlaps = (dots: { at: number; r: number }[], ys: number[]) => { + for (let i = 0; i < dots.length; i++) { + for (let j = i + 1; j < dots.length; j++) { + const d = Math.hypot(dots[i].at - dots[j].at, ys[i] - ys[j]); + if (d < dots[i].r + dots[j].r - 1e-6) return true; + } + } + return false; +}; + +describe('swarm packing', () => { + it('leaves dots on the line when they have room', () => { + expect( + swarm([ + { at: 0, r: 3 }, + { at: 10, r: 3 }, + { at: 20, r: 3 }, + ]), + ).toEqual([0, 0, 0]); + }); + + it('nudges crowded dots sideways, either way, never overlapping', () => { + const dots = Array.from({ length: 30 }, (_, i) => ({ at: (i % 5) * 2, r: 3 })); + const ys = swarm(dots); + expect(overlaps(dots, ys)).toBe(false); + expect(ys.some((y) => y > 0)).toBe(true); + expect(ys.some((y) => y < 0)).toBe(true); + // Two dots in the same place: one on the line, the next just touching it. + const [a, b] = swarm([ + { at: 5, r: 2 }, + { at: 5, r: 2 }, + ]); + expect(a).toBe(0); + expect(Math.abs(b)).toBeCloseTo(4.75); + expect( + swarmReach( + [ + { at: 5, r: 2 }, + { at: 5, r: 2 }, + ], + [a, b], + ), + ).toBeCloseTo(6.75); + }); + + it('shrinks a swarm that is too tall for its band, then squeezes it in', () => { + const dots = Array.from({ length: 40 }, () => ({ at: 50, r: 3 })); + const roomy = fitSwarm(dots.slice(0, 2), 20); + expect(roomy.scale).toBe(1); + const shrunk = fitSwarm(dots.slice(0, 6), 10); + expect(shrunk.scale).toBeLessThan(1); + const squeezed = fitSwarm(dots, 10, 1); + expect(Math.max(...squeezed.offsets.map(Math.abs))).toBeLessThanOrEqual(10); + }); + + it('sizes dots by value, by area, across the row', () => { + expect(sizeFor(30, 10, 30, 4)).toBe(4); + expect(sizeFor(10, 10, 30, 4)).toBeCloseTo(4 * Math.sqrt(0.12)); + expect(sizeFor(20, 10, 30, 4)).toBeCloseTo(4 * Math.sqrt(0.56)); + expect(sizeFor(5, 5, 5, 4)).toBe(4); + }); +}); + +describe('beeswarm helpers', () => { + it('reads its options', () => { + expect(swarmOptions({} as CardConfig)).toEqual({ placeBy: 'value', dot: 6, sized: false }); + expect(swarmOptions({ place_by: 'time_of_day', dot_size: 4, size_by_value: true } as CardConfig)).toEqual({ + placeBy: 'time_of_day', + dot: 4, + sized: true, + }); + }); + + it('finds the times something turned on', () => { + const events = onEvents([ + { t: 0, v: 1, carried: true }, + { t: 1, v: 0 }, + { t: 2, v: 1 }, + { t: 3, v: 1 }, + { t: 4, v: null }, + { t: 5, v: 1 }, + ]); + // Back on after unavailable, when it was on before, is a restart, not a new event. + expect(events.map((p) => p.t)).toEqual([2]); + }); + + it('gives the hour of the day in a time zone', () => { + const t = Date.UTC(2026, 6, 1, 6, 30); + expect(hourOfDay(t, 'UTC')).toBe(6.5); + expect(hourOfDay(t, 'Europe/London')).toBe(7.5); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; + +function hassWith(history: Record) { + const hass = makeHass({ + 'sensor.hall': ['20', { friendly_name: 'Hall', unit_of_measurement: '°C' }], + 'binary_sensor.motion': ['off', { friendly_name: 'Motion' }], + 'sensor.gone': ['unavailable', { friendly_name: 'Gone' }], + }); + hass.config = { time_zone: 'UTC' } as HomeAssistant['config']; + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +const hall = Array.from({ length: 40 }, (_, i) => ({ + s: String(18 + (i % 8) / 2), + lu: (now - 20 * H + i * 20 * 60_000) / 1000, +})); +const motion = Array.from({ length: 12 }, (_, i) => ({ s: i % 2 ? 'off' : 'on', lu: (now - 22 * H + i * H) / 1000 })); +const history = { + 'sensor.hall': hall, + 'binary_sensor.motion': motion, + 'sensor.gone': [{ s: 'unavailable', lu: (now - 20 * H) / 1000 }], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'beeswarm', + entities: ['sensor.hall', 'sensor.gone'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (e: Element) => e.textContent!.replace(/\s+/g, ' ').trim(); +const dotCount = (el: Element) => + [...el.querySelectorAll('path.dots')].reduce((a, p) => a + p.getAttribute('d')!.split('M').length - 1, 0); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('beeswarm chart', () => { + it('packs every reading of each entity in its own row, counting them', async () => { + const card = await mount({}); + const swarms = qa(card, 'g.swarm'); + expect(swarms).toHaveLength(1); + expect(swarms[0].getAttribute('data-index')).toBe('0'); + expect(swarms[0].getAttribute('stroke')).toContain('--primary-color'); + expect(dotCount(swarms[0])).toBe(40); + expect(qa(card, '.cat-label').map(text)).toEqual(['Hall 40 readings', 'Gone No readings']); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Beeswarm chart of Hall, Gone'); + expect(q(card, 'svg.plot')!.getAttribute('viewBox')).toBe('0 0 1000 200'); + }); + + it('places motion by the time of day it turned on, on a clock axis', async () => { + const card = await mount({ + place_by: 'time_of_day', + entities: ['binary_sensor.motion', 'sensor.hall'], + } as Partial); + expect(qa(card, '.cat-label').map(text)[0]).toBe('Motion 6 times on'); + expect(dotCount(qa(card, 'g.swarm')[0])).toBe(6); + const ticks = qa(card, '.cat-axis .axis-label').map(text); + expect(ticks[0]).toMatch(/^(0?0:00|12:00 AM)$/); + expect(ticks.length).toBeGreaterThan(2); + }); + + it('sizes dots by value, and stands up', async () => { + const card = await mount({ size_by_value: true, orientation: 'vertical', dot_size: 8 } as Partial); + const sizes = qa(card, 'g.swarm path.dots').map((p) => p.getAttribute('style')); + expect(new Set(sizes).size).toBeGreaterThan(1); + expect(q(card, '.cat-plot.vertical')).not.toBeNull(); + }); + + it('shows count, median, middle half and range for the row under the pointer', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 300, height: 192, right: 300, bottom: 192 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await card.updateComplete; + const rows = qa(card, '.band-tooltip .tooltip-row').map(text); + expect(rows[0]).toBe('Readings 40'); + expect(rows[1]).toMatch(/^Median [\d.]+ °C$/); + const clock = await mount({ place_by: 'time_of_day', entities: ['binary_sensor.motion'] } as Partial); + const plot = q(clock, '.plot-area')!; + plot.getBoundingClientRect = () => ({ left: 0, top: 0, width: 300, height: 96, right: 300, bottom: 96 }) as DOMRect; + plot.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await clock.updateComplete; + expect(qa(clock, '.band-tooltip .tooltip-row').map(text)[1]).toMatch(/^Median \d{1,2}:\d\d/); + }); + + it('says when sampling leaves dots out', async () => { + const many = Array.from({ length: 900 }, (_, i) => ({ s: String(i % 50), lu: (now - 20 * H + i * 60_000) / 1000 })); + const card = await mount({ entities: ['sensor.hall'] }, hassWith({ 'sensor.hall': many })); + expect(dotCount(q(card, 'g.swarm')!)).toBe(400); + const area = q(card, '.plot-area')!; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 300, height: 96, right: 300, bottom: 96 }) as DOMRect; + area.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 50, clientY: 20 })); + await card.updateComplete; + expect(qa(card, '.band-tooltip .tooltip-row').map(text)[1]).toBe('Dots shown 400'); + }); + + it('opens an entity from its swarm', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const swarmEl = q(card, 'g.swarm')!; + swarmEl.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + swarmEl.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.hall']); + }); + + it('says when there is nothing to draw', async () => { + const card = await mount({}, hassWith({})); + expect(q(card, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'beeswarm', entities: ['sensor.hall'], ...extra } as CardConfig); + expect(set({ place_by: 'weekday' })).toThrow(/"place_by" must be one of: value, time_of_day/); + expect(set({ dot_size: 40 })).toThrow(/"dot_size" must be a number from 2 to 16/); + expect(set({ size_by_value: 'yes' })).toThrow(/size_by_value/); + expect(set({ orientation: 'diagonal' })).toThrow(/orientation/); + expect(set({ place_by: 'time_of_day', dot_size: 4, size_by_value: true })).not.toThrow(); + }); +}); + +describe('beeswarm editor', () => { + it('writes placement, dot size and sizing, hiding the value range for time of day', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'beeswarm', entities: ['sensor.hall'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toMatchObject({ + place_by: 'value', + dot_size: 6, + size_by_value: false, + orientation: 'horizontal', + }); + expect(JSON.stringify(form.schema)).toContain('value_min'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), place_by: 'time_of_day', dot_size: 4, size_by_value: true } }, + }), + ); + const next = events.at(-1)!; + expect(next).toEqual({ ...raw, place_by: 'time_of_day', dot_size: 4, size_by_value: true }); + ed.setConfig(next); + await ed.updateComplete; + const clock = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(clock.schema)).not.toContain('value_min'); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['fill_color']); + }); +}); diff --git a/test/history.test.ts b/test/history.test.ts index 52600f5..273f44f 100644 --- a/test/history.test.ts +++ b/test/history.test.ts @@ -37,10 +37,12 @@ describe('period', () => { }); describe('fetch', () => { - it('reads numbers and turns everything else into gaps', () => { + it('reads numbers and on / off, and turns everything else into gaps', () => { expect(toReading('21.5')).toBe(21.5); expect(toReading(3)).toBe(3); expect(toReading('unavailable')).toBeNull(); + expect(toReading('on')).toBe(1); + expect(toReading('off')).toBe(0); expect(toReading('')).toBeNull(); expect(toReading(Infinity)).toBeNull(); }); From bd52d7bf2f418a96588d9ce3a6c10b967330baa0 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 20:37:56 +0000 Subject: [PATCH 21/41] feat: show how power or energy flows between sources and loads as a Sankey chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new "Sankey (flow)" style (group "Parts of a total") draws each of the card's entities as one flow between two named nodes, from live values (no history). Each entity gets "Flows from" and "Flows to" in its options in the editor (`from:` and `to:` in YAML), plus an optional "Flow colour": style: sankey entities: - entity: sensor.solar_to_house from: Solar to: House - entity: sensor.grid_import from: Grid to: House - entity: sensor.heat_pump_power from: House to: Heat pump Nodes come from the names. A node's value is the larger of what flows in and what flows out, and its column is the longest path to it from a source, so a node that only receives (a battery or export fed straight from solar) stays in the middle rather than being pushed to the far right. Columns share one height scale, set by the fullest; band widths are proportional to the flow, and bands leave and arrive in the order of the nodes at their other end so they cross as little as possible. Bands are filled shapes, not wide strokes, so a thick band between close columns keeps clean edges. Labels give each node's name and total, with the first flow's unit and decimals: left of the first column, right of the last, and above a middle node that has flows in and out ("House 3.9 kW"). Long names are cut short with an ellipsis and values dropped where they would not fit, so nothing is clipped on a 320 px card; labels in a column are moved apart so they never overlap. The Display name and value switches hide either. Colours follow the theme: nodes take the series colours in order. With "Colour flows by" (link_color, Chart section) on auto, a band takes its source's colour, or its target's when the source is itself fed (each circuit out of the house in its own colour); source and target force one or the other. A flow's own colour paints its band and the node it is coloured by. Tapping, holding or double-tapping a band runs that entity's actions. Hovering or focusing one raises it, dims the rest and shows "From → To value" in the card's tooltip. The drawing is role="img" with a label listing every flow, and fades in unless animation is off or reduced motion is asked for. Zero, negative, unavailable and missing flows draw nothing (a negative value counts as 0); when nothing flows the card says so, and when no entity has both ends it says how to add them. A setup where flows go round in a loop is rejected with the loop named ("Flows can't go round in a loop: A → B → A"); at draw time any flow that would close a loop is skipped rather than failing. --- src/chart/sankey.ts | 277 +++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/styles/sankey.ts | 447 +++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/sankey.test.ts | 411 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 1139 insertions(+), 2 deletions(-) create mode 100644 src/chart/sankey.ts create mode 100644 src/styles/sankey.ts create mode 100644 test/sankey.test.ts diff --git a/src/chart/sankey.ts b/src/chart/sankey.ts new file mode 100644 index 0000000..6aac283 --- /dev/null +++ b/src/chart/sankey.ts @@ -0,0 +1,277 @@ +/** + * Sankey maths: which flows can be drawn, which column each node goes in, and where every node + * and link sits. Pure functions in pixel units, so the style only has to draw what comes back. + */ + +/** One flow from the card's entities: a link from one named node to another. */ +export interface Flow { + /** Index into the card's items, for tap actions. */ + index: number; + from: string; + to: string; + /** Current value; null, negative or non-finite counts as nothing flowing. */ + value: number | null; +} + +export interface SankeyNode { + name: string; + /** Order of first appearance in the flows, for its colour and its place in its column. */ + order: number; + column: number; + value: number; + in: number; + out: number; + x: number; + y: number; + h: number; +} + +export interface SankeyLink { + flow: Flow; + value: number; + source: SankeyNode; + target: SankeyNode; + /** Band width in px. */ + width: number; + /** Centre of the band where it leaves the source and where it reaches the target. */ + y0: number; + y1: number; +} + +export interface SankeyLayout { + nodes: SankeyNode[]; + links: SankeyLink[]; + columns: number; +} + +export interface LayoutOptions { + width: number; + height: number; + /** Space kept left of the first column and right of the last, for labels. */ + left: number; + right: number; + nodeWidth: number; + /** Space between nodes in a column. */ + gap: number; + /** Extra space a node needs above it in its column (a label sitting on top). */ + above?(node: SankeyNode, columns: number): number; +} + +/** Nodes a node can reach by following links forwards. */ +function reaches(edges: Map, from: string, to: string): boolean { + const seen = new Set(); + const stack = [from]; + while (stack.length) { + const n = stack.pop()!; + if (n === to) return true; + if (seen.has(n)) continue; + seen.add(n); + stack.push(...(edges.get(n) ?? [])); + } + return false; +} + +/** + * The flows that can be drawn, in order: each needs a `from` and a different `to`, and a flow + * that would close a loop (A → B → A) is left out, as are the ones after it that would. + */ +export function acceptFlows>(flows: T[]): T[] { + const edges = new Map(); + const kept: T[] = []; + for (const f of flows) { + if (!f.from || !f.to || f.from === f.to) continue; + if (reaches(edges, f.to, f.from)) continue; + edges.set(f.from, [...(edges.get(f.from) ?? []), f.to]); + kept.push(f); + } + return kept; +} + +/** The first loop in a list of from → to pairs, as the names round it ("A", "B", "A"), if any. */ +export function findCycle(pairs: [string, string][]): string[] | undefined { + const edges = new Map(); + for (const [from, to] of pairs) { + if (from === to) return [from, to]; + edges.set(from, [...(edges.get(from) ?? []), to]); + } + const state = new Map(); + const path: string[] = []; + const visit = (n: string): string[] | undefined => { + if (state.get(n) === 'open') return [...path.slice(path.indexOf(n)), n]; + if (state.get(n) === 'done') return undefined; + state.set(n, 'open'); + path.push(n); + for (const next of edges.get(n) ?? []) { + const found = visit(next); + if (found) return found; + } + path.pop(); + state.set(n, 'done'); + return undefined; + }; + for (const n of edges.keys()) { + const found = visit(n); + if (found) return found; + } + return undefined; +} + +/** Nothing flowing counts as 0: no reading, a negative one, or not a number. */ +export function flowValue(v: number | null): number { + return v !== null && Number.isFinite(v) && v > 0 ? v : 0; +} + +/** + * Each node's column: the longest path to it from a node nothing flows into. A node that only + * receives stays in the column after its source, rather than being pushed to the far right. + * Expects loop-free flows (see `acceptFlows`). + */ +export function nodeColumns(flows: Pick[]): Map { + const column = new Map(); + for (const f of flows) { + column.set(f.from, column.get(f.from) ?? 0); + column.set(f.to, column.get(f.to) ?? 0); + } + // Relax until settled; with no loops, at most one pass per node. + for (let pass = 0; pass <= column.size; pass++) { + let changed = false; + for (const f of flows) { + const next = column.get(f.from)! + 1; + if (next > column.get(f.to)!) { + column.set(f.to, next); + changed = true; + } + } + if (!changed) break; + } + return column; +} + +/** + * Lay out the flows in a `width` × `height` box. Columns come from every flow (so a node keeps + * its column while a flow is at 0), but only nodes with something flowing are drawn, and empty + * columns close up. Node heights share one scale, set by the fullest column; each column is + * centred. A node's links leave and arrive in the order of the nodes at their other ends, so + * bands cross as little as they can. + */ +export function sankeyLayout(flows: Flow[], o: LayoutOptions): SankeyLayout { + const accepted = acceptFlows(flows); + const columnOf = nodeColumns(accepted); + const order = new Map(); + for (const f of accepted) { + if (!order.has(f.from)) order.set(f.from, order.size); + if (!order.has(f.to)) order.set(f.to, order.size); + } + const live = accepted.filter((f) => flowValue(f.value) > 0); + const byName = new Map(); + const node = (name: string) => { + let n = byName.get(name); + if (!n) { + n = { name, order: order.get(name)!, column: columnOf.get(name)!, value: 0, in: 0, out: 0, x: 0, y: 0, h: 0 }; + byName.set(name, n); + } + return n; + }; + const links: SankeyLink[] = live.map((flow) => { + const value = flowValue(flow.value); + const source = node(flow.from); + const target = node(flow.to); + source.out += value; + target.in += value; + return { flow, value, source, target, width: 0, y0: 0, y1: 0 }; + }); + const nodes = [...byName.values()].sort((a, b) => a.order - b.order); + for (const n of nodes) n.value = Math.max(n.in, n.out); + + // Close up empty columns. + const used = [...new Set(nodes.map((n) => n.column))].sort((a, b) => a - b); + for (const n of nodes) n.column = used.indexOf(n.column); + const columns = used.length; + if (columns === 0) return { nodes, links, columns }; + + const stacks = used.map((_, c) => nodes.filter((n) => n.column === c)); + const above = (n: SankeyNode) => o.above?.(n, columns) ?? 0; + const fixed = stacks.map((s) => s.reduce((sum, n) => sum + above(n), 0) + o.gap * (s.length - 1)); + const totals = stacks.map((s) => s.reduce((sum, n) => sum + n.value, 0)); + const scale = Math.max(0, Math.min(...stacks.map((_, c) => (o.height - fixed[c]) / totals[c]))); + const span = o.width - o.left - o.right - o.nodeWidth; + stacks.forEach((stack, c) => { + let y = (o.height - (totals[c] * scale + fixed[c])) / 2; + const x = o.left + (columns > 1 ? (span * c) / (columns - 1) : span / 2); + for (const n of stack) { + y += above(n); + n.x = x; + n.y = y; + n.h = n.value * scale; + y += n.h + o.gap; + } + }); + + const mid = (n: SankeyNode) => n.y + n.h / 2; + for (const l of links) l.width = l.value * scale; + for (const n of nodes) { + const outs = links.filter((l) => l.source === n).sort((a, b) => mid(a.target) - mid(b.target)); + let y = n.y; + for (const l of outs) { + l.y0 = y + l.width / 2; + y += l.width; + } + const ins = links.filter((l) => l.target === n).sort((a, b) => mid(a.source) - mid(b.source)); + y = n.y; + for (const l of ins) { + l.y1 = y + l.width / 2; + y += l.width; + } + } + return { nodes, links, columns }; +} + +/** + * A band from source to target as a filled shape, its edges curving half way across. Filled + * rather than stroked, so a wide band between close columns keeps clean edges. At least 1 px. + */ +export function linkPath(l: SankeyLink, nodeWidth: number): string { + const x0 = l.source.x + nodeWidth; + const x1 = l.target.x; + const mx = (x0 + x1) / 2; + const h = Math.max(1, l.width) / 2; + const r = (n: number) => Math.round(n * 10) / 10; + const [a, b, c, d] = [l.y0 - h, l.y1 - h, l.y1 + h, l.y0 + h].map(r); + return ( + `M${r(x0)},${a}C${r(mx)},${a} ${r(mx)},${b} ${r(x1)},${b}` + + `L${r(x1)},${c}C${r(mx)},${c} ${r(mx)},${d} ${r(x0)},${d}Z` + ); +} + +/** Rough text width in px: no measuring in a render, so a generous average glyph width. */ +export function textWidth(text: string, fontSize: number): number { + return text.length * fontSize * 0.58; +} + +/** `text` cut to fit `max` px, ending in "…" when cut; '' when not even a letter fits. */ +export function fitText(text: string, max: number, fontSize: number): string { + if (textWidth(text, fontSize) <= max) return text; + const chars = Math.floor(max / (fontSize * 0.58)) - 1; + return chars >= 1 ? `${text.slice(0, chars).trimEnd()}…` : ''; +} + +/** + * Move label centres in one column apart so labels `size` px tall don't overlap, staying within + * 0..height where they can. Returns the new centres in the same order as given. + */ +export function spreadLabels(centres: number[], size: number, height: number): number[] { + const idx = centres.map((_, i) => i).sort((a, b) => centres[a] - centres[b]); + const out = [...centres]; + let prev = -Infinity; + for (const i of idx) { + out[i] = Math.max(out[i], prev + size, size / 2); + prev = out[i]; + } + // Pushed past the bottom: shift back up from the end. + let next = Infinity; + for (const i of [...idx].reverse()) { + out[i] = Math.min(out[i], next - size, height - size / 2); + next = out[i]; + } + return out; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 134751a..febb1e8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -38,6 +38,7 @@ import { streamgraph } from './streamgraph'; import { candlestick } from './candlestick'; import { violin } from './violin'; import { beeswarm } from './beeswarm'; +import { sankey } from './sankey'; export interface RenderContext { show: ShowOptions; @@ -165,4 +166,5 @@ export const STYLES: Record = { candlestick, violin, beeswarm, + sankey, }; diff --git a/src/styles/sankey.ts b/src/styles/sankey.ts new file mode 100644 index 0000000..9d8ac3b --- /dev/null +++ b/src/styles/sankey.ts @@ -0,0 +1,447 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { isInteractive } from '../actions'; +import { baseColor } from '../bands'; +import { SERIES_COLORS, frameStyles } from '../chart/frame'; +import { chartWidth, stopChartWidth } from '../chart/grid'; +import { acceptFlows, findCycle, fitText, linkPath, sankeyLayout, spreadLabels, textWidth } from '../chart/sankey'; +import type { Flow, SankeyLayout, SankeyLink, SankeyNode } from '../chart/sankey'; +import { tooltipStyles } from '../chart/tooltip'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, EntityConfig, HomeAssistant } from '../types'; + +registerStrings('en', { + editor_style_sankey: 'Sankey (flow)', + editor_link_color: 'Colour flows by', + editor_link_color_auto: 'Where they come from, or where they go after a middle node', + editor_link_color_source: 'Where they come from', + editor_link_color_target: 'Where they go', + editor_from: 'Flows from', + editor_to: 'Flows to', + editor_flow_color: 'Flow colour', + sankey_aria: 'Flow chart: {flows}', + sankey_flow: '{from} to {to} {value}', + sankey_no_flows: 'Give each entity a "Flows from" and a "Flows to" in its options.', + sankey_nothing: 'Nothing is flowing right now.', +}); + +export const LINK_COLORS = ['auto', 'source', 'target'] as const; +export type LinkColor = (typeof LINK_COLORS)[number]; + +const NODE_W = 10; +const GAP = 12; +/** Room above a middle node for its one-line label. */ +const ABOVE = 20; +/** Space between a node and its label. */ +const PAD = 6; +const MIN_H = 200; +/** Height per node in the fullest column, so two-line labels have room. */ +const SLOT_H = 44; + +type FlowEntity = EntityConfig & { from?: unknown; to?: unknown }; + +const nodeName = (v: unknown) => (typeof v === 'string' ? v.trim() : ''); + +/** The card's entities as flows; ones without both ends are left out. */ +export function flowsOf(ctx: Pick): Flow[] { + return ctx.items.flatMap((e, index) => { + const own = e.config as FlowEntity; + const from = nodeName(own.from); + const to = nodeName(own.to); + return from && to ? [{ index, from, to, value: e.available ? e.value : null }] : []; + }); +} + +/** + * Each node's colour and each link's. Nodes take the series colours in order of first + * appearance. A link is coloured by its source, or (`auto`) by its target when the source is a + * middle node, as "House → Heat pump" takes the heat pump's colour. A flow's own `color` paints + * its link and the node it is coloured by (the first such flow wins for a node). + */ +export function sankeyColors( + flows: Pick[], + mode: LinkColor, + own: (index: number) => string | undefined, +): { node: Map; link: Map } { + const accepted = acceptFlows(flows); + const receives = new Set(accepted.map((f) => f.to)); + const node = new Map(); + for (const f of accepted) { + for (const name of [f.from, f.to]) if (!node.has(name)) node.set(name, ''); + } + const by = (f: Pick) => + mode === 'target' || (mode === 'auto' && receives.has(f.from)) ? f.to : f.from; + const set = new Set(); + for (const f of accepted) { + const color = own(f.index); + if (color && !set.has(by(f))) { + node.set(by(f), baseColor(color)); + set.add(by(f)); + } + } + [...node.keys()].forEach((name, i) => { + if (!set.has(name)) node.set(name, baseColor(SERIES_COLORS[i % SERIES_COLORS.length])); + }); + const link = new Map(); + for (const f of accepted) { + const color = own(f.index); + link.set(f.index, color ? baseColor(color) : node.get(by(f))!); + } + return { node, link }; +} + +function linkColor(raw: CardConfig): LinkColor { + return (raw.link_color as LinkColor | undefined) ?? 'auto'; +} + +/** Which link the pointer or focus is on, per card. */ +const hovered = new WeakMap(); + +interface Label { + node: SankeyNode; + side: 'left' | 'right' | 'above'; + name: string; + value: string; + /** Centre line for side labels; baseline for labels above. */ + y: number; +} + +const isMiddle = (n: SankeyNode, columns: number) => n.column > 0 && n.column < columns - 1; +const sits = (n: SankeyNode, columns: number): Label['side'] => { + if (n.column === 0) return 'left'; + if (isMiddle(n, columns) && n.in > 0 && n.out > 0) return 'above'; + return 'right'; +}; + +interface Drawing { + width: number; + height: number; + font: number; + layout: SankeyLayout; + labels: Label[]; +} + +/** + * Size the drawing to the card: room either side for the outer columns' labels (at most 30 % of + * the width each; longer names are cut short), a node height scale set by the fullest column, + * and labels moved apart where small nodes sit close together. + */ +export function drawSankey( + flows: Flow[], + width: number, + text: { name: boolean; value: boolean; format(v: number): string }, +): Drawing { + const font = width < 400 ? 11 : 12; + const line = font + 3; + // A first pass finds the columns and how many nodes the fullest one holds. + const probe = sankeyLayout(flows, { width, height: MIN_H, left: 0, right: 0, nodeWidth: NODE_W, gap: GAP }); + const columns = probe.columns; + const counts = new Map(); + for (const n of probe.nodes) counts.set(n.column, (counts.get(n.column) ?? 0) + 1); + const height = Math.max(MIN_H, Math.max(0, ...counts.values()) * SLOT_H); + const max = width * 0.3; + const words = (n: SankeyNode) => ({ name: text.name ? n.name : '', value: text.value ? text.format(n.value) : '' }); + const gutter = (side: 'left' | 'right') => { + const wanted = probe.nodes + .filter((n) => sits(n, columns) === side && !isMiddle(n, columns)) + .map((n) => Math.max(textWidth(words(n).name, font), textWidth(words(n).value, font))); + return wanted.length && Math.max(...wanted) > 0 ? Math.min(max, Math.max(...wanted)) + PAD + 3 : 2; + }; + const left = gutter('left'); + const right = gutter('right'); + const anyAbove = probe.nodes.some((n) => sits(n, columns) === 'above'); + const top = anyAbove && (text.name || text.value) ? ABOVE : 0; + const layout = sankeyLayout(flows, { + width, + height: height - top, + left, + right, + nodeWidth: NODE_W, + gap: GAP, + above: (n, cols) => (sits(n, cols) === 'above' && top ? ABOVE : 0), + }); + for (const n of layout.nodes) n.y += top; + for (const l of layout.links) { + l.y0 += top; + l.y1 += top; + } + const spacing = columns > 1 ? (width - left - right - NODE_W) / (columns - 1) : width; + const labels: Label[] = []; + if (text.name || text.value) { + for (const n of layout.nodes) { + const side = sits(n, columns); + const w = words(n); + if (side === 'above') { + // Centred over the node: it may reach most of the way to the next column on each side. + const centre = n.x + NODE_W / 2; + const room = Math.min(spacing * 1.6, 2 * centre, 2 * (width - centre)); + const both = textWidth(`${w.name} ${w.value}`, font); + const value = both <= room || !w.name ? w.value : ''; + const name = fitText(w.name, value ? room - textWidth(` ${value}`, font) : room, font); + labels.push({ node: n, side, name, value, y: n.y - PAD }); + continue; + } + const room = isMiddle(n, columns) ? spacing - NODE_W - PAD * 2 : (side === 'left' ? left : right) - PAD - 2; + const value = textWidth(w.value, font) <= room ? w.value : ''; + labels.push({ node: n, side, name: fitText(w.name, room, font), value, y: n.y + n.h / 2 }); + } + // Side labels in one column must not sit on each other. + const size = text.name && text.value ? line * 2 : line; + for (let c = 0; c < columns; c++) { + const col = labels.filter((l) => l.side !== 'above' && l.node.column === c); + spreadLabels( + col.map((l) => l.y), + size, + height, + ).forEach((y, i) => (col[i].y = y)); + } + } + return { width, height, font, layout, labels }; +} + +function labelText(l: Label, font: number): SVGTemplateResult { + const n = l.node; + if (l.side === 'above') { + return svg` + ${l.name}${l.value ? svg`${l.name ? ' ' : ''}${l.value}` : nothing} + `; + } + const x = l.side === 'left' ? n.x - PAD : n.x + NODE_W + PAD; + const anchor = l.side === 'left' ? 'end' : 'start'; + const two = l.name && l.value; + const line = font + 3; + return svg` + ${l.name ? svg`${l.name}` : nothing} + ${l.value ? svg`${l.value}` : nothing} + `; +} + +function tooltip(l: SankeyLink | undefined, d: Drawing, color: string, value: string): TemplateResult | typeof nothing { + if (!l) return nothing; + const x = (l.source.x + NODE_W + l.target.x) / 2; + const y = (l.y0 + l.y1) / 2 - l.width / 2; + const left = Math.min(Math.max(x, 70), d.width - 70); + return html``; +} + +function message(key: string, hass: HomeAssistant) { + return html`
${localize(hass, key)}
`; +} + +function render(ctx: ChartContext): TemplateResult { + const flows = flowsOf(ctx); + if (!flows.length) return message('sankey_no_flows', ctx.hass); + // Unit and decimals come from the first flow, like a total's. + const first = ctx.items[flows[0].index]; + const format = (v: number) => withUnit(formatNumber(v, first.decimals, ctx.hass), first.unit); + const width = Math.max(200, chartWidth(ctx) - 32); + const { show } = ctx.config; + const d = drawSankey(flows, width, { name: show.name, value: show.value, format }); + const { layout } = d; + if (!layout.links.length) return message('sankey_nothing', ctx.hass); + const colors = sankeyColors(flows, linkColor(ctx.raw), (i) => { + const c = (ctx.items[i].config as FlowEntity).color; + return typeof c === 'string' && c ? c : undefined; + }); + const active = hovered.get(ctx.host); + const hover = (index: number | undefined) => () => { + if (index === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, index); + ctx.requestUpdate(); + }; + const dark = ctx.hass.themes?.darkMode === true; + const describe = (l: SankeyLink) => + localize(ctx.hass, 'sankey_flow', { from: l.flow.from, to: l.flow.to, value: format(l.value) }); + const label = localize(ctx.hass, 'sankey_aria', { flows: layout.links.map(describe).join(', ') }); + const shown = layout.links.find((l) => l.flow.index === active); + + const links = layout.links.map((l) => { + const e = ctx.items[l.flow.index]; + const interactive = isInteractive(e.config); + return svg` + ${describe(l)} + + `; + }); + const nodes = layout.nodes.map( + (n) => + svg``, + ); + return html`
+ + ${links} + ${nodes} + + + ${tooltip(shown, d, shown ? colors.link.get(shown.flow.index)! : '', shown ? format(shown.value) : '')} +
`; +} + +const FIELDS: FieldBinding[] = [{ name: 'link_color', path: ['link_color'], default: 'auto' }]; + +export function sankeyEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema('sankey', ctx.hass, () => [ + { name: 'link_color', selector: choice(ctx.hass, 'link_color', LINK_COLORS) }, + ]); + return chartForm(ctx, 'chart', schema, FIELDS); +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'from', path: ['from'], default: '' }, + { name: 'to', path: ['to'], default: '' }, + { name: 'flow_color', path: ['color'] }, +]; +const ENTITY_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'from', selector: { text: {} } }, + { name: 'to', selector: { text: {} } }, + ], + }, + { name: 'flow_color', selector: { text: {} } }, +]; + +/** Readable errors for the options this style reads, including flows that go round in a loop. */ +export function validateSankey(raw: CardConfig): void { + if (raw.link_color !== undefined && !LINK_COLORS.includes(raw.link_color as LinkColor)) { + throw new Error(`"link_color" must be one of: ${LINK_COLORS.join(', ')}`); + } + const pairs: [string, string][] = []; + (raw.entities ?? []).forEach((e, i) => { + if (typeof e !== 'object' || e === null) return; + const own = e as FlowEntity; + for (const key of ['from', 'to'] as const) { + if (own[key] !== undefined && typeof own[key] !== 'string') { + throw new Error(`Entity ${i + 1}: "${key}" must be a node name`); + } + } + const from = nodeName(own.from); + const to = nodeName(own.to); + if (from && to && from === to) throw new Error(`Entity ${i + 1}: "from" and "to" must be different nodes`); + if (from && to) pairs.push([from, to]); + }); + const loop = findCycle(pairs); + if (loop) throw new Error(`Flows can't go round in a loop: ${loop.join(' → ')}`); +} + +export const sankey: ChartStyleModule = { + layout: 'chart', + group: 'totals', + height: 220, + styles: css` + ${frameStyles} + ${tooltipStyles} + .sankey-wrap { + position: relative; + } + .sankey { + display: block; + width: 100%; + height: auto; + overflow: visible; + } + .sankey .link { + fill-opacity: 0.35; + transition: + fill-opacity 0.15s ease, + opacity var(--cd-duration) var(--cd-easing); + } + .sankey.dark .link { + fill-opacity: 0.45; + } + .sankey.hovering .link { + fill-opacity: 0.2; + } + .sankey .flow:hover .link, + .sankey .flow:focus-visible .link, + .sankey .flow.active .link { + fill-opacity: 0.75; + } + .sankey .flow { + cursor: pointer; + outline: none; + } + .sankey .flow:focus-visible { + box-shadow: none; + } + .sankey .flow:focus-visible .link { + fill-opacity: 0.85; + } + :host(:not([ready])) .sankey .link { + opacity: 0; + } + .sankey .node { + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .sankey .node { + opacity: 0; + } + :host([static]) .sankey .node { + transition: none; + } + .sankey .label { + pointer-events: none; + } + .sankey .name { + fill: var(--primary-text-color); + } + .sankey .value { + fill: var(--secondary-text-color); + } + .sankey-tip { + pointer-events: none; + white-space: nowrap; + max-width: none; + transform: translate(-50%, calc(-100% - 6px)); + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate: validateSankey, + editor: sankeyEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + stopChartWidth(host); + hovered.delete(host); + }, + render, +}; diff --git a/src/types.ts b/src/types.ts index cc98df2..84fdedd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/sankey.test.ts b/test/sankey.test.ts new file mode 100644 index 0000000..070e566 --- /dev/null +++ b/test/sankey.test.ts @@ -0,0 +1,411 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import '../src/chartdeck-card'; +import { + acceptFlows, + findCycle, + fitText, + flowValue, + linkPath, + nodeColumns, + sankeyLayout, + spreadLabels, + textWidth, +} from '../src/chart/sankey'; +import type { Flow } from '../src/chart/sankey'; +import { drawSankey, sankeyColors, validateSankey } from '../src/styles/sankey'; +import { STYLES, isChart } from '../src/styles/index'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const flow = (index: number, from: string, to: string, value: number | null): Flow => ({ index, from, to, value }); + +/** The mockup's house: solar and grid in, battery and export out of solar, circuits out of the house. */ +const HOUSE: Flow[] = [ + flow(0, 'Solar', 'House', 3), + flow(1, 'Solar', 'Battery', 0.4), + flow(2, 'Solar', 'Export', 0.7), + flow(3, 'Grid', 'House', 0.9), + flow(4, 'House', 'Heat pump', 1.6), + flow(5, 'House', 'EV', 1.2), + flow(6, 'House', 'Kitchen', 0.7), + flow(7, 'House', 'Other', 0.4), +]; + +const OPTS = { width: 400, height: 200, left: 50, right: 60, nodeWidth: 10, gap: 10 }; + +describe('sankey maths', () => { + it('counts nothing flowing as 0', () => { + expect([flowValue(2), flowValue(0), flowValue(-3), flowValue(null), flowValue(NaN)]).toEqual([2, 0, 0, 0, 0]); + }); + + it('leaves out flows without both ends, to themselves, or closing a loop', () => { + const kept = acceptFlows([ + { from: 'A', to: 'B' }, + { from: '', to: 'B' }, + { from: 'B', to: 'B' }, + { from: 'B', to: 'C' }, + { from: 'C', to: 'A' }, + { from: 'A', to: 'C' }, + ]); + expect(kept.map((f) => `${f.from}${f.to}`)).toEqual(['AB', 'BC', 'AC']); + }); + + it('finds a loop in from/to pairs', () => { + expect( + findCycle([ + ['A', 'B'], + ['B', 'C'], + ]), + ).toBeUndefined(); + expect( + findCycle([ + ['A', 'B'], + ['B', 'C'], + ['C', 'A'], + ]), + ).toEqual(['A', 'B', 'C', 'A']); + expect( + findCycle([ + ['X', 'Y'], + ['A', 'B'], + ['B', 'A'], + ]), + ).toEqual(['A', 'B', 'A']); + expect(findCycle([['A', 'A']])).toEqual(['A', 'A']); + }); + + it('puts each node in the column of its longest path from a source', () => { + const cols = nodeColumns(HOUSE); + expect(Object.fromEntries(cols)).toEqual({ + Solar: 0, + Grid: 0, + House: 1, + Battery: 1, + Export: 1, + 'Heat pump': 2, + EV: 2, + Kitchen: 2, + Other: 2, + }); + // A shortcut doesn't pull a node back: C is reached through B as well. + expect(nodeColumns([flow(0, 'A', 'C', 1), flow(1, 'A', 'B', 1), flow(2, 'B', 'C', 1)]).get('C')).toBe(2); + }); + + it('values each node at the larger of what comes in and what goes out', () => { + const { nodes, columns } = sankeyLayout(HOUSE, OPTS); + expect(columns).toBe(3); + const house = nodes.find((n) => n.name === 'House')!; + expect(house.in).toBeCloseTo(3.9); + expect(house.out).toBeCloseTo(3.9); + expect(nodes.find((n) => n.name === 'Solar')!.value).toBeCloseTo(4.1); + expect(nodes.find((n) => n.name === 'Other')!.value).toBeCloseTo(0.4); + const lop = sankeyLayout([flow(0, 'A', 'B', 5), flow(1, 'B', 'C', 2)], OPTS).nodes.find((n) => n.name === 'B')!; + expect(lop.value).toBe(5); + }); + + it('scales heights by the fullest column, centres columns and spaces them across the width', () => { + const { nodes } = sankeyLayout(HOUSE, OPTS); + const col = (c: number) => nodes.filter((n) => n.column === c); + // Middle column: 3.9 + 0.4 + 0.7 = 5, with two gaps; it fills the height exactly. + const mid = col(1); + const scale = (200 - 20) / 5; + expect(mid[0].y).toBeCloseTo(0); + expect(mid[0].h).toBeCloseTo(3.9 * scale); + expect(mid[2].y + mid[2].h).toBeCloseTo(200); + // Left column: 5.0 tall too, with one gap, so it sits centred. + const left = col(0); + expect(left[0].y).toBeCloseTo(5); + expect(left.map((n) => n.x)).toEqual([50, 50]); + expect(mid[0].x).toBe(50 + (400 - 50 - 60 - 10) / 2); + expect(col(2)[0].x).toBe(400 - 60 - 10); + }); + + it('stacks links at each end in the order of the nodes at their other end', () => { + const { links, nodes } = sankeyLayout(HOUSE, OPTS); + const solar = nodes.find((n) => n.name === 'Solar')!; + const fromSolar = links.filter((l) => l.source === solar).sort((a, b) => a.y0 - b.y0); + expect(fromSolar.map((l) => l.target.name)).toEqual(['House', 'Battery', 'Export']); + expect(fromSolar[0].y0).toBeCloseTo(solar.y + fromSolar[0].width / 2); + expect(fromSolar[1].y0).toBeCloseTo(solar.y + fromSolar[0].width + fromSolar[1].width / 2); + const house = nodes.find((n) => n.name === 'House')!; + const intoHouse = links.filter((l) => l.target === house).sort((a, b) => a.y1 - b.y1); + expect(intoHouse.map((l) => l.source.name)).toEqual(['Solar', 'Grid']); + expect(intoHouse[1].y1).toBeCloseTo(house.y + intoHouse[0].width + intoHouse[1].width / 2); + const w = fromSolar[0].width / 2; + const d = linkPath(fromSolar[0], 10); + expect(d.startsWith(`M60,${Math.round((fromSolar[0].y0 - w) * 10) / 10}C125,`)).toBe(true); + expect(d).toMatch(/ 190,[\d.]+L190,[\d.]+C.* 60,[\d.]+Z$/); + }); + + it('drops zero, negative and missing flows, and closes up an emptied column', () => { + const { nodes, links, columns } = sankeyLayout( + [flow(0, 'A', 'B', 0), flow(1, 'B', 'C', 2), flow(2, 'B', 'D', -1), flow(3, 'B', 'E', null)], + OPTS, + ); + expect(links.map((l) => l.flow.index)).toEqual([1]); + expect(nodes.map((n) => [n.name, n.column])).toEqual([ + ['B', 0], + ['C', 1], + ]); + expect(columns).toBe(2); + }); + + it('ignores a flow that would make a loop instead of failing', () => { + const { links } = sankeyLayout([flow(0, 'A', 'B', 1), flow(1, 'B', 'A', 1)], OPTS); + expect(links.map((l) => l.flow.index)).toEqual([0]); + expect(sankeyLayout([], OPTS)).toEqual({ nodes: [], links: [], columns: 0 }); + }); + + it('leaves room above a node when asked', () => { + const { nodes } = sankeyLayout([flow(0, 'A', 'B', 1), flow(1, 'A', 'C', 1)], { + ...OPTS, + above: (n) => (n.name === 'C' ? 20 : 0), + }); + const [b, c] = nodes.filter((n) => n.column === 1); + expect(c.y - (b.y + b.h)).toBeCloseTo(10 + 20); + }); + + it('fits and spreads labels', () => { + expect(textWidth('abcd', 10)).toBeCloseTo(23.2); + expect(fitText('Heat pump', 100, 12)).toBe('Heat pump'); + expect(fitText('Heat pump', 30, 12)).toBe('Hea…'); + expect(fitText('Heat pump', 5, 12)).toBe(''); + expect(spreadLabels([50, 55, 200], 20, 300)).toEqual([50, 70, 200]); + expect(spreadLabels([290, 295], 20, 300)).toEqual([270, 290]); + expect(spreadLabels([0], 20, 300)).toEqual([10]); + }); +}); + +describe('sankey drawing', () => { + const format = (v: number) => `${v.toFixed(1)} kW`; + + it('puts labels left, right and above the middle node, and cuts long names on a narrow card', () => { + const d = drawSankey(HOUSE, 500, { name: true, value: true, format }); + const side = Object.fromEntries(d.labels.map((l) => [l.node.name, l.side])); + expect(side).toMatchObject({ Solar: 'left', House: 'above', Battery: 'right', 'Heat pump': 'right' }); + expect(d.labels.find((l) => l.node.name === 'House')!.value).toBe('3.9 kW'); + expect(d.height).toBeGreaterThanOrEqual(200); + const long = [flow(0, 'A very long source name indeed', 'Somewhere far away from here', 1)]; + const narrow = drawSankey(long, 240, { name: true, value: true, format }); + const names = narrow.labels.map((l) => l.name); + expect(names.every((n) => n.endsWith('…'))).toBe(true); + for (const l of narrow.labels) expect(textWidth(l.name, narrow.font)).toBeLessThanOrEqual(240 * 0.3); + }); + + it('draws no labels when names and values are hidden', () => { + const d = drawSankey(HOUSE, 500, { name: false, value: false, format }); + expect(d.labels).toEqual([]); + expect(d.layout.nodes[0].x).toBe(2); + }); + + it('colours nodes in order, and links by source or, out of a middle node, by target', () => { + const { node, link } = sankeyColors(HOUSE, 'auto', () => undefined); + expect(node.get('Solar')).toContain('--primary-color'); + expect(node.get('House')).toContain('--warning-color'); + expect(link.get(0)).toBe(node.get('Solar')); + expect(link.get(4)).toBe(node.get('Heat pump')); + expect(sankeyColors(HOUSE, 'source', () => undefined).link.get(4)).toBe(node.get('House')); + expect(sankeyColors(HOUSE, 'target', () => undefined).link.get(0)).toBe(node.get('House')); + }); + + it("uses a flow's own colour for its link and the node it is coloured by", () => { + const { node, link } = sankeyColors(HOUSE, 'auto', (i) => (i === 4 ? 'red' : i === 0 ? '#123456' : undefined)); + expect(link.get(4)).toContain('--error-color'); + expect(node.get('Heat pump')).toContain('--error-color'); + expect(node.get('Solar')).toBe('#123456'); + expect(link.get(1)).toBe(node.get('Solar')); + expect(link.get(2)).toBe(node.get('Solar')); + }); +}); + +describe('sankey validation', () => { + const v = (raw: Partial) => () => validateSankey({ type: 'x', ...raw } as CardConfig); + + it('accepts flows and the link colour choices', () => { + expect( + v({ + link_color: 'target', + entities: ['sensor.a', { entity: 'sensor.b', from: 'A', to: 'B' }, { entity: 'sensor.c', from: 'B' }], + }), + ).not.toThrow(); + }); + + it('explains what is wrong', () => { + expect(v({ link_color: 'rainbow' })).toThrow('"link_color" must be one of: auto, source, target'); + expect(v({ entities: [{ entity: 'sensor.a', from: 3 }] as CardConfig['entities'] })).toThrow( + 'Entity 1: "from" must be a node name', + ); + expect(v({ entities: [{ entity: 'sensor.a', from: 'A', to: ' A ' }] as CardConfig['entities'] })).toThrow( + 'Entity 1: "from" and "to" must be different nodes', + ); + expect( + v({ + entities: [ + { entity: 'sensor.a', from: 'A', to: 'B' }, + { entity: 'sensor.b', from: 'B', to: 'A' }, + ] as CardConfig['entities'], + }), + ).toThrow("Flows can't go round in a loop: A → B → A"); + }); + + it('is checked when the card is set up', () => { + const card = document.createElement('chartdeck-card') as HTMLElement & { setConfig(c: CardConfig): void }; + expect(() => card.setConfig({ type: 'x', style: 'sankey', link_color: 'x' } as CardConfig)).toThrow(/link_color/); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const hass = (overrides: Record = {}) => + makeHass({ + 'sensor.solar': [overrides.solar ?? '3', { friendly_name: 'Solar to house', unit_of_measurement: 'kW' }], + 'sensor.grid': [overrides.grid ?? '0.9', { friendly_name: 'Grid import', unit_of_measurement: 'kW' }], + 'sensor.heat': [overrides.heat ?? '1.6', { friendly_name: 'Heat pump', unit_of_measurement: 'kW' }], + 'sensor.off': ['unavailable', { friendly_name: 'Wind' }], + }); + +const ENTITIES = [ + { entity: 'sensor.solar', from: 'Solar', to: 'House' }, + { entity: 'sensor.grid', from: 'Grid', to: 'House', color: 'purple' }, + { entity: 'sensor.heat', from: 'House', to: 'Heat pump' }, + { entity: 'sensor.off', from: 'Wind', to: 'House' }, + { entity: 'sensor.missing', from: 'Battery', to: 'House' }, +]; + +async function mount(config: Partial, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'sankey', entities: ENTITIES, ...config } as CardConfig); + card.hass = h; + document.body.appendChild(card); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('sankey card', () => { + it('is a chart in the parts-of-a-total group', () => { + expect(isChart(STYLES.sankey)).toBe(true); + expect(STYLES.sankey.group).toBe('totals'); + }); + + it('draws a band per flow that has something flowing, each one opening its entity', async () => { + const card = await mount({}); + const flows = qa(card, 'g.flow.entity'); + expect(flows.map((f) => f.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(flows[0].getAttribute('tabindex')).toBe('0'); + expect(flows[0].querySelector('title')!.textContent).toBe('Solar to House 3.0 kW'); + expect(flows[1].querySelector('path')!.getAttribute('fill')).toContain('--purple-color'); + // Out of the middle node, the band takes the heat pump's colour. + const heatNode = qa(card, 'rect.node')[3]; + expect(flows[2].querySelector('path')!.getAttribute('fill')).toBe(heatNode.getAttribute('fill')); + expect(qa(card, 'rect.node')).toHaveLength(4); + const svg = q(card, 'svg.sankey')!; + expect(svg.getAttribute('role')).toBe('img'); + expect(svg.getAttribute('aria-label')).toBe( + 'Flow chart: Solar to House 3.0 kW, Grid to House 0.9 kW, House to Heat pump 1.6 kW', + ); + const labels = qa(card, 'text.label').map((t) => t.textContent!.replace(/\s+/g, ' ').trim()); + expect(labels).toEqual(['Solar 3.0 kW', 'House 3.9 kW', 'Grid 0.9 kW', 'Heat pump 1.6 kW']); + }); + + it('raises a band and shows its value while it is hovered or focused', async () => { + const card = await mount({}); + const flow = qa(card, 'g.flow')[2]; + flow.dispatchEvent(new Event('pointerenter')); + await card.updateComplete; + expect(q(card, 'svg.sankey')!.classList.contains('hovering')).toBe(true); + expect(qa(card, 'g.flow')[2].classList.contains('active')).toBe(true); + expect(q(card, '.sankey-tip')!.textContent!.replace(/\s+/g, ' ').trim()).toBe('House → Heat pump 1.6 kW'); + flow.dispatchEvent(new Event('pointerleave')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).toBeNull(); + flow.dispatchEvent(new Event('focus')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).not.toBeNull(); + flow.dispatchEvent(new Event('blur')); + await card.updateComplete; + expect(q(card, '.sankey-tip')).toBeNull(); + }); + + it('treats a negative flow as nothing, and says so when nothing flows at all', async () => { + const card = await mount({}, hass({ heat: '-2' })); + expect(qa(card, 'g.flow')).toHaveLength(2); + document.body.innerHTML = ''; + const idle = await mount({}, hass({ solar: '0', grid: '-1', heat: '0' })); + expect(q(idle, '.chart-message')!.textContent).toBe('Nothing is flowing right now.'); + }); + + it('asks for from and to when no entity has them', async () => { + const card = await mount({ entities: ['sensor.solar'] }); + expect(q(card, '.chart-message')!.textContent).toContain('"Flows from"'); + }); + + it('hides names or values with the Display switches', async () => { + const card = await mount({ show: { value: false } } as Partial); + expect(qa(card, 'text.label tspan.value')).toHaveLength(0); + expect(qa(card, 'text.label tspan.name')).toHaveLength(4); + }); +}); + +describe('sankey editor', () => { + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = makeHass({}); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'sankey', ...config } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + return ed; + } + + it('sets the link colouring in the Chart section, dropping the default', async () => { + const ed = await editor({ entities: ENTITIES } as Partial); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((form.schema as { name: string }[]).map((s) => s.name)).toEqual(['link_color']); + expect(form.data).toEqual({ link_color: 'auto' }); + const changed: CardConfig[] = []; + ed.addEventListener('config-changed', (ev) => changed.push((ev as CustomEvent).detail.config)); + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { link_color: 'target' } } })); + expect(changed.at(-1)!.link_color).toBe('target'); + ed.setConfig(changed.at(-1)!); + await ed.updateComplete; + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { link_color: 'auto' } } })); + expect('link_color' in changed.at(-1)!).toBe(false); + }); + + it("puts from, to and the flow's colour in each entity's options", async () => { + const ed = await editor({ entities: ENTITIES } as Partial); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = list.chartFields as { schema: unknown[]; fields: { name: string; path: string[] }[] }; + expect(fields.fields.map((f) => [f.name, f.path.join('.')])).toEqual([ + ['from', 'from'], + ['to', 'to'], + ['flow_color', 'color'], + ]); + }); +}); From 634cd7c78b6e42cae200074383229f4b63d56e9d Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:20:57 +0000 Subject: [PATCH 22/41] =?UTF-8?q?feat:=20state=20timeline=20of=20when=20ea?= =?UTF-8?q?ch=20entity=20was=20on,=20off,=20home,=20heating=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new history chart, `style: timeline`, for entities whose states are words. Each entity gets a row of coloured bars, one per stretch of time it spent in one state, across the card's period, with the shared time labels, period buttons and hover tooltip (the time, each row's state and how long that stretch lasted). Bars that are wide enough say the state, how long it lasted, or both (`bar_labels`). A legend above lists the states drawn. Rows given the same `group` are drawn together under a heading. A dashed line marks now when the period runs past it. New states are added live as they happen, without reloading. Colours: the card's `state_colors` first (a palette name or any CSS colour), then the theme's `--state---color` when it has one, then defaults for common Home Assistant states (on amber, home green, heat orange, cool blue, …), with quiet states (off, idle, closed, away, docked…) drawn faint grey, and any other word given a colour picked from the word, so it matches on every row. The history loader gains text series: `SeriesRequest.text` keeps the words in `Point.s` instead of turning them into gaps, on load and on live updates; their key ends in `|text`. Number charts get exactly what they did before. `historyFrame` takes a third argument to ask for them. The same loader change is on the climate chart branch. Edge cases: unavailable and unknown are gaps (the empty track shows). A row that is all numbers with more than eight different values is a measurement, not states, and says a line chart suits it instead of drawing a sliver per reading. Statistics keep no states, so `source: statistics` and `group_by` are refused. In the editor's Chart section: the period, text on the bars, time labels, lines down, the tooltip, compact, whether to show the legend, and a list of state colours (add, rename, recolour, remove; a new row starts from a current state). Each entity's options have its group. Closes #25 --- src/chart/frame.ts | 8 +- src/chart/timeline.ts | 213 +++++++++++++ src/editor/timeline-editor.ts | 194 ++++++++++++ src/history/fetch.ts | 29 +- src/history/store.ts | 4 +- src/history/types.ts | 4 + src/styles/index.ts | 2 + src/styles/timeline.ts | 572 ++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/timeline.test.ts | 476 ++++++++++++++++++++++++++++ 10 files changed, 1495 insertions(+), 11 deletions(-) create mode 100644 src/chart/timeline.ts create mode 100644 src/editor/timeline-editor.ts create mode 100644 src/styles/timeline.ts create mode 100644 test/timeline.test.ts diff --git a/src/chart/frame.ts b/src/chart/frame.ts index d9976e7..341a145 100644 --- a/src/chart/frame.ts +++ b/src/chart/frame.ts @@ -100,6 +100,8 @@ export interface FrameOptions { * the card's. */ aggregate?: (entity: ResolvedEntity) => Aggregate | undefined; + /** Keep each state's words (`Point.s`) instead of reading numbers, for charts of states like "on" or "heat". */ + text?: boolean; } /** Load (or reuse) the card's history and pair it with its entities. */ @@ -111,7 +113,11 @@ export function historyFrame(ctx: ChartContext, now = Date.now(), options: Frame const group = parseGrouping(ctx.raw); // Grouped, the chart starts where its first group does: the last 7 days by day from midnight. const range: Range = { start: group ? startOf(group.unit, span.start, zone) : span.start, end: span.end }; - const requests = ctx.items.map((e) => ({ entity: e.entityId, attribute: e.config.attribute })); + const requests = ctx.items.map((e) => ({ + entity: e.entityId, + attribute: e.config.attribute, + ...(options.text ? { text: true } : {}), + })); const store = storeFor(ctx.host, ctx.requestUpdate); const statistics = group && source === 'statistics' diff --git a/src/chart/timeline.ts b/src/chart/timeline.ts new file mode 100644 index 0000000..2efc484 --- /dev/null +++ b/src/chart/timeline.ts @@ -0,0 +1,213 @@ +import { baseColor, onFillText } from '../bands'; +import type { Point, Range } from '../history/types'; + +/** One stretch of time an entity spent in one state. `state` is undefined where nothing was recorded. */ +export interface StateSpan { + start: number; + end: number; + state: string | undefined; +} + +/** States that mean "no reading": drawn as a gap, not a bar. */ +export const GAP_STATES = new Set(['unavailable', 'unknown']); + +/** Whether a span is a gap (nothing recorded, unavailable or unknown). */ +export function isGap(state: string | undefined): boolean { + return state === undefined || state === '' || GAP_STATES.has(state); +} + +/** + * Text points as spans: each state lasts until the next point, the last one until `until`. + * Repeats of the same state (an attribute changed, not the state) join into one span; points + * at or after `until` and empty spans are left out. + */ +export function stateSpans(points: readonly Point[], until: number): StateSpan[] { + const spans: StateSpan[] = []; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.t >= until) break; + const end = Math.min(until, i + 1 < points.length ? points[i + 1].t : until); + if (end <= p.t) continue; + const last = spans[spans.length - 1]; + if (last && last.state === p.s && last.end === p.t) last.end = end; + else spans.push({ start: p.t, end, state: p.s }); + } + return spans; +} + +/** The span covering time `t`, if any. */ +export function spanAt(spans: readonly StateSpan[], t: number): StateSpan | undefined { + return spans.find((s) => t >= s.start && (t < s.end || (t === s.end && s === spans[spans.length - 1]))); +} + +/** + * Colours for common Home Assistant states, palette names or CSS colours. Words not listed get a + * colour picked from the word itself, so one state has the same colour on every row. + */ +export const DEFAULT_STATE_COLORS: Record = { + on: 'amber', + open: 'amber', + opening: 'amber', + closing: 'amber', + home: 'green', + playing: 'green', + charging: 'green', + auto: 'green', + heat_cool: 'green', + heat: '#ff8100', + heating: '#ff8100', + preheating: '#ff8100', + cool: '#2b9af9', + cooling: '#2b9af9', + dry: '#efbd07', + drying: '#efbd07', + fan_only: '#14b8a6', + fan: '#14b8a6', + paused: 'purple', + buffering: 'purple', + detected: 'red', + problem: 'red', + triggered: 'red', + unlocked: 'red', + jammed: 'red', + locked: 'green', + armed_home: 'green', + armed_away: 'green', + armed_night: 'green', + armed_vacation: 'green', + armed_custom_bypass: 'green', + arming: 'amber', + pending: 'amber', + cleaning: 'primary', + returning: 'purple', +}; + +/** States that mean "nothing happening": drawn faint, so the active ones stand out. */ +export const QUIET_STATES = new Set([ + 'off', + 'closed', + 'not_home', + 'away', + 'idle', + 'standby', + 'docked', + 'clear', + 'disarmed', + 'none', + 'false', +]); + +/** Colours for words with no default, in turn by the word. */ +const WORD_COLORS = ['primary', 'purple', '#14b8a6', '#ec4899', 'amber', 'green', 'blue', 'red']; + +/** A small stable number for a word. */ +export function wordHash(word: string): number { + let h = 0; + for (let i = 0; i < word.length; i++) h = (h * 31 + word.charCodeAt(i)) >>> 0; + return h; +} + +export interface StateColor { + /** A CSS colour (theme variables included). */ + color: string; + /** Drawn faint: a "nothing happening" state with no colour of its own. */ + quiet: boolean; + /** Text colour for a label on the bar. */ + text: 'dark' | 'light' | 'theme'; +} + +/** + * The colour of one state. The card's own `state_colors` win (a palette name or any CSS + * colour). Otherwise Home Assistant's theme colour for that domain and state is used when the + * theme has one (`--state-light-on-color`, `--state-climate-heat-color`), falling back to the + * defaults above, then grey for a quiet state, then a colour chosen by the word. + */ +export function stateColor(state: string, domain: string, own: Record | undefined): StateColor { + const set = own?.[state]; + if (set) return { color: baseColor(set), quiet: false, text: onFillText(set) }; + const quiet = QUIET_STATES.has(state) && !Object.prototype.hasOwnProperty.call(DEFAULT_STATE_COLORS, state); + const base = Object.prototype.hasOwnProperty.call(DEFAULT_STATE_COLORS, state) + ? DEFAULT_STATE_COLORS[state] + : quiet + ? 'grey' + : WORD_COLORS[wordHash(state) % WORD_COLORS.length]; + const fallback = baseColor(base); + const css = /^[a-z0-9_]+$/.test(state) && /^[a-z0-9_]+$/.test(domain); + return { + color: css ? `var(--state-${domain}-${state}-color, ${fallback})` : fallback, + quiet, + text: quiet ? 'theme' : onFillText(base), + }; +} + +/** A length of time, short: "45 s", "12 min", "3 h 30", "2 d 4 h". */ +export function formatDuration(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + if (h < 24) { + const rest = m - h * 60; + return rest ? `${h} h ${String(rest).padStart(2, '0')}` : `${h} h`; + } + const d = Math.floor(h / 24); + const rest = h - d * 24; + return rest ? `${d} d ${rest} h` : `${d} d`; +} + +/** Rough width in px of a label at the timeline's 11 px font, with its padding. */ +export function labelWidth(text: string): number { + return text.length * 6.2 + 10; +} + +/** Where a span sits across the plot, as fractions of the range (clamped to it). */ +export function spanBox(span: StateSpan, range: Range): { left: number; width: number } { + const total = range.end - range.start; + if (!(total > 0)) return { left: 0, width: 0 }; + const a = Math.max(range.start, span.start); + const b = Math.min(range.end, span.end); + return { left: (a - range.start) / total, width: Math.max(0, b - a) / total }; +} + +/** One row of the timeline: an entity, or a heading over a group of them. */ +export type TimelineRow = { heading: string } | { item: T }; + +/** + * Rows in listed order, with entities that share a `group` brought together under a heading + * where the group is first listed. Entities without a group stay where they are, unheaded. + */ +export function timelineRows(items: readonly T[], groupOf: (item: T) => string | undefined): TimelineRow[] { + const rows: TimelineRow[] = []; + const done = new Set(); + for (const item of items) { + const group = groupOf(item); + if (group === undefined) { + rows.push({ item }); + continue; + } + if (done.has(group)) continue; + done.add(group); + rows.push({ heading: group }); + for (const other of items) if (groupOf(other) === group) rows.push({ item: other }); + } + return rows; +} + +/** More distinct numbers than this and a row is a measurement, not a set of states. */ +export const MAX_NUMBER_STATES = 8; + +/** + * Whether spans look like a measurement (a temperature, a power reading) rather than states: + * every recorded state is a number, and there are more different ones than a few modes would + * give. Those draw as a hint to use a line chart instead of a bar per reading. + */ +export function isMeasurement(spans: readonly StateSpan[]): boolean { + const seen = new Set(); + for (const s of spans) { + if (isGap(s.state)) continue; + if (!Number.isFinite(Number(s.state)) || (s.state as string).trim() === '') return false; + seen.add(s.state as string); + } + return seen.size > MAX_NUMBER_STATES; +} diff --git a/src/editor/timeline-editor.ts b/src/editor/timeline-editor.ts new file mode 100644 index 0000000..9d17754 --- /dev/null +++ b/src/editor/timeline-editor.ts @@ -0,0 +1,194 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_bar_labels: 'Text on the bars', + editor_bar_labels_state: 'State', + editor_bar_labels_duration: 'How long', + editor_bar_labels_both: 'State and how long', + editor_bar_labels_none: 'None', + editor_timeline_legend: 'Legend of states', + editor_timeline_legend_above: 'Above the chart', + editor_timeline_legend_hidden: 'Hidden', + editor_state_colors: 'State colours', + editor_state_colors_help: 'A palette name (amber, green, blue, red, purple, grey, primary) or any CSS colour.', + editor_state_colors_state: 'State', + editor_state_colors_color: 'Colour', + editor_state_colors_add: 'Add a state colour', + editor_state_colors_remove: 'Remove', + editor_timeline_group: 'Group (rows with the same group share a heading)', +}); + +/** A state timeline reads states, which statistics don't keep, and draws them ungrouped. */ +const NOT_FOR_TIMELINE = new Set(['source', 'group_by', 'aggregate']); + +export const TIMELINE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.filter((f) => !NOT_FOR_TIMELINE.has(f.name)), + { name: 'bar_labels', path: ['bar_labels'], default: 'state' }, + { name: 'x_axis', path: ['x_axis'], default: true }, + { name: 'grid_vertical', path: ['gridlines', 'vertical'], default: true }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'timeline_legend', path: ['header'], default: 'above' }, + { name: 'compact', path: ['compact'], default: false }, +]; + +function timelineHistorySchema(hass: HomeAssistant | undefined, config: CardConfig): unknown[] { + type Row = { name?: string; schema?: Row[] }; + return (historySchema(hass, config) as Row[]).filter( + (row) => + !NOT_FOR_TIMELINE.has(row.name ?? '') && !(row.schema ?? []).some((s) => NOT_FOR_TIMELINE.has(s.name ?? '')), + ); +} + +const DELETE_ICON = 'M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z'; +const TEXT = { text: {} }; + +function colorsOf(config: CardConfig): [string, string][] { + const v = config.state_colors; + return v && typeof v === 'object' && !Array.isArray(v) ? Object.entries(v as Record) : []; +} + +/** The config with `state_colors` set to these entries; none removes the key. */ +export function withStateColors(config: CardConfig, entries: [string, string][]): CardConfig { + const next = { ...config }; + const kept = entries.filter(([state]) => state.trim() !== ''); + if (kept.length) next.state_colors = Object.fromEntries(kept.map(([s, c]) => [s.trim(), c])); + else delete next.state_colors; + return next; +} + +/** + * A state for a new row: the first current state of the card's entities that has no colour yet, + * else a common one, so the user mostly just picks a colour. + */ +export function suggestState(config: CardConfig, hass: HomeAssistant | undefined): string { + const taken = new Set(colorsOf(config).map(([s]) => s)); + const ids = (config.entities ?? []).map((e) => (typeof e === 'string' ? e : e.entity)); + const current = ids.map((id) => hass?.states[id]?.state).filter((s): s is string => !!s); + const candidates = [...current, 'on', 'off', 'home', 'not_home', 'heat', 'idle']; + return candidates.find((s) => !taken.has(s) && s !== 'unavailable' && s !== 'unknown') ?? `state_${taken.size + 1}`; +} + +/** One row per coloured state: its name, its colour and a remove button, then an add button. */ +function stateColorsEditor(ctx: ChartEditorContext): TemplateResult { + const entries = colorsOf(ctx.config); + const t = (k: string) => localize(ctx.hass, k); + const set = (next: [string, string][]) => ctx.commit(withStateColors(ctx.config, next)); + const change = (i: number, part: 0 | 1) => (ev: CustomEvent) => { + ev.stopPropagation(); + const value = String(ev.detail?.value ?? ''); + const next = entries.map((e) => [...e] as [string, string]); + if (part === 1 && value.trim() === '') return; + next[i][part] = value; + set(next); + }; + return html`
+ +
${t('editor_state_colors')}
+
${t('editor_state_colors_help')}
+ ${entries.map( + ([state, color], i) => + html`
+ + + + set(entries.filter((_, j) => j !== i))} + > +
`, + )} + +
`; +} + +export function timelineEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`timeline|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...timelineHistorySchema(ctx.hass, ctx.config), + { name: 'bar_labels', selector: choice(ctx.hass, 'bar_labels', ['state', 'duration', 'both', 'none']) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'x_axis', selector: { boolean: {} } }, + { name: 'grid_vertical', selector: { boolean: {} } }, + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + { name: 'timeline_legend', selector: choice(ctx.hass, 'timeline_legend', ['above', 'hidden']) }, + ]); + return html`${chartForm(ctx, 'chart', schema, TIMELINE_FIELDS)}${stateColorsEditor(ctx)}`; +} + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'timeline_group', path: ['group'] }]; + +/** Each entity's group: rows that share one are drawn together under its name. */ +export function timelineEntityFields(hass: HomeAssistant | undefined): EntityFields { + return { + schema: cachedSchema('timeline_entity', hass, () => [{ name: 'timeline_group', selector: { text: {} } }]), + fields: ENTITY_FIELDS, + }; +} diff --git a/src/history/fetch.ts b/src/history/fetch.ts index 1190eb3..cd5ff97 100644 --- a/src/history/fetch.ts +++ b/src/history/fetch.ts @@ -23,14 +23,28 @@ interface CompressedState { lc?: number; } -/** One entity's history (Home Assistant's compressed format) as points, oldest first. */ -export function parseHistory(rows: CompressedState[] | undefined, attribute?: string): Point[] { +/** A state or attribute as a text point's words: numbers as written, anything else (absent, null) as none. */ +export function toText(raw: unknown): string | undefined { + if (typeof raw === 'string') return raw; + return typeof raw === 'number' || typeof raw === 'boolean' ? String(raw) : undefined; +} + +/** One reading as a point: a number, or for a text series its words (with `v` null). */ +export function toPoint(t: number, raw: unknown, text = false): Point { + return text ? { t, v: null, s: toText(raw) } : { t, v: toReading(raw) }; +} + +/** + * One entity's history (Home Assistant's compressed format) as points, oldest first. `text` keeps + * the words instead of a number, for states like "heating" or "idle". + */ +export function parseHistory(rows: CompressedState[] | undefined, attribute?: string, text = false): Point[] { const points: Point[] = []; for (const row of rows ?? []) { const time = row.lu ?? row.lc; if (time === undefined) continue; const raw = attribute ? row.a?.[attribute] : row.s; - points.push({ t: Math.round(time * 1000), v: toReading(raw) }); + points.push(toPoint(Math.round(time * 1000), raw, text)); } return points; } @@ -116,7 +130,9 @@ export async function loadHistory( no_attributes: true, }) .then((result) => { - for (const id of plain) out.set(key({ entity: id }), parseHistory(result?.[id])); + for (const s of series.filter((x) => !x.attribute)) { + out.set(key(s), parseHistory(result?.[s.entity], undefined, s.text)); + } }), ); } @@ -126,7 +142,7 @@ export async function loadHistory( .callWS>({ ...base, entity_ids: withAttributes, no_attributes: false }) .then((result) => { for (const s of series.filter((x) => x.attribute)) { - out.set(key(s), parseHistory(result?.[s.entity], s.attribute)); + out.set(key(s), parseHistory(result?.[s.entity], s.attribute, s.text)); } }), ); @@ -159,5 +175,6 @@ export async function loadStatistics( /** The map key for a series: its entity, plus the attribute when there is one. */ export function key(s: SeriesRequest): string { - return s.attribute ? `${s.entity}|${s.attribute}` : s.entity; + const k = s.attribute ? `${s.entity}|${s.attribute}` : s.entity; + return s.text ? `${k}|text` : k; } diff --git a/src/history/store.ts b/src/history/store.ts index 4e925a4..75631d4 100644 --- a/src/history/store.ts +++ b/src/history/store.ts @@ -1,5 +1,5 @@ import type { HomeAssistant } from '../types'; -import { defaultStatisticsQuery, key, loadHistory, loadStatistics, toReading } from './fetch'; +import { defaultStatisticsQuery, key, loadHistory, loadStatistics, toPoint } from './fetch'; import type { StatisticsQuery } from './fetch'; import type { Point, Range, SeriesRequest, Source } from './types'; @@ -148,7 +148,7 @@ export class HistoryStore { const points = this.data.get(k) ?? []; const last = points[points.length - 1]; if (last && t <= last.t) continue; - points.push({ t, v: toReading(s.attribute ? state.attributes?.[s.attribute] : state.state) }); + points.push(toPoint(t, s.attribute ? state.attributes?.[s.attribute] : state.state, s.text)); this.data.set(k, points); } } diff --git a/src/history/types.ts b/src/history/types.ts index 5d81977..0421dfc 100644 --- a/src/history/types.ts +++ b/src/history/types.ts @@ -10,6 +10,8 @@ export interface Point { carried?: boolean; /** A grouped value: where its group ends (its `t` is where the group starts). */ end?: number; + /** Text series only (`SeriesRequest.text`): the state or attribute as written; `v` is then null. */ + s?: string; } /** Where a chart's readings come from. */ @@ -26,4 +28,6 @@ export interface SeriesRequest { entity: string; /** Read this attribute instead of the state (history only). */ attribute?: string; + /** Keep the words ("heating", "idle") in `Point.s` instead of reading a number (history only). */ + text?: boolean; } diff --git a/src/styles/index.ts b/src/styles/index.ts index febb1e8..3ab32c4 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -39,6 +39,7 @@ import { candlestick } from './candlestick'; import { violin } from './violin'; import { beeswarm } from './beeswarm'; import { sankey } from './sankey'; +import { timeline } from './timeline'; export interface RenderContext { show: ShowOptions; @@ -167,4 +168,5 @@ export const STYLES: Record = { violin, beeswarm, sankey, + timeline, }; diff --git a/src/styles/timeline.ts b/src/styles/timeline.ts new file mode 100644 index 0000000..4f86cad --- /dev/null +++ b/src/styles/timeline.ts @@ -0,0 +1,572 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatDateTime, timeLabel, timeTicks, timeZoneOf, zoneOffset } from '../chart/axes'; +import { disconnectHistory, frameMessage, frameStyles, historyFrame, periodNav } from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartWidth, gridStyles, stopChartWidth } from '../chart/grid'; +import { chartLayout, layoutStyles, validateLayout } from '../chart/layout'; +import { linear } from '../chart/scale'; +import { + formatDuration, + isGap, + isMeasurement, + labelWidth, + spanAt, + spanBox, + stateColor, + stateSpans, + timelineRows, +} from '../chart/timeline'; +import type { StateColor, StateSpan, TimelineRow } from '../chart/timeline'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { timelineEditor, timelineEntityFields } from '../editor/timeline-editor'; +import { parsePeriod } from '../history/period'; +import { localize, registerStrings } from '../localize'; +import type { HassEntity, CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_timeline: 'State timeline', + timeline_no_states: 'No states recorded in this period.', + timeline_for: 'for {duration}', + timeline_now: 'Now', + timeline_measurement: 'Readings, not states: a line chart suits this one', +}); + +/** What each bar says, if it has room. */ +export const BAR_LABELS = ['state', 'duration', 'both', 'none'] as const; +export type BarLabels = (typeof BAR_LABELS)[number]; + +/** Row heights in px: an entity's row, and a group's heading (normal, compact). */ +const ROW_PX = { normal: 30, compact: 22 }; +const HEADING_PX = { normal: 24, compact: 18 }; +/** The names column takes at most this share of the card. */ +const NAMES_SHARE = 0.35; +/** Evenly spaced times the hover guide can rest on, besides the state changes. */ +const HOVER_STEPS = 240; +/** Room each time label needs, in px. */ +const TIME_LABEL_PX = 70; + +type Own = { group?: unknown; state_colors?: unknown }; + +const groupOf = (s: ChartSeries): string | undefined => { + const g = (s.entity.config as Own).group; + return typeof g === 'string' && g.trim() !== '' ? g.trim() : undefined; +}; + +/** Throw a readable error for a bad option. */ +function validate(raw: CardConfig): void { + parsePeriod(raw.period as CardConfig['period']); + if (raw.source !== undefined && raw.source !== 'history') { + throw new Error('"source" must be history: a state timeline reads states, which statistics don\'t keep'); + } + if (raw.group_by !== undefined) throw new Error('"group_by" does not apply to a state timeline'); + if (raw.bar_labels !== undefined && !BAR_LABELS.includes(raw.bar_labels as BarLabels)) { + throw new Error(`"bar_labels" must be one of: ${BAR_LABELS.join(', ')}`); + } + if (raw.x_axis !== undefined && typeof raw.x_axis !== 'boolean') throw new Error('"x_axis" must be true or false'); + const grid = raw.gridlines as Record | undefined; + if (grid !== undefined && (typeof grid !== 'object' || grid === null || Array.isArray(grid))) { + throw new Error('"gridlines" must have "vertical" (true or false)'); + } + if (grid?.vertical !== undefined && typeof grid.vertical !== 'boolean') { + throw new Error('"gridlines.vertical" must be true or false'); + } + checkColors(raw.state_colors, '"state_colors"'); + for (const e of raw.entities ?? []) { + if (typeof e !== 'object') continue; + const own = e as Own; + if (own.group !== undefined && typeof own.group !== 'string') + throw new Error(`"group" of ${e.entity} must be text`); + } + validateTooltipOptions(raw); + validateLayout(raw); +} + +function checkColors(v: unknown, where: string): void { + if (v === undefined) return; + if (typeof v !== 'object' || v === null || Array.isArray(v)) { + throw new Error(`${where} must map states to colours, like "on: amber"`); + } + for (const [state, color] of Object.entries(v)) { + if (typeof color !== 'string' || color.trim() === '') + throw new Error(`${where}: the colour for "${state}" must be text`); + } +} + +/** The card's state colours: its own `state_colors`, plus the editor's list form. */ +export function ownColors(raw: CardConfig): Record | undefined { + const v = raw.state_colors; + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : undefined; +} + +/** A state as Home Assistant words it ("Heat", "Away"), or as recorded when it can't say. */ +function stateText(hass: HomeAssistant, s: ChartSeries, state: string): string { + const stateObj: HassEntity = s.entity.stateObj ?? { entity_id: s.entity.entityId, state, attributes: {} }; + const attribute = s.entity.config.attribute; + try { + if (attribute) return hass.formatEntityAttributeValue?.(stateObj, attribute, state) ?? state; + return hass.formatEntityState?.({ ...stateObj, state }, state) ?? state; + } catch { + return state; + } +} + +interface Row { + series: ChartSeries; + spans: StateSpan[]; + domain: string; + /** All numbers, too many to be states: shown as a hint, not bars. */ + measurement: boolean; +} + +function colorOf(row: Row, state: string, raw: CardConfig): StateColor { + return stateColor(state, row.domain, ownColors(raw)); +} + +/** The states drawn in the period, once each, in the order they first appear, for the legend. */ +function legend(rows: Row[], ctx: ChartContext): TemplateResult | typeof nothing { + // One key per word as shown, first colour wins; quiet states ("Off", "Idle") go last. + const seen = new Map(); + for (const row of rows) { + if (row.measurement) continue; + for (const span of row.spans) { + if (isGap(span.state)) continue; + const label = stateText(ctx.hass, row.series, span.state as string); + if (!seen.has(label)) seen.set(label, { label, color: colorOf(row, span.state as string, ctx.raw) }); + } + } + if (seen.size === 0) return nothing; + const keys = [...seen.values()]; + return html`
+ ${[...keys.filter((k) => !k.color.quiet), ...keys.filter((k) => k.color.quiet)].map( + ({ label, color }) => + html`${label}`, + )} +
`; +} + +function barLabel(mode: BarLabels, text: string, span: StateSpan): string { + const duration = formatDuration(span.end - span.start); + if (mode === 'state') return text; + if (mode === 'duration') return duration; + if (mode === 'both') return `${text} · ${duration}`; + return ''; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function bars(row: Row, frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const mode = (ctx.raw.bar_labels as BarLabels | undefined) ?? 'state'; + return row.spans.map((span) => { + if (isGap(span.state)) return nothing; + const { left, width } = spanBox(span, frame.range); + if (width <= 0) return nothing; + const state = span.state as string; + const color = colorOf(row, state, ctx.raw); + const text = barLabel(mode, stateText(ctx.hass, row.series, state), span); + const fits = text !== '' && labelWidth(text) <= width * plotPx; + return html`
+ ${fits ? html`${text}` : nothing} +
`; + }); +} + +/** The guide line and a box naming each row's state at the hovered time, and for how long. */ +function hoverLayer(rows: Row[], frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const t = hoverTime(ctx.host); + if (t === undefined) return nothing; + const x = (t - frame.range.start) / (frame.range.end - frame.range.start); + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotPx); + return html``; +} + +function timeAxis(frame: HistoryFrame, ctx: ChartContext, plotPx: number) { + const count = Math.max(2, Math.min(8, Math.round(plotPx / TIME_LABEL_PX))); + const offset = zoneOffset(timeZoneOf(ctx.hass)); + const { unit, ticks } = timeTicks(frame.range, count, offset); + const x = linear([frame.range.start, frame.range.end], [0, 1]); + return { unit, ticks, x, offset }; +} + +export const timeline: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 200, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .tl-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding: 0 0 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .tl-key { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .swatch.quiet { + opacity: 0.45; + } + .tl-wrap { + display: grid; + grid-template-columns: fit-content(35%) minmax(0, 1fr); + grid-template-rows: auto auto; + } + .tl-wrap.no-names { + grid-template-columns: 0 minmax(0, 1fr); + } + .tl-names { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 10px; + } + .tl-name, + .tl-heading { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: var(--tl-row); + } + .tl-name { + font-size: 0.875em; + cursor: pointer; + border-radius: 4px; + } + .tl-name span { + overflow: hidden; + text-overflow: ellipsis; + } + .tl-name:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .tl-heading { + height: var(--tl-heading); + font-size: 0.75em; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary-text-color); + } + .tl-plot { + position: relative; + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + .tl-gap { + height: var(--tl-heading); + position: relative; + } + .tl-gap::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.25)); + } + .tl-row { + position: relative; + height: var(--tl-row); + cursor: pointer; + } + .tl-track, + .tl-bar { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 6px; + } + .tl-track { + left: 0; + right: 0; + background: color-mix(in srgb, var(--primary-text-color) 5%, transparent); + } + .tl-bars { + position: absolute; + inset: 0; + clip-path: inset(0 100% 0 0); + transition: clip-path var(--cd-duration) var(--cd-easing); + } + :host([ready]) .tl-bars { + clip-path: inset(0 0 0 0); + } + .tl-bar { + min-width: 2px; + background: var(--tl-color); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-sizing: border-box; + } + .tl-bar.quiet { + background: color-mix(in srgb, var(--tl-color) 30%, transparent); + } + .tl-label { + font-size: 11px; + font-weight: 500; + line-height: 1; + padding: 0 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tl-bar.text-dark .tl-label { + color: #1c1c1c; + } + .tl-bar.text-light .tl-label { + color: #fff; + } + .tl-bar.text-theme .tl-label { + color: var(--primary-text-color); + } + .tl-grid { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.25)); + pointer-events: none; + } + .tl-now { + position: absolute; + top: 0; + bottom: 0; + border-left: 1.5px dashed var(--secondary-text-color); + opacity: 0.7; + pointer-events: none; + } + .tl-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 8px; + font-size: 0.75em; + color: var(--secondary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .tl-for { + font-weight: 400; + color: var(--secondary-text-color); + } + .tl-wrap .x-axis { + position: relative; + grid-column: 2; + grid-row: 2; + height: 18px; + } + .tl-wrap .x-axis .axis-label { + top: 5px; + } + .frame.compact .tl-legend { + padding-bottom: 4px; + font-size: 0.8em; + } + .frame.compact .tl-wrap .x-axis { + height: 14px; + } + .frame.compact .tl-wrap .x-axis .axis-label { + top: 3px; + } + .frame.compact .tl-track, + .frame.compact .tl-bar { + top: 3px; + bottom: 3px; + border-radius: 4px; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate, + editor: timelineEditor, + entityFields: timelineEntityFields, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const now = Date.now(); + const frame = historyFrame(ctx, now, { text: true }); + const layout = chartLayout(ctx.raw); + const compact = layout.compact ? 'compact' : 'normal'; + const nav = periodNav(frame, ctx); + const until = Math.min(frame.range.end, now); + const rows: Row[] = frame.series.map((series) => ({ + series, + spans: stateSpans(series.points, until), + domain: series.entity.entityId.split('.')[0], + measurement: false, + })); + for (const row of rows) row.measurement = isMeasurement(row.spans); + const showLegend = layout.header !== 'hidden'; + const frameBox = (header: unknown, body: TemplateResult) => + html`
${header}${body}
`; + const message = + frame.status !== 'ready' + ? frameMessage(frame, ctx) + : rows.every((r) => r.spans.every((s) => isGap(s.state))) + ? html`
${localize(ctx.hass, 'timeline_no_states')}
` + : undefined; + if (message) return frameBox(nav, message); + + const showNames = ctx.config.show.name; + const width = chartWidth(ctx); + const longest = Math.max(0, ...rows.map((r) => r.series.entity.name.length)); + const namesPx = showNames ? Math.min(width * NAMES_SHARE, longest * 7 + 10) : 0; + const plotPx = Math.max(40, width - 32 - namesPx); + const axis = timeAxis(frame, ctx, plotPx); + const xAxis = ctx.raw.x_axis !== false; + const vertical = (ctx.raw.gridlines as { vertical?: boolean } | undefined)?.vertical ?? true; + const tooltip = tooltipEnabled(ctx.raw); + const byIndex = new Map(rows.map((r) => [r.series.index, r])); + const ordered: TimelineRow[] = timelineRows( + frame.series.map((s) => byIndex.get(s.index) as Row), + (r) => groupOf(r.series), + ); + const span = frame.range.end - frame.range.start; + const showNow = frame.live && frame.range.end > now && now > frame.range.start; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_timeline'), + names: rows.map((r) => r.series.entity.name).join(', '), + }); + const hover = tooltip + ? hoverHandlers(ctx, frame.range, [ + Array.from({ length: HOVER_STEPS + 1 }, (_, i) => ({ + t: frame.range.start + (span * i) / HOVER_STEPS, + v: null, + })), + ...rows.map((r) => r.spans.map((s) => ({ t: s.start, v: null }))), + ]) + : undefined; + const vars = `--tl-row:${ROW_PX[compact]}px;--tl-heading:${HEADING_PX[compact]}px`; + return frameBox( + html`${nav}${showLegend ? legend(rows, ctx) : nothing}`, + html`
+ ${ + showNames + ? html`
+ ${ordered.map((r) => + 'heading' in r + ? html`
${r.heading}
` + : html`
+ ${r.item.series.entity.name} +
`, + )} +
` + : nothing + } + + ${ + xAxis + ? html`
+ ${axis.ticks.map((t) => { + const left = axis.x(t); + const edge = left < 0.05 ? 'start' : left > 0.95 ? 'end' : ''; + return html`${timeLabel(t, axis.unit, span, ctx.hass, axis.offset)}`; + })} +
` + : nothing + } +
`, + ); + }, +}; diff --git a/src/types.ts b/src/types.ts index 84fdedd..14de671 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/timeline.test.ts b/test/timeline.test.ts new file mode 100644 index 0000000..5fb417a --- /dev/null +++ b/test/timeline.test.ts @@ -0,0 +1,476 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + DEFAULT_STATE_COLORS, + formatDuration, + isGap, + isMeasurement, + labelWidth, + spanAt, + spanBox, + stateColor, + stateSpans, + timelineRows, + wordHash, +} from '../src/chart/timeline'; +import { key, loadHistory, parseHistory } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import { suggestState, timelineEntityFields, withStateColors } from '../src/editor/timeline-editor'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const H = 3_600_000; +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const pts = (...rows: [number, string | undefined][]) => rows.map(([t, s]) => ({ t, v: null, s })); + +describe('state spans', () => { + it('lasts each state until the next, joins repeats, and stops at the present', () => { + expect(stateSpans(pts([0, 'off'], [10, 'on'], [20, 'on'], [30, 'off'], [50, 'on']), 40)).toEqual([ + { start: 0, end: 10, state: 'off' }, + { start: 10, end: 30, state: 'on' }, + { start: 30, end: 40, state: 'off' }, + ]); + expect(stateSpans(pts([0, 'on'], [0, 'off']), 5)).toEqual([{ start: 0, end: 5, state: 'off' }]); + expect(stateSpans([], 5)).toEqual([]); + }); + + it('finds the span at a time, the last one including its end', () => { + const spans = stateSpans(pts([0, 'off'], [10, 'on']), 20); + expect(spanAt(spans, 5)?.state).toBe('off'); + expect(spanAt(spans, 10)?.state).toBe('on'); + expect(spanAt(spans, 20)?.state).toBe('on'); + expect(spanAt(spans, 25)).toBeUndefined(); + }); + + it('places a span across the range, clamped to it', () => { + expect(spanBox({ start: 0, end: 50, state: 'on' }, { start: 0, end: 100 })).toEqual({ left: 0, width: 0.5 }); + expect(spanBox({ start: -50, end: 150, state: 'on' }, { start: 0, end: 100 })).toEqual({ left: 0, width: 1 }); + expect(spanBox({ start: 0, end: 5, state: 'on' }, { start: 10, end: 10 })).toEqual({ left: 0, width: 0 }); + }); + + it('treats unavailable, unknown and nothing as gaps', () => { + expect(['unavailable', 'unknown', '', undefined].every(isGap)).toBe(true); + expect(isGap('off')).toBe(false); + }); + + it('spots a measurement: many different numbers, and nothing else', () => { + const many = stateSpans( + pts(...Array.from({ length: 12 }, (_, i): [number, string] => [i, String(20 + i / 10)])), + 12, + ); + expect(isMeasurement(many)).toBe(true); + expect(isMeasurement(stateSpans(pts([0, '1'], [1, '2'], [2, '3']), 3))).toBe(false); + expect(isMeasurement([...many, { start: 12, end: 13, state: 'on' }])).toBe(false); + expect(isMeasurement([...many, { start: 12, end: 13, state: 'unavailable' }])).toBe(true); + }); +}); + +describe('state colours', () => { + it('follows the theme for a known state, falling back to its default', () => { + expect(stateColor('heat', 'climate', undefined)).toEqual({ + color: 'var(--state-climate-heat-color, #ff8100)', + quiet: false, + text: 'light', + }); + expect(stateColor('on', 'switch', undefined).color).toBe( + 'var(--state-switch-on-color, var(--warning-color, #f59e0b))', + ); + expect(stateColor('on', 'switch', undefined).text).toBe('dark'); + }); + + it('draws quiet states faint, and the card’s own colours win', () => { + expect(stateColor('off', 'light', undefined)).toMatchObject({ quiet: true, text: 'theme' }); + expect(stateColor('off', 'light', { off: 'red' })).toEqual({ + color: 'var(--error-color, #ef3b5a)', + quiet: false, + text: 'light', + }); + }); + + it('gives an unknown word a stable colour of its own, and skips the theme lookup for odd words', () => { + const a = stateColor('rinse', 'sensor', undefined); + expect(a).toEqual(stateColor('rinse', 'sensor', undefined)); + expect(a.quiet).toBe(false); + expect(wordHash('rinse')).toBe(wordHash('rinse')); + expect(stateColor('Mode 2', 'sensor', undefined).color).not.toContain('--state-'); + expect(Object.keys(DEFAULT_STATE_COLORS)).toContain('home'); + }); +}); + +describe('durations and rows', () => { + it('writes a length of time short', () => { + expect(formatDuration(45_000)).toBe('45 s'); + expect(formatDuration(12 * 60_000)).toBe('12 min'); + expect(formatDuration(3.5 * H)).toBe('3 h 30'); + expect(formatDuration(2 * H)).toBe('2 h'); + expect(formatDuration(52 * H)).toBe('2 d 4 h'); + expect(formatDuration(48 * H)).toBe('2 d'); + expect(formatDuration(-5)).toBe('0 s'); + expect(labelWidth('Heat')).toBeGreaterThan(30); + }); + + it('brings a group together under a heading where it is first listed', () => { + const items = [ + { id: 'a' }, + { id: 'b', g: 'People' }, + { id: 'c' }, + { id: 'd', g: 'People' }, + { id: 'e', g: 'Cars' }, + ]; + const rows = timelineRows(items, (i) => i.g).map((r) => ('heading' in r ? `#${r.heading}` : r.item.id)); + expect(rows).toEqual(['a', '#People', 'b', 'd', 'c', '#Cars', 'e']); + }); +}); + +describe('loading states as words', () => { + it('keeps the words for a text series and numbers for the rest, in one call', async () => { + const hass = makeHass({}); + hass.callWS = vi.fn(async () => ({ + 'switch.a': [ + { s: 'on', lu: 1 }, + { s: 'off', lu: 2 }, + ], + 'sensor.t': [{ s: '21.5', lu: 1 }], + })) as HomeAssistant['callWS']; + const out = await loadHistory(hass, [{ entity: 'switch.a', text: true }, { entity: 'sensor.t' }], { + start: 0, + end: 5000, + }); + expect(hass.callWS).toHaveBeenCalledTimes(1); + expect(out.get('switch.a|text')).toEqual([ + { t: 1000, v: null, s: 'on' }, + { t: 2000, v: null, s: 'off' }, + ]); + expect(out.get('sensor.t')).toEqual([{ t: 1000, v: 21.5 }]); + expect(key({ entity: 'switch.a', text: true })).toBe('switch.a|text'); + expect(parseHistory([{ s: 'on', lu: 1 }])).toEqual([{ t: 1000, v: null }]); + }); + + it('appends live states as words', async () => { + const hass = makeHass({ 'switch.a': 'on' }); + hass.states['switch.a'].last_updated = new Date(now - H).toISOString(); + hass.callWS = vi.fn(async () => ({ 'switch.a': [{ s: 'off', lu: sec(5 * H) }] })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => undefined); + const req = [{ entity: 'switch.a', text: true }]; + const range = { start: now - 24 * H, end: now }; + store.get(hass, req, range, 'history'); + await new Promise((r) => setTimeout(r, 0)); + hass.states = { ...hass.states, 'switch.a': { ...hass.states['switch.a'], state: 'on' } }; + const points = store.get(hass, req, range, 'history').data.get('switch.a|text')!; + expect(points.map((p) => p.s)).toEqual(['off', 'on']); + }); +}); + +/* ------------------------------------------------------------------ card ------------------------------------------------------------------ */ + +const history = { + 'switch.heater': [ + { s: 'off', lu: sec(20 * H) }, + { s: 'on', lu: sec(12 * H) }, + { s: 'off', lu: sec(8 * H) }, + ], + 'person.alex': [ + { s: 'home', lu: sec(30 * H) }, + { s: 'not_home', lu: sec(10 * H) }, + { s: 'unavailable', lu: sec(6 * H) }, + { s: 'home', lu: sec(2 * H) }, + ], + 'sensor.temp': Array.from({ length: 20 }, (_, i) => ({ s: String(18 + i / 10), lu: sec((20 - i) * H) })), +}; + +function hassWith(rows: Record = history, fail = false) { + const hass = makeHass({ + 'switch.heater': ['off', { friendly_name: 'Heater' }], + 'person.alex': ['home', { friendly_name: 'Alex' }], + 'sensor.temp': ['19.9', { friendly_name: 'Temp', unit_of_measurement: '°C' }], + }); + hass.formatEntityState = (s, state) => { + const v = state ?? s.state; + return ( + ({ on: 'On', off: 'Off', home: 'Home', not_home: 'Away', unavailable: 'Unavailable' } as Record)[ + v + ] ?? v + ); + }; + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return rows; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial = {}, hass = hassWith()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'timeline', + entities: ['switch.heater', 'person.alex'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (e: Element | null) => e!.textContent!.replace(/\s+/g, ' ').trim(); + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('state timeline', () => { + it('draws a row of state bars per entity, with names, a legend and time labels', async () => { + const card = await mount(); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + expect(qa(card, '.tl-name').map(text)).toEqual(['Heater', 'Alex']); + const rows = qa(card, '.tl-row'); + expect(rows).toHaveLength(2); + // Heater: off (carried in), on, off. Alex: away, a gap while unavailable, home. + expect(rows[0].querySelectorAll('.tl-bar')).toHaveLength(3); + expect(rows[1].querySelectorAll('.tl-bar')).toHaveLength(3); + const on = rows[0].querySelectorAll('.tl-bar')[1] as HTMLElement; + expect(on.getAttribute('style')).toContain('--state-switch-on-color'); + expect(on.classList.contains('quiet')).toBe(false); + expect(rows[0].querySelector('.tl-bar')!.classList.contains('quiet')).toBe(true); + expect(qa(card, '.tl-key').map(text)).toEqual(['On', 'Home', 'Off', 'Away']); + expect(qa(card, '.x-axis .axis-label').length).toBeGreaterThan(1); + expect(qa(card, '.tl-grid').length).toBeGreaterThan(1); + expect(q(card, '.tl-plot')!.getAttribute('aria-label')).toBe('State timeline chart of Heater, Alex'); + expect(q(card, '.tl-plot')!.getAttribute('role')).toBe('img'); + }); + + it('labels the bars that have room with the state, how long, or both', async () => { + const labels = async (bar_labels?: string) => + Array.from(qa(await mount(bar_labels ? { bar_labels } : {}), '.tl-row')[0].querySelectorAll('.tl-label')).map( + text, + ); + expect(await labels()).toContain('On'); + expect(await labels('duration')).toContain('4 h'); + expect(await labels('both')).toContain('Off · 8 h'); + expect(await labels('none')).toEqual([]); + }); + + it('groups rows under headings, and uses the card’s own colours', async () => { + const card = await mount({ + state_colors: { on: 'red' }, + entities: ['switch.heater', { entity: 'person.alex', group: 'People' }], + } as Partial); + expect(qa(card, '.tl-heading').map(text)).toEqual(['People']); + expect(qa(card, '.tl-gap')).toHaveLength(1); + expect(qa(card, '.tl-bar')[1].getAttribute('style')).toContain('--tl-color:var(--error-color'); + }); + + it('shows a hint for a measurement, and hides names and legend when asked', async () => { + const card = await mount({ + entities: ['switch.heater', 'sensor.temp'], + header: 'hidden', + show: { name: false }, + compact: true, + x_axis: false, + gridlines: { vertical: false }, + } as Partial); + expect(text(q(card, '.tl-hint'))).toBe('Readings, not states: a line chart suits this one'); + expect(q(card, '.tl-legend')).toBeNull(); + expect(q(card, '.tl-names')).toBeNull(); + expect(q(card, '.tl-wrap')!.classList.contains('no-names')).toBe(true); + expect(q(card, '.x-axis')).toBeNull(); + expect(qa(card, '.tl-grid')).toHaveLength(0); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('marks now when the period runs on past it', async () => { + const card = await mount({ period: { calendar: 'day', until: 'end' } } as Partial); + expect(q(card, '.tl-now')).not.toBeNull(); + const rolling = await mount(); + expect(q(rolling, '.tl-now')).toBeNull(); + }); + + it('shows each row’s state, and for how long, under the pointer', async () => { + const card = await mount(); + const area = q(card, '.tl-plot')!; + expect(area.classList.contains('hoverable')).toBe(true); + // 18 hours ago: the heater was on (12 h to 8 h ago is 4 h), Alex home. + point(area, 'pointermove', 1 - 10 / 24); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map(text); + expect(rows).toEqual(['Heater On for 4 h', 'Alex Away for 4 h']); + point(area, 'pointermove', 1 - 4 / 24); + await card.updateComplete; + expect(qa(card, '.tooltip-row').map(text)[1]).toBe('Alex Unavailable'); + area.dispatchEvent(Object.assign(new MouseEvent('pointerleave', { bubbles: true }), { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const off = await mount({ tooltip: false }); + expect(q(off, '.tl-plot')!.classList.contains('hoverable')).toBe(false); + }); + + it('says when history is loading, failed, or holds no states', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'timeline', entities: ['switch.heater'] } as CardConfig); + card.hass = hassWith(); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({}, hassWith({ 'switch.heater': [{ s: 'unavailable', lu: sec(H) }] })); + expect(text(q(empty, '.chart-message'))).toBe('No states recorded in this period.'); + }); + + it('opens more-info from a name or a row', async () => { + const card = await mount(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + for (const el of [qa(card, '.tl-name')[1], qa(card, '.tl-row')[0]] as HTMLElement[]) { + el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + } + expect(fired).toEqual(['person.alex', 'switch.heater']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'timeline', entities: ['switch.heater'], ...extra } as CardConfig); + expect(set({ source: 'statistics' })).toThrow(/source/); + expect(set({ group_by: 'day' })).toThrow(/group_by/); + expect(set({ bar_labels: 'loud' })).toThrow(/bar_labels/); + expect(set({ x_axis: 'yes' })).toThrow(/x_axis/); + expect(set({ gridlines: 'yes' })).toThrow(/gridlines/); + expect(set({ gridlines: { vertical: 1 } })).toThrow(/gridlines.vertical/); + expect(set({ state_colors: ['red'] })).toThrow(/state_colors/); + expect(set({ state_colors: { on: 3 } })).toThrow(/"on"/); + expect(set({ entities: [{ entity: 'switch.heater', group: 3 }] })).toThrow(/group/); + expect(set({ tooltip: 'no' })).toThrow(/tooltip/); + expect(set({ compact: 'no' })).toThrow(/compact/); + expect(set({ state_colors: { on: 'red' }, bar_labels: 'both', source: 'history' })).not.toThrow(); + }); +}); + +/* ----------------------------------------------------------------- editor ----------------------------------------------------------------- */ + +describe('state timeline editor', () => { + async function editor(config: Partial = {}) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({ 'switch.heater': 'on' }); + const raw = { + type: 'custom:chartdeck-card', + style: 'timeline', + entities: ['switch.heater'], + ...config, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw }; + } + + it('writes the Chart section, without source or grouping', async () => { + const { ed, events, raw } = await editor(); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_calendar: 'rolling', + period_last: 24, + period_unit: 'hour', + period_offset: 0, + period_until: 'now', + period_navigate: false, + bar_labels: 'state', + x_axis: true, + grid_vertical: true, + tooltip: true, + timeline_legend: 'above', + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('"source"'); + expect(names).not.toContain('"group_by"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { ...(form.data as object), bar_labels: 'both', grid_vertical: false, timeline_legend: 'hidden' }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, bar_labels: 'both', gridlines: { vertical: false }, header: 'hidden' }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + expect((list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name)).toEqual(['timeline_group']); + }); + + it('adds, renames, recolours and removes state colours', async () => { + const { ed, events, raw } = await editor(); + const section = () => ed.shadowRoot!.querySelector('.state-colors')!; + (section().querySelector('.sc-add') as HTMLElement).click(); + expect(events.at(-1)).toEqual({ ...raw, state_colors: { on: 'primary' } }); + + const { ed: ed2, events: ev2, raw: raw2 } = await editor({ state_colors: { on: 'amber', off: 'grey' } }); + const rows = [...ed2.shadowRoot!.querySelectorAll('.state-colors .sc-row')]; + expect(rows).toHaveLength(2); + const fire = (el: Element, value: string) => + el.dispatchEvent(new CustomEvent('value-changed', { detail: { value }, bubbles: true })); + fire(rows[0].querySelector('[data-role="color"]')!, 'red'); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { on: 'red', off: 'grey' } }); + fire(rows[1].querySelector('[data-role="state"]')!, 'idle'); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { on: 'amber', idle: 'grey' } }); + const count = ev2.length; + fire(rows[1].querySelector('[data-role="color"]')!, ''); + expect(ev2).toHaveLength(count); + (rows[0].querySelector('.sc-remove') as HTMLElement).click(); + expect(ev2.at(-1)).toEqual({ ...raw2, state_colors: { off: 'grey' } }); + }); + + it('suggests a state for a new colour, and drops the key when the list empties', () => { + const hass = makeHass({ 'switch.a': 'on', 'person.b': 'not_home' }); + const config = { type: 'x', entities: ['switch.a', { entity: 'person.b' }] } as CardConfig; + expect(suggestState(config, hass)).toBe('on'); + expect(suggestState({ ...config, state_colors: { on: 'red' } }, hass)).toBe('not_home'); + const all = Object.fromEntries(['on', 'off', 'home', 'not_home', 'heat', 'idle'].map((s) => [s, 'red'])); + expect(suggestState({ type: 'x', state_colors: all }, undefined)).toBe('state_7'); + expect(withStateColors({ type: 'x', state_colors: { on: 'red' } }, [])).toEqual({ type: 'x' }); + expect( + withStateColors({ type: 'x' }, [ + [' on ', 'red'], + ['', 'blue'], + ]), + ).toEqual({ type: 'x', state_colors: { on: 'red' } }); + expect(timelineEntityFields(undefined).schema).toBe(timelineEntityFields(undefined).schema); + }); +}); From 79c4a5a1cf80cd85469dfdec7fc1f200d180c1d1 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:09:04 +0000 Subject: [PATCH 23/41] feat: climate chart of a thermostat's temperature against its set point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new history chart, `style: climate`, for one climate entity. It draws the room temperature (`current_temperature`) as a line, the set point (`temperature`) as a dashed stepped line held until it changes, and, for heat/cool thermostats, the `target_temp_low` and `target_temp_high` lines with a faint band between them. While the thermostat is running (`hvac_action`) the chart is shaded: heating and preheating in Home Assistant's heat colour, cooling and defrosting in its cool colour, drying in its dry colour, and fan-only when asked. Idle and off are not shaded. The row above shows the room temperature, the set point (or "19.0 – 24.0 °C") and what it is doing now; the hover tooltip shows the same at the guide. It is its own style rather than a line-chart preset because one entity becomes four number series plus a series of words drawn as shaded spans, which the line chart's one-entity-one-series model doesn't fit. It reuses the shared history machinery, so periods, previous/next, axes, gridlines, a fixed value range, the tooltip, compact and header placement all work as on the line chart. All five attributes load in one history call. The loader gains text series: `SeriesRequest.text` keeps the words in `Point.s` instead of turning them into gaps, both on load and when live states are appended. Number series are unchanged; a text series' key ends in `|text`. Edge cases: only the first entity is drawn (one thermostat per chart). An entity that isn't `climate.*` shows its state and says to pick a thermostat. An unavailable one says so in the row above and still draws its past. A set point that disappears (mode switched to off or to heat/cool) ends there. `source: statistics` and `group_by` are rejected, since statistics don't keep attributes. Temperatures use the card's unit and decimals, else Home Assistant's temperature unit and one decimal. In the editor's Chart section: show the set point, shade while heating or cooling, shade while only the fan runs, the line shape and width, and the temperature, set point, heating and cooling colours (empty follows the theme), with the usual period, axes and layout options. Closes #59 --- src/chart/climate-data.ts | 185 ++++++++++++++ src/editor/climate-editor.ts | 92 +++++++ src/styles/climate.ts | 458 +++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/climate.test.ts | 472 +++++++++++++++++++++++++++++++++++ 6 files changed, 1211 insertions(+), 2 deletions(-) create mode 100644 src/chart/climate-data.ts create mode 100644 src/editor/climate-editor.ts create mode 100644 src/styles/climate.ts create mode 100644 test/climate.test.ts diff --git a/src/chart/climate-data.ts b/src/chart/climate-data.ts new file mode 100644 index 0000000..25db0e0 --- /dev/null +++ b/src/chart/climate-data.ts @@ -0,0 +1,185 @@ +import type { Point, SeriesRequest } from '../history/types'; + +/** The attributes a climate entity reports, and what the chart draws from each. */ +export const CLIMATE_ATTRIBUTES = { + current: 'current_temperature', + target: 'temperature', + low: 'target_temp_low', + high: 'target_temp_high', + action: 'hvac_action', +} as const; + +export type ClimatePart = keyof typeof CLIMATE_ATTRIBUTES; +const PARTS = Object.keys(CLIMATE_ATTRIBUTES) as ClimatePart[]; + +/** Everything to load for one thermostat: four numbers and the action's words, in one history call. */ +export function climateRequests(entity: string): Record { + const out = {} as Record; + for (const part of PARTS) { + out[part] = + part === 'action' + ? { entity, attribute: CLIMATE_ATTRIBUTES[part], text: true } + : { entity, attribute: CLIMATE_ATTRIBUTES[part] }; + } + return out; +} + +export type ClimateData = Record; + +/** + * One thermostat's loaded points, each carried on to `until` (the present, or the end of a past + * span): a thermostat holds its readings and its set point until they change. + */ +export function climateData( + loaded: Map, + requests: Record, + until: number, + key: (s: SeriesRequest) => string, +): ClimateData { + const out = {} as ClimateData; + for (const part of PARTS) { + const points = loaded.get(key(requests[part])) ?? []; + const last = points[points.length - 1]; + const held = part === 'action' ? last?.s !== undefined : last?.v !== null && last?.v !== undefined; + out[part] = last && held && last.t < until ? [...points, { ...last, t: until, carried: true }] : points; + } + return out; +} + +/** How a running thermostat is shaded. Idle and off aren't shaded at all. */ +export type ActionKind = 'heat' | 'cool' | 'dry' | 'fan'; +export const ACTION_KINDS: ActionKind[] = ['heat', 'cool', 'dry', 'fan']; + +const KIND_OF: Record = { + heating: 'heat', + preheating: 'heat', + cooling: 'cool', + // A heat pump defrosting runs its cycle backwards: it is taking heat out of the room. + defrosting: 'cool', + drying: 'dry', + fan: 'fan', +}; + +/** The shading for an `hvac_action`; undefined for idle, off, unknown words, and none. */ +export function actionKind(action: string | undefined): ActionKind | undefined { + return action === undefined ? undefined : KIND_OF[action]; +} + +/** A stretch of time the thermostat spent doing one thing. */ +export interface ActionSpan { + kind: ActionKind; + action: string; + start: number; + end: number; +} + +/** + * The stretches spent heating, cooling and so on, from the action's points. An action lasts + * from its point to the next point that says something else; the last one runs on to `end`. + * Only kinds in `kinds` are kept. + */ +export function actionSpans(points: Point[], end: number, kinds: readonly ActionKind[] = ACTION_KINDS): ActionSpan[] { + const out: ActionSpan[] = []; + let open: ActionSpan | undefined; + for (const p of points) { + if (open && p.s === open.action) continue; + if (open) { + open.end = p.t; + if (open.end > open.start) out.push(open); + open = undefined; + } + const kind = actionKind(p.s); + if (kind && kinds.includes(kind)) open = { kind, action: p.s as string, start: p.t, end }; + } + if (open && end > open.start) out.push({ ...open, end: Math.max(open.end, end) }); + return out; +} + +/** The point in effect at `t`: the last one at or before it (points sorted by time). */ +export function valueAt(points: readonly Point[], t: number): Point | undefined { + let lo = 0; + let hi = points.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (points[mid].t <= t) lo = mid + 1; + else hi = mid; + } + return lo > 0 ? points[lo - 1] : undefined; +} + +/** + * A set point as held values: unbroken runs, each value kept until the next reading (so a run + * ends where the set point went away, not at its last reading), with repeats dropped. + */ +export function holdRuns(points: readonly Point[]): { t: number; v: number }[][] { + const out: { t: number; v: number }[][] = []; + let run: { t: number; v: number }[] = []; + const close = (t: number) => { + if (!run.length) return; + const last = run[run.length - 1]; + if (t > last.t) run.push({ t, v: last.v }); + out.push(run); + run = []; + }; + for (const p of points) { + if (p.v === null) { + close(p.t); + continue; + } + const last = run[run.length - 1]; + if (last && last.v === p.v) continue; + run.push({ t: p.t, v: p.v }); + } + const end = points[points.length - 1]; + if (run.length) close(end.t); + return out; +} + +/** Both ends of a heat/cool range at each moment they were both set, as points carrying `[low, high]`. */ +export function rangePoints( + low: readonly Point[], + high: readonly Point[], +): { t: number; v: [number, number] | null }[] { + const highAt = new Map(high.map((p) => [p.t, p.v])); + return low.map((p) => { + const h = highAt.get(p.t); + return { t: p.t, v: p.v !== null && typeof h === 'number' ? [p.v, h] : null }; + }); +} + +type XY = [number, number]; +const f = (n: number) => (Math.round(n * 100) / 100).toString(); + +/** + * The area between the two ends of a heat/cool range, held like a set point: along the top + * (high) from left to right, then back along the bottom (low). One closed shape per unbroken run. + */ +export function rangeBandPath( + points: readonly { t: number; v: [number, number] | null }[], + x: (t: number) => number, + y: (v: number) => number, +): string { + let d = ''; + let run: { t: number; v: [number, number] }[] = []; + const flush = (t: number) => { + if (!run.length) return; + const last = run[run.length - 1]; + if (t > last.t) run.push({ t, v: last.v }); + if (run.length > 1) { + const top: XY[] = run.map((p) => [x(p.t), y(p.v[1])]); + const bottom: XY[] = run.map((p) => [x(p.t), y(p.v[0])]); + d += `M${f(top[0][0])},${f(top[0][1])}`; + for (let i = 1; i < top.length; i++) d += `H${f(top[i][0])}V${f(top[i][1])}`; + d += `V${f(bottom[bottom.length - 1][1])}`; + for (let i = bottom.length - 2; i >= 0; i--) d += `V${f(bottom[i][1])}H${f(bottom[i][0])}`; + d += 'Z'; + } + run = []; + }; + for (const p of points) { + if (p.v === null) flush(p.t); + else run.push({ t: p.t, v: p.v }); + } + if (run.length) flush(points[points.length - 1].t); + return d; +} diff --git a/src/editor/climate-editor.ts b/src/editor/climate-editor.ts new file mode 100644 index 0000000..99777dd --- /dev/null +++ b/src/editor/climate-editor.ts @@ -0,0 +1,92 @@ +import type { TemplateResult } from 'lit'; +import { CURVES } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + axesSchema, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from './chart-editors'; +import { LINE_WIDTH_SELECTOR } from './schemas'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_set_point: 'Show the set point', + editor_shading: 'Shade while heating or cooling', + editor_shade_fan: 'Shade while only the fan runs', + editor_current_color: 'Temperature colour', + editor_set_point_color: 'Set point colour', + editor_heating_color: 'Heating colour', + editor_cooling_color: 'Cooling colour', +}); + +/** Climate charts read attributes, which only history keeps, and draw them ungrouped. */ +const NOT_FOR_CLIMATE = new Set(['source', 'group_by', 'aggregate']); + +export const CLIMATE_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS.filter((f) => !NOT_FOR_CLIMATE.has(f.name)), + { name: 'set_point', path: ['set_point'], default: true }, + { name: 'shading', path: ['shading'], default: true }, + { name: 'shade_fan', path: ['shade_fan'], default: false }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + // Empty colours follow the theme: primary, error, and Home Assistant's heat and cool colours. + { name: 'current_color', path: ['current_color'] }, + { name: 'set_point_color', path: ['set_point_color'] }, + { name: 'heating_color', path: ['heating_color'] }, + { name: 'cooling_color', path: ['cooling_color'] }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The history schema without the source and grouping rows. */ +function climateHistorySchema(ctx: ChartEditorContext): unknown[] { + type Row = { name?: string; schema?: Row[] }; + return (historySchema(ctx.hass, ctx.config) as Row[]).filter( + (row) => !NOT_FOR_CLIMATE.has(row.name ?? '') && !(row.schema ?? []).some((s) => NOT_FOR_CLIMATE.has(s.name ?? '')), + ); +} + +export function climateEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`climate|${historyVariant(ctx.config)}`, ctx.hass, () => [ + ...climateHistorySchema(ctx), + { + type: 'grid', + name: '', + schema: [ + { name: 'set_point', selector: { boolean: {} } }, + { name: 'shading', selector: { boolean: {} } }, + { name: 'shade_fan', selector: { boolean: {} } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'current_color', selector: { text: {} } }, + { name: 'set_point_color', selector: { text: {} } }, + { name: 'heating_color', selector: { text: {} } }, + { name: 'cooling_color', selector: { text: {} } }, + ], + }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, CLIMATE_FIELDS); +} diff --git a/src/styles/climate.ts b/src/styles/climate.ts new file mode 100644 index 0000000..f32c466 --- /dev/null +++ b/src/styles/climate.ts @@ -0,0 +1,458 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatDateTime, validateAxisOptions } from '../chart/axes'; +import { + CLIMATE_ATTRIBUTES, + actionKind, + actionSpans, + climateData, + climateRequests, + holdRuns, + rangeBandPath, + rangePoints, + valueAt, +} from '../chart/climate-data'; +import type { ActionKind, ClimateData } from '../chart/climate-data'; +import { downsample } from '../chart/downsample'; +import { VIEW_H, VIEW_W, disconnectHistory, frameStyles, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotScales } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { runPath, runs } from '../chart/paths'; +import type { Curve } from '../chart/paths'; +import { extent } from '../chart/scale'; +import { DEFAULT_LINE_WIDTH, validateSeriesStyle } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + stopHover, + tooltipEnabled, + tooltipSide, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { climateEditor } from '../editor/climate-editor'; +import { formatNumber, withUnit } from '../format'; +import { key } from '../history/fetch'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, Range } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +/** Room-temperature points kept after thinning: about two per pixel on a wide card. */ +const MAX_POINTS = 500; + +registerStrings('en', { + editor_style_climate: 'Climate', + climate_set_point: 'Set point', + climate_range: '{low} – {high}', + climate_not_thermostat: '{name} is not a thermostat. Pick a climate entity.', + climate_action_heating: 'Heating', + climate_action_preheating: 'Preheating', + climate_action_cooling: 'Cooling', + climate_action_defrosting: 'Defrosting', + climate_action_drying: 'Drying', + climate_action_fan: 'Fan', + climate_action_idle: 'Idle', + climate_action_off: 'Off', +}); + +/** The card's climate options with defaults filled in. */ +export interface ClimateOptions { + setPoint: boolean; + shading: boolean; + shadeFan: boolean; + colors: { current: string; setPoint: string } & Record; +} + +/** Colours for each kind of running, from Home Assistant's own climate theme variables. */ +const ACTION_COLORS: Record = { + heat: 'var(--state-climate-heat-color, #ff8100)', + cool: 'var(--state-climate-cool-color, #2b9af9)', + dry: 'var(--state-climate-dry-color, #efbd07)', + fan: 'var(--state-climate-fan_only-color, #8a8a8a)', +}; + +const BOOLEANS = ['set_point', 'shading', 'shade_fan'] as const; +const COLORS = ['current_color', 'set_point_color', 'heating_color', 'cooling_color'] as const; + +/** Throw a readable error for a bad climate option. */ +export function validateClimateOptions(raw: CardConfig): void { + if (raw.source !== undefined && raw.source !== 'history') { + throw new Error('"source" must be history: long-term statistics don\'t keep a thermostat\'s set point or action'); + } + if (raw.group_by !== undefined) throw new Error('"group_by" is not available on a climate chart'); + for (const k of BOOLEANS) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } + for (const k of COLORS) { + if (raw[k] !== undefined && (typeof raw[k] !== 'string' || raw[k] === '')) { + throw new Error(`"${k}" must be a colour name or CSS colour`); + } + } +} + +export function climateOptions(raw: CardConfig): ClimateOptions { + const color = (k: (typeof COLORS)[number], fallback: string) => baseColor((raw[k] as string | undefined) ?? fallback); + return { + setPoint: raw.set_point !== false, + shading: raw.shading !== false, + shadeFan: raw.shade_fan === true, + colors: { + current: color('current_color', 'primary'), + setPoint: color('set_point_color', 'red'), + heat: color('heating_color', ACTION_COLORS.heat), + cool: color('cooling_color', ACTION_COLORS.cool), + dry: ACTION_COLORS.dry, + fan: ACTION_COLORS.fan, + }, + }; +} + +/** An `hvac_action` in words: Home Assistant's own translation when it has one, else ours. */ +export function actionLabel(hass: HomeAssistant | undefined, entity: ResolvedEntity, action: string): string { + if (entity.stateObj && hass?.formatEntityAttributeValue) { + const text = hass.formatEntityAttributeValue(entity.stateObj, CLIMATE_ATTRIBUTES.action, action); + if (text) return text; + } + const own = localize(hass, `climate_action_${action}`); + return own === `climate_action_${action}` ? action : own; +} + +interface Temps { + unit: string; + decimals: number; + text(v: unknown): string; +} + +/** How this thermostat's temperatures are written: the card's unit and decimals, else HA's unit and one decimal. */ +function temps(entity: ResolvedEntity, hass: HomeAssistant): Temps { + const system = (hass.config as { unit_system?: { temperature?: string } } | undefined)?.unit_system; + const unit = entity.config.unit ?? system?.temperature ?? '°C'; + const decimals = entity.config.decimals ?? 1; + return { + unit, + decimals, + text: (v) => (typeof v === 'number' && Number.isFinite(v) ? withUnit(formatNumber(v, decimals, hass), unit) : '—'), + }; +} + +/** The set point as words: one temperature, or a heat/cool range, or a dash when there's none. */ +function setPointText(t: Temps, target: unknown, low: unknown, high: unknown, hass: HomeAssistant): string { + if (typeof target === 'number') return t.text(target); + if (typeof low === 'number' && typeof high === 'number') { + return localize(hass, 'climate_range', { low: formatNumber(low, t.decimals, hass), high: t.text(high) }); + } + return '—'; +} + +/** Names and values above the chart: room temperature, set point, and what it's doing now. */ +function header(ctx: ChartContext, entity: ResolvedEntity, options: ClimateOptions, t: Temps) { + const { show } = ctx.config; + if (!show.name && !show.value) return nothing; + const attrs = entity.stateObj?.attributes ?? {}; + // A thermostat that isn't reporting, or an entity that isn't one, shows its state instead. + const live = + entity.entityId.startsWith('climate.') && + entity.stateObj !== undefined && + !['unavailable', 'unknown'].includes(entity.stateObj.state); + const action = + typeof attrs[CLIMATE_ATTRIBUTES.action] === 'string' ? (attrs[CLIMATE_ATTRIBUTES.action] as string) : undefined; + const kind = actionKind(action); + const item = (swatch: TemplateResult, name: string, value: string) => + html`
+ ${swatch} ${show.name ? html`${name}` : nothing} + ${show.value ? html`${value}` : nothing} +
`; + return html`
+ ${item( + html``, + entity.name, + live ? t.text(attrs[CLIMATE_ATTRIBUTES.current]) : entity.display, + )} + ${ + options.setPoint && live + ? item( + html``, + localize(ctx.hass, 'climate_set_point'), + setPointText( + t, + attrs[CLIMATE_ATTRIBUTES.target], + attrs[CLIMATE_ATTRIBUTES.low], + attrs[CLIMATE_ATTRIBUTES.high], + ctx.hass, + ), + ) + : nothing + } + ${ + live && action + ? html`
+ + ${actionLabel(ctx.hass, entity, action)} +
` + : nothing + } +
`; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** The guide, a dot on the room temperature, and a box with the temperature, set point and action then. */ +function tooltip( + ctx: ChartContext, + entity: ResolvedEntity, + data: ClimateData, + options: ClimateOptions, + scales: PlotScales, + t: Temps, + at: number | undefined, +) { + if (at === undefined) return nothing; + const x = scales.x(at) / VIEW_W; + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotWidth(ctx)); + const current = valueAt(data.current, at); + const action = valueAt(data.action, at)?.s; + const kind = actionKind(action); + const v = (points: Point[]) => valueAt(points, at)?.v ?? undefined; + const top = current && current.v !== null ? scales.y(current.v) / VIEW_H : -1; + const row = (swatch: TemplateResult, name: string, value: string) => + html`
+ ${swatch}${name}${value} +
`; + return html``; +} + +const message = (text: string, error = false) => html`
${text}
`; + +/** A stepped, dashed set-point line through held values. */ +function setPointPath(points: Point[], scales: PlotScales): string { + return holdRuns(points) + .map((run) => + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v)]), + 'step', + ), + ) + .join(''); +} + +export const climate: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + .style-climate .current { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-climate .set-point { + stroke-dasharray: 6 5; + stroke-width: 2px; + } + .style-climate .range-band { + fill-opacity: 0.08; + } + .style-climate .action-span { + fill-opacity: 0.2; + } + .style-climate .set-point-swatch { + box-sizing: border-box; + height: 0; + border-radius: 0; + border-top: 3px dashed; + } + .style-climate .action-swatch { + border-radius: 2px; + opacity: 0.45; + } + `, + thumbnail: () => svg` + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateSeriesStyle(raw); + validateLayout(raw); + validateClimateOptions(raw); + }, + editor: climateEditor, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render(ctx) { + const layout = chartLayout(ctx.raw); + const options = climateOptions(ctx.raw); + // One thermostat per chart: the first entity. Several rooms are several cards in a stack. + const entity = ctx.items[0]; + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + const zone = zoneFor(ctx.hass); + const now = Date.now(); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const range: Range = { start: span.start, end: span.end }; + const t = temps(entity, ctx.hass); + const thermostat = entity.entityId.startsWith('climate.'); + const head = header(ctx, entity, thermostat ? options : { ...options, setPoint: false }, t); + if (!thermostat) { + return chartFrame( + { ...layout, header: 'above' }, + head, + message(localize(ctx.hass, 'climate_not_thermostat', { name: entity.name })), + ); + } + const requests = climateRequests(entity.entityId); + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, Object.values(requests), range, 'history', { + live: span.live, + }); + const data = climateData(loaded.data, requests, Math.min(range.end, now), key); + const frame: HistoryFrame = { + status: loaded.status, + error: loaded.error, + range, + period, + source: 'history', + series: [], + live: span.live, + back: span.back, + zone, + }; + const nav = periodNav(frame, ctx); + const top = nav === nothing && head === nothing ? nothing : html`${nav}${head}`; + const kinds: ActionKind[] = options.shadeFan ? ['heat', 'cool', 'dry', 'fan'] : ['heat', 'cool', 'dry']; + const spans = options.shading ? actionSpans(data.action, Math.min(range.end, now), kinds) : []; + const numbers = options.setPoint ? [data.current, data.target, data.low, data.high] : [data.current]; + let body: TemplateResult | undefined; + if (loaded.status === 'loading') body = message(localize(ctx.hass, 'chart_loading')); + else if (loaded.status === 'error') { + body = message(localize(ctx.hass, 'chart_error', { error: loaded.error ?? '' }), true); + } else if (numbers.every((p) => p.every((x) => x.v === null)) && spans.length === 0) { + body = message(localize(ctx.hass, 'chart_no_data')); + } + if (body) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, top, body); + + const axes = plotAxes(ctx, range, extent(numbers)); + const { scales } = axes; + const width = (ctx.raw.line_width as number | undefined) ?? DEFAULT_LINE_WIDTH; + const curve = (ctx.raw.curve as Curve | undefined) ?? 'smooth'; + const shading = svg`${spans.map( + (s) => svg``, + )}`; + const range2 = rangePoints(data.low, data.high); + const setPoint = options.setPoint + ? svg` + + ${[data.target, data.low, data.high].map( + (points) => + svg``, + )}` + : nothing; + const current = runs(downsample(data.current, MAX_POINTS)) + .map((run) => + runPath( + run.map((p) => [scales.x(p.t), scales.y(p.v as number)]), + curve, + ), + ) + .join(''); + const content = svg`${shading}${setPoint}`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_climate'), + names: entity.name, + }); + const hover = tooltipEnabled(ctx.raw); + return chartFrame( + layout, + top, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(top) : nothing} + ${hover ? tooltip(ctx, entity, data, options, scales, t, hoverTime(ctx.host)) : nothing}`, + hover: hover + ? hoverHandlers(ctx, range, [data.current, data.target, data.low, data.high, data.action]) + : undefined, + }), + ); + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 3ab32c4..49f4755 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -40,6 +40,7 @@ import { violin } from './violin'; import { beeswarm } from './beeswarm'; import { sankey } from './sankey'; import { timeline } from './timeline'; +import { climate } from './climate'; export interface RenderContext { show: ShowOptions; @@ -169,4 +170,5 @@ export const STYLES: Record = { beeswarm, sankey, timeline, + climate, }; diff --git a/src/types.ts b/src/types.ts index 14de671..8a69eb3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/climate.test.ts b/test/climate.test.ts new file mode 100644 index 0000000..b2f3a55 --- /dev/null +++ b/test/climate.test.ts @@ -0,0 +1,472 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + actionKind, + actionSpans, + climateData, + climateRequests, + holdRuns, + rangeBandPath, + rangePoints, + valueAt, +} from '../src/chart/climate-data'; +import { key, loadHistory, parseHistory, toPoint, toText } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import type { Point } from '../src/history/types'; +import { actionLabel, climateOptions } from '../src/styles/climate'; +import type { ResolvedEntity } from '../src/resolve'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; +const flush = () => new Promise((r) => setTimeout(r, 0)); +const p = (t: number, v: number | null): Point => ({ t, v }); +const s = (t: number, words?: string): Point => ({ t, v: null, s: words }); + +describe('climate data', () => { + it('asks for the four temperatures and the action as words, all from the one entity', () => { + const r = climateRequests('climate.lounge'); + expect(Object.values(r).map((x) => x.attribute)).toEqual([ + 'current_temperature', + 'temperature', + 'target_temp_low', + 'target_temp_high', + 'hvac_action', + ]); + expect(r.action.text).toBe(true); + expect(r.current.text).toBeUndefined(); + expect(key(r.action)).toBe('climate.lounge|hvac_action|text'); + expect(key(r.current)).toBe('climate.lounge|current_temperature'); + }); + + it('keeps words for a text series and numbers for the rest', () => { + expect(toText('heating')).toBe('heating'); + expect(toText(3)).toBe('3'); + expect(toText(true)).toBe('true'); + expect(toText(null)).toBeUndefined(); + expect(toPoint(5, 'heating', true)).toEqual({ t: 5, v: null, s: 'heating' }); + expect(toPoint(5, '21')).toEqual({ t: 5, v: 21 }); + const rows = [ + { s: 'heat', a: { current_temperature: 19.5, hvac_action: 'heating' }, lu: 1 }, + { s: 'unavailable', a: {}, lu: 2 }, + { s: 'heat', a: { current_temperature: 20, hvac_action: 'idle' }, lu: 3 }, + ]; + expect(parseHistory(rows, 'hvac_action', true)).toEqual([ + { t: 1000, v: null, s: 'heating' }, + { t: 2000, v: null, s: undefined }, + { t: 3000, v: null, s: 'idle' }, + ]); + expect(parseHistory(rows, 'current_temperature').map((x) => x.v)).toEqual([19.5, null, 20]); + expect(parseHistory(rows, undefined, true).map((x) => x.s)).toEqual(['heat', 'unavailable', 'heat']); + }); + + it('loads every attribute in one call, and a text state in the compact one', async () => { + const hass = makeHass({}); + const calls: Record[] = []; + hass.callWS = vi.fn(async (msg: Record) => { + calls.push(msg); + return { + 'climate.a': [{ s: 'heat', a: { current_temperature: 20, hvac_action: 'heating' }, lu: 1 }], + 'switch.b': [{ s: 'on', lu: 1 }], + }; + }) as HomeAssistant['callWS']; + const r = climateRequests('climate.a'); + const out = await loadHistory(hass, [...Object.values(r), { entity: 'switch.b', text: true }], { + start: 0, + end: 10_000, + }); + expect(calls).toHaveLength(2); + expect(calls.find((c) => c.no_attributes === false)!.entity_ids).toEqual(['climate.a']); + expect(out.get(key(r.current))).toEqual([{ t: 1000, v: 20 }]); + expect(out.get(key(r.action))).toEqual([{ t: 1000, v: null, s: 'heating' }]); + expect(out.get(key(r.target))).toEqual([{ t: 1000, v: null }]); + expect(out.get('switch.b|text')).toEqual([{ t: 1000, v: null, s: 'on' }]); + }); + + it('appends live attribute changes, as numbers and as words', async () => { + const hass = makeHass({ 'climate.a': ['heat', { current_temperature: 19, hvac_action: 'idle' }] }); + hass.callWS = vi.fn(async () => ({ + 'climate.a': [{ s: 'heat', a: { current_temperature: 19, hvac_action: 'idle' }, lu: 1 }], + })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => {}); + const r = climateRequests('climate.a'); + const series = Object.values(r); + store.get(hass, series, { start: 0, end: 20_000 }, 'history'); + await flush(); + const next = { + ...hass, + states: { + 'climate.a': { + ...hass.states['climate.a'], + attributes: { current_temperature: 19.5, hvac_action: 'heating' }, + last_updated: new Date(15_000).toISOString(), + }, + }, + }; + const loaded = store.get(next, series, { start: 0, end: 20_000 }, 'history'); + expect(loaded.data.get(key(r.current))!.map((x) => x.v)).toEqual([19, 19.5]); + expect(loaded.data.get(key(r.action))!.map((x) => x.s)).toEqual(['idle', 'heating']); + expect(loaded.data.get(key(r.target))!.map((x) => x.v)).toEqual([null, null]); + }); + + it('carries each part on to the present', () => { + const r = climateRequests('climate.a'); + const loaded = new Map([ + [key(r.current), [p(0, 19), p(10, 20)]], + [key(r.target), [p(0, 21), p(10, null)]], + [key(r.action), [s(0, 'heating')]], + ]); + const d = climateData(loaded, r, 50, key); + expect(d.current.at(-1)).toEqual({ t: 50, v: 20, carried: true }); + expect(d.target).toHaveLength(2); + expect(d.action.at(-1)).toEqual({ t: 50, v: null, s: 'heating', carried: true }); + expect(d.low).toEqual([]); + }); + + it('sorts actions into heating, cooling, drying and fan, and idle into nothing', () => { + expect(actionKind('heating')).toBe('heat'); + expect(actionKind('preheating')).toBe('heat'); + expect(actionKind('cooling')).toBe('cool'); + expect(actionKind('defrosting')).toBe('cool'); + expect(actionKind('drying')).toBe('dry'); + expect(actionKind('fan')).toBe('fan'); + expect(actionKind('idle')).toBeUndefined(); + expect(actionKind('off')).toBeUndefined(); + expect(actionKind(undefined)).toBeUndefined(); + }); + + it('finds the stretches spent running, merging repeats, broken by idle and gaps', () => { + const pts = [ + s(0, 'idle'), + s(10, 'heating'), + s(20, 'heating'), + s(30, 'idle'), + s(40, 'cooling'), + s(50), + s(60, 'fan'), + s(70, 'heating'), + ]; + expect(actionSpans(pts, 100)).toEqual([ + { kind: 'heat', action: 'heating', start: 10, end: 30 }, + { kind: 'cool', action: 'cooling', start: 40, end: 50 }, + { kind: 'fan', action: 'fan', start: 60, end: 70 }, + { kind: 'heat', action: 'heating', start: 70, end: 100 }, + ]); + expect(actionSpans(pts, 100, ['heat']).map((x) => x.start)).toEqual([10, 70]); + expect(actionSpans([s(0, 'heating'), s(0, 'idle')], 10)).toEqual([]); + expect(actionSpans([], 10)).toEqual([]); + }); + + it('reads the value in effect at a time', () => { + const pts = [p(10, 1), p(20, 2), p(30, 3)]; + expect(valueAt(pts, 5)).toBeUndefined(); + expect(valueAt(pts, 10)!.v).toBe(1); + expect(valueAt(pts, 25)!.v).toBe(2); + expect(valueAt(pts, 99)!.v).toBe(3); + }); + + it('holds a set point until it changes or goes away, dropping repeats', () => { + expect(holdRuns([p(0, 21), p(10, 21), p(20, 18), p(30, null), p(40, 19), p(50, 19)])).toEqual([ + [ + { t: 0, v: 21 }, + { t: 20, v: 18 }, + { t: 30, v: 18 }, + ], + [ + { t: 40, v: 19 }, + { t: 50, v: 19 }, + ], + ]); + expect(holdRuns([p(0, null)])).toEqual([]); + expect(holdRuns([p(5, 20)])).toEqual([[{ t: 5, v: 20 }]]); + }); + + it('pairs a heat/cool range and draws the area between its ends', () => { + const range = rangePoints([p(0, 19), p(10, 18), p(20, null), p(30, 19)], [p(0, 24), p(10, 25), p(30, null)]); + expect(range.map((x) => x.v)).toEqual([[19, 24], [18, 25], null, null]); + const d = rangeBandPath( + range, + (t) => t, + (v) => v, + ); + // Along the top (high) left to right, down, then back along the bottom (low), held between readings. + expect(d).toBe('M0,24H10V25H20V25V18V18H10V19H0Z'); + expect( + rangeBandPath( + [], + (t) => t, + (v) => v, + ), + ).toBe(''); + }); +}); + +describe('climate options', () => { + it('fills in defaults from the theme and takes colours and switches', () => { + const o = climateOptions({ type: 'x' }); + expect(o).toMatchObject({ setPoint: true, shading: true, shadeFan: false }); + expect(o.colors.current).toContain('--primary-color'); + expect(o.colors.setPoint).toContain('--error-color'); + expect(o.colors.heat).toContain('--state-climate-heat-color'); + expect(o.colors.cool).toContain('--state-climate-cool-color'); + const set = climateOptions({ type: 'x', set_point: false, heating_color: 'red', cooling_color: '#00f' }); + expect(set.setPoint).toBe(false); + expect(set.colors.heat).toContain('--error-color'); + expect(set.colors.cool).toBe('#00f'); + }); + + it("names actions with Home Assistant's words, else its own, else as written", () => { + const entity = { stateObj: { entity_id: 'climate.a', state: 'heat', attributes: {} } } as ResolvedEntity; + expect(actionLabel(makeHass({}), entity, 'heating')).toBe('Heating'); + expect(actionLabel(makeHass({}), entity, 'boosting')).toBe('boosting'); + const hass = makeHass({}, { formatEntityAttributeValue: (_s, _a, v) => `HA ${String(v)}` }); + expect(actionLabel(hass, entity, 'heating')).toBe('HA heating'); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; + +const heatRows = [ + { s: 'heat', a: { current_temperature: 17, temperature: 18, hvac_action: 'heating' }, lu: sec(20 * H) }, + { s: 'heat', a: { current_temperature: 18.5, temperature: 18, hvac_action: 'idle' }, lu: sec(16 * H) }, + { s: 'unavailable', a: {}, lu: sec(12 * H) }, + { s: 'heat', a: { current_temperature: 19, temperature: 21, hvac_action: 'heating' }, lu: sec(8 * H) }, + { s: 'heat', a: { current_temperature: 20.6, temperature: 21, hvac_action: 'fan' }, lu: sec(2 * H) }, +]; +const rangeRows = [ + { + s: 'heat_cool', + a: { current_temperature: 25, target_temp_low: 19, target_temp_high: 24, hvac_action: 'cooling' }, + lu: sec(10 * H), + }, + { + s: 'heat_cool', + a: { current_temperature: 22, target_temp_low: 19, target_temp_high: 24, hvac_action: 'idle' }, + lu: sec(5 * H), + }, +]; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'climate.lounge': [ + 'heat', + { friendly_name: 'Lounge', current_temperature: 20.6, temperature: 21, hvac_action: 'heating' }, + ], + 'climate.office': [ + 'heat_cool', + { + friendly_name: 'Office', + current_temperature: 22, + target_temp_low: 19, + target_temp_high: 24, + hvac_action: 'idle', + }, + ], + 'climate.gone': ['unavailable', { friendly_name: 'Gone' }], + 'sensor.temp': ['20', { friendly_name: 'Kitchen', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWith({ 'climate.lounge': heatRows })) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'climate', + entities: ['climate.lounge'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await flush(); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (e: Element | null) => e!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('climate chart', () => { + it('draws the room temperature, a stepped set point and shading while heating, from one history call', async () => { + const card = await mount({}); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(msg).toMatchObject({ entity_ids: ['climate.lounge'], no_attributes: false }); + const current = q(card, 'path.current')!; + expect(current.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(current.getAttribute('stroke')).toContain('--primary-color'); + const target = qa(card, 'path.set-point')[0]; + expect(target.getAttribute('d')).toMatch(/H.*V/); + expect(target.getAttribute('stroke')).toContain('--error-color'); + const spans = qa(card, 'rect.action-span'); + expect(spans).toHaveLength(2); + expect(spans.every((r) => r.classList.contains('action-heat'))).toBe(true); + expect(qa(card, '.series-item').map(text)).toEqual(['Lounge 20.6 °C', 'Set point 21.0 °C', 'Heating']); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Climate chart of Lounge'); + }); + + it('draws a heat/cool range as two lines with the area between, and shades cooling', async () => { + const card = await mount({ entities: ['climate.office'] }, hassWith({ 'climate.office': rangeRows })); + const lines = qa(card, 'path.set-point').map((e) => e.getAttribute('d')); + expect(lines[0]).toBe(''); + expect(lines[1]).not.toBe(''); + expect(lines[2]).not.toBe(''); + expect(q(card, 'path.range-band')!.getAttribute('d')).toMatch(/Z$/); + expect(qa(card, 'rect.action-cool')).toHaveLength(1); + expect(qa(card, '.series-item').map(text)).toEqual(['Office 22.0 °C', 'Set point 19.0 – 24.0 °C', 'Idle']); + }); + + it('hides the set point, the shading, or shades the fan, as asked', async () => { + const plain = await mount({ set_point: false, shading: false }); + expect(qa(plain, 'path.set-point')).toHaveLength(0); + expect(qa(plain, 'rect.action-span')).toHaveLength(0); + expect(qa(plain, '.series-item').map(text)).toEqual(['Lounge 20.6 °C', 'Heating']); + const fan = await mount({ shade_fan: true, heating_color: 'green', current_color: 'purple' }); + expect(qa(fan, 'rect.action-fan')).toHaveLength(1); + expect(q(fan, 'rect.action-heat')!.getAttribute('fill')).toContain('--success-color'); + expect(q(fan, 'path.current')!.getAttribute('stroke')).toContain('--purple-color'); + }); + + it('says when an entity is not a thermostat, and shows an unavailable one as such', async () => { + const card = await mount({ entities: ['sensor.temp'] }); + expect(text(q(card, '.chart-message'))).toBe('Kitchen is not a thermostat. Pick a climate entity.'); + expect(qa(card, '.series-item').map(text)).toEqual(['Kitchen 20.0 °C']); + const gone = await mount({ entities: ['climate.gone'] }, hassWith({ 'climate.gone': heatRows })); + expect(qa(gone, '.series-item').map(text)).toEqual(['Gone Unavailable']); + expect(q(gone, 'path.current')).not.toBeNull(); + }); + + it('says when history is loading, failed, or empty', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'climate', entities: ['climate.lounge'] } as CardConfig); + card.hass = hassWith({}); + document.body.appendChild(card); + await card.updateComplete; + expect(text(q(card, '.chart-message'))).toBe('Loading history…'); + const failed = await mount({}, hassWith({}, true)); + expect(text(q(failed, '.chart-message.error'))).toBe("Couldn't load history: boom"); + const empty = await mount({ header: 'over' }, hassWith({})); + expect(text(q(empty, '.chart-message'))).toBe('No readings in this period.'); + }); + + it('shows the temperature, set point and action at the guide', async () => { + const card = await mount({}); + const area = q(card, '.plot-area')!; + area.dispatchEvent(new PointerEvent('pointermove', { clientX: 0, bubbles: true })); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map(text); + expect(rows).toEqual(['Lounge17.0 °C', 'Set point18.0 °C', 'Heating']); + expect(q(card, '.hover-dot')).not.toBeNull(); + }); + + it('takes new readings live', async () => { + const hass = hassWith({ 'climate.lounge': heatRows }); + const card = await mount({}, hass); + expect(qa(card, 'rect.action-span')).toHaveLength(2); + card.hass = { + ...hass, + states: { + ...hass.states, + 'climate.lounge': { + ...hass.states['climate.lounge'], + attributes: { ...hass.states['climate.lounge'].attributes, hvac_action: 'heating', current_temperature: 20 }, + last_updated: new Date(now - H).toISOString(), + }, + }, + }; + await card.updateComplete; + expect(qa(card, 'rect.action-span')).toHaveLength(3); + expect(card.hass.callWS).toHaveBeenCalledTimes(1); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'climate', entities: ['climate.lounge'], ...extra } as CardConfig); + expect(set({ source: 'statistics' })).toThrow(/"source" must be history/); + expect(set({ group_by: 'hour' })).toThrow(/group_by/); + expect(set({ set_point: 'yes' })).toThrow(/"set_point" must be true or false/); + expect(set({ heating_color: 3 })).toThrow(/heating_color/); + expect(set({ curve: 'wiggly' })).toThrow(/curve/); + expect(set({ source: 'history', shade_fan: true })).not.toThrow(); + }); +}); + +describe('climate chart editor', () => { + it('shows the Chart section without source or grouping, and writes the climate options', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'climate', entities: ['climate.lounge'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_calendar: 'rolling', + period_last: 24, + period_unit: 'hour', + period_offset: 0, + period_until: 'now', + period_navigate: false, + set_point: true, + shading: true, + shade_fan: false, + curve: 'smooth', + line_width: 2.5, + x_axis: true, + y_axis: true, + axis_labels: 'outside', + grid_horizontal: true, + grid_vertical: false, + tooltip: true, + header: 'above', + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toMatch(/"source"|"group_by"|"aggregate"/); + expect(names).toMatch(/"heating_color"/); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), shading: false, heating_color: 'red', shade_fan: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, shading: false, heating_color: 'red', shade_fan: true }); + const label = (form.computeLabel as (s: { name: string }) => string)({ name: 'set_point_color' }); + expect(label).toBe('Set point colour'); + }); +}); From 37c9902d3edecdf714c6c3ddb98b7efa93e07750 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 20:40:55 +0000 Subject: [PATCH 24/41] feat: wind rose of where the wind comes from, and how hard The new wind rose style (style: wind_rose, under Distributions in the style picker) reads two entities: the first is the wind direction, the second the wind speed. Direction can be degrees or compass points (N, SSW, "north-east", any case); anything else is skipped. Without a speed, or when the speed has no readings, the petals show only how often the wind came from each way. By default it covers the last 30 days of history. Each direction is paired with the speed in effect at the same moment: both sensors hold their value until they change, a new stretch starts whenever either changes, and each stretch counts for as long as it lasted. Counting rows would favour gusty spells, which report more often. Long-term statistics can be used instead (hourly means, with the hour's max as the strongest speed); the editor warns that an hourly mean direction is only right across north for sensors recorded as angles. Readings fall into 16 sectors (or 8 or 4) centred on north, so with 16 the north sector runs from 348.75 to 11.25 degrees. Speeds below calm (0.5 m/s by default, or the same in km/h, mph or knots) count as calm and are left out of the petals. Petal length is the share of the time, stacked from the centre by speed band: 0-3-6-9-12+ m/s, with bands that start at Beaufort 3 to 6 in km/h, mph and knots. Colour bands set in the Colours section replace the speed bands and their colours. The default colours run light to dark from the theme's primary colour, and turn round in a dark theme so strong winds still stand out. Beside the rose (below it on a narrow card) are the prevailing sector and its share, mean speed, strongest speed and calm share, then the speed legend. Hovering or focusing a petal shows its share split by speed band. Tapping a petal opens the direction sensor; tapping the speed figures opens the speed sensor. Petals grow out from the centre, and don't with reduced motion. Compact, previous/next period buttons and the tooltip switch work as on the line chart. The history loader learned to read a series as bearings, so compass text loads and updates live as degrees; other charts are unchanged. --- src/chart/wind.ts | 230 ++++++++++++ src/editor/wind-rose-editor.ts | 101 ++++++ src/history/bearing.ts | 50 +++ src/history/fetch.ts | 38 +- src/history/store.ts | 4 +- src/history/types.ts | 2 + src/styles/index.ts | 2 + src/styles/wind-rose.ts | 646 +++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/wind-rose.test.ts | 613 +++++++++++++++++++++++++++++++ 10 files changed, 1676 insertions(+), 14 deletions(-) create mode 100644 src/chart/wind.ts create mode 100644 src/editor/wind-rose-editor.ts create mode 100644 src/history/bearing.ts create mode 100644 src/styles/wind-rose.ts create mode 100644 test/wind-rose.test.ts diff --git a/src/chart/wind.ts b/src/chart/wind.ts new file mode 100644 index 0000000..114ce40 --- /dev/null +++ b/src/chart/wind.ts @@ -0,0 +1,230 @@ +import { COMPASS_POINTS, normalizeDegrees } from '../history/bearing'; +import type { Point } from '../history/types'; + +/** How many compass sectors a wind rose can have. */ +export const SECTOR_COUNTS = [4, 8, 16] as const; +export type SectorCount = (typeof SECTOR_COUNTS)[number]; + +/** + * The sector a direction falls in. Sector `i` of `n` is centred on `i * 360 / n`, so with 16 the + * north sector runs from 348.75° up to (not including) 11.25°, and 11.25° itself is NNE. + */ +export function sectorOf(degrees: number, n: number): number { + const width = 360 / n; + return Math.floor(normalizeDegrees(degrees) / width + 0.5) % n; +} + +/** A sector's compass name: N, NNE… for 16, N, NE… for 8, N, E, S, W for 4. */ +export function sectorName(i: number, n: number): string { + return COMPASS_POINTS[(i * (16 / n)) % 16]; +} + +/** One stretch of time with a steady direction (and speed, when there is one). */ +export interface WindSample { + /** Degrees, the direction the wind blows from. */ + dir: number; + speed: number | null; + /** How long it held, ms. */ + ms: number; +} + +/** The value a series holds at `t`: its last reading at or before `t`, unless that is too old. */ +function heldAt(points: Point[], i: number, t: number, maxHold: number): number | null { + if (i < 0) return null; + const p = points[i]; + return t - p.t < maxHold ? p.v : null; +} + +/** + * Pair each direction with the speed in effect at the same moment, weighted by how long the pair + * held. Sensors report on change, so both series are step-held: a new sample starts whenever + * either one changes (the union of their change times), and runs until the next change or + * `until`. Counting rows instead would favour gusty spells, which report more often. + * + * A gap (null) in either series drops that stretch. Without a `speed` series every stretch with a + * direction counts, speed null. `maxHold` stops a reading carrying on past it: statistics rows + * describe one period each, so a missing hour is a gap rather than the last hour held. + */ +export function pairWind(dir: Point[], speed: Point[] | undefined, until: number, maxHold = Infinity): WindSample[] { + const times = new Set([until]); + for (const series of speed ? [dir, speed] : [dir]) { + for (const p of series) { + times.add(p.t); + if (Number.isFinite(maxHold)) times.add(p.t + maxHold); + } + } + const sorted = [...times].filter((t) => t <= until).sort((a, b) => a - b); + const out: WindSample[] = []; + let d = -1; + let s = -1; + for (let k = 0; k + 1 < sorted.length; k++) { + const a = sorted[k]; + const ms = sorted[k + 1] - a; + while (d + 1 < dir.length && dir[d + 1].t <= a) d++; + const direction = heldAt(dir, d, a, maxHold); + if (direction === null) continue; + let v: number | null = null; + if (speed) { + while (s + 1 < speed.length && speed[s + 1].t <= a) s++; + v = heldAt(speed, s, a, maxHold); + if (v === null) continue; + } + const last = out[out.length - 1]; + // Neighbours that read the same merge, so a quiet sensor makes one sample, not hundreds. + if (last && last.dir === direction && last.speed === v) last.ms += ms; + else out.push({ dir: direction, speed: v, ms }); + } + return out; +} + +/** Default speed bands and calm limit for a unit, with how the bands are labelled. */ +export interface SpeedDefaults { + /** Where each band starts, lowest first; the last runs on with no top. */ + from: number[]; + /** Below this the air counts as calm. */ + calm: number; +} + +/** + * Speed bands for a sensor's unit. In m/s: 0–3, 3–6, 6–9, 9–12, 12+. In km/h, mph and knots the + * bands start where Beaufort forces 3, 4, 5 and 6 do (a gentle breeze up to a strong breeze), and + * in ft/s they are about the m/s bands. Calm is 0.5 m/s in each unit. Other units (or none) get + * the m/s numbers. + */ +export function speedDefaults(unit: string | undefined): SpeedDefaults { + switch ((unit ?? '').trim().toLowerCase()) { + case 'km/h': + case 'kmh': + case 'kph': + return { from: [0, 12, 20, 29, 39], calm: 1.8 }; + case 'mph': + return { from: [0, 8, 13, 19, 25], calm: 1.1 }; + case 'kn': + case 'kt': + case 'kts': + case 'knot': + case 'knots': + return { from: [0, 7, 11, 17, 22], calm: 1 }; + case 'ft/s': + return { from: [0, 10, 20, 30, 40], calm: 1.6 }; + default: + return { from: [0, 3, 6, 9, 12], calm: 0.5 }; + } +} + +/** Index of the band a speed falls in: the last that starts at or below it (the first if none). */ +export function bandIndex(speed: number, from: readonly number[]): number { + let found = 0; + for (let i = 0; i < from.length; i++) { + if (from[i] <= speed) found = i; + else break; + } + return found; +} + +/** A wind rose's numbers: shares of the time, per sector and speed band. */ +export interface Rose { + /** All the time counted, ms (calm included). */ + total: number; + /** Share of the time calm, 0–1. */ + calm: number; + /** `sectors[i][b]`: share of the time (0–1) the wind blew from sector i in speed band b. */ + sectors: number[][]; + /** Each sector's share, all bands together. */ + totals: number[]; + /** The sector with the most time, or -1 when there is none. */ + prevailing: number; + /** Time-weighted mean speed (calm included), or null without speeds. */ + meanSpeed: number | null; +} + +/** + * Bin samples into `n` sectors and the speed bands starting at `from`. A sample below `calm` (when + * it has a speed) counts towards calm, not a sector: a vane's reading means little in still air, + * and many sensors report 0° (north) then. Samples without a speed all go in band 0. + */ +export function windRose(samples: WindSample[], n: number, from: readonly number[], calm = 0): Rose { + const bands = Math.max(1, from.length); + const ms = Array.from({ length: n }, () => new Array(bands).fill(0)); + let total = 0; + let calmMs = 0; + let speedMs = 0; + let speedSum = 0; + for (const sample of samples) { + total += sample.ms; + if (sample.speed !== null) { + speedMs += sample.ms; + speedSum += sample.speed * sample.ms; + if (sample.speed < calm) { + calmMs += sample.ms; + continue; + } + } + const b = sample.speed === null ? 0 : bandIndex(sample.speed, from); + ms[sectorOf(sample.dir, n)][b] += sample.ms; + } + const share = (v: number) => (total > 0 ? v / total : 0); + const sectors = ms.map((row) => row.map(share)); + const totals = sectors.map((row) => row.reduce((a, b) => a + b, 0)); + let prevailing = -1; + totals.forEach((t, i) => { + if (t > 0 && (prevailing < 0 || t > totals[prevailing])) prevailing = i; + }); + return { + total, + calm: share(calmMs), + sectors, + totals, + prevailing, + meanSpeed: speedMs > 0 ? speedSum / speedMs : null, + }; +} + +/** The highest speed in the readings: a statistics row's max where it has one, else its value. */ +export function strongest(points: Point[]): number | null { + let best: number | null = null; + for (const p of points) { + const v = typeof p.max === 'number' && Number.isFinite(p.max) ? p.max : p.v; + if (v !== null && (best === null || v > best)) best = v; + } + return best; +} + +/** + * Round ring values (in %) for a rose whose longest petal is `max` %: steps of 1, 2, 2.5 or 5 + * times a power of ten, about three rings, the last at or just past the longest petal. + */ +export function ringSteps(max: number): number[] { + if (!(max > 0)) return []; + const raw = max / 3; + const power = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * power).find((s) => s >= raw - 1e-9) ?? 10 * power; + const count = Math.max(1, Math.ceil(max / step - 1e-9)); + return Array.from({ length: count }, (_, i) => Math.round((i + 1) * step * 1000) / 1000); +} + +/* + * Geometry. Angles are radians clockwise from north (12 o'clock), as a compass reads, with the + * centre at (0, 0) and y pointing down as in SVG. These two are the same as `polar` and + * `sectorPath` in the polar area chart's `src/chart/polar.ts`; kept here until the two meet. + */ + +const round = (n: number) => Math.round(n * 100) / 100 || 0; + +/** The point at radius `r` and `angle` from the centre. */ +export function polarPoint(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +/** The SVG path of a ring sector from `a0` to `a1` between radii `r0` and `r1`. */ +export function petalPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= r0 || a1 <= a0) return ''; + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polarPoint(r1, a0); + const [ox1, oy1] = polarPoint(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (r0 <= 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polarPoint(r0, a1); + const [ix0, iy0] = polarPoint(r0, a0); + return `${outer}L${ix1} ${iy1}A${r0} ${r0} 0 ${large} 0 ${ix0} ${iy0}Z`; +} diff --git a/src/editor/wind-rose-editor.ts b/src/editor/wind-rose-editor.ts new file mode 100644 index 0000000..4b38a43 --- /dev/null +++ b/src/editor/wind-rose-editor.ts @@ -0,0 +1,101 @@ +import { html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { SECTOR_COUNTS } from '../chart/wind'; +import { localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_sectors: 'Directions', + editor_sectors_4: '4 (N, E, S, W)', + editor_sectors_8: '8 (N, NE, E…)', + editor_sectors_16: '16 (N, NNE, NE…)', + editor_calm: 'Calm below (blank: 0.5 m/s, or the same in km/h, mph or knots)', + editor_wind_roles: + 'The first entity is the wind direction, in degrees or compass points such as SW. The second is the wind speed: without one, the petals only show how often the wind came from each way.', + editor_wind_now: 'Direction: {direction}. Speed: {speed}.', + editor_wind_none: 'none', + editor_wind_statistics: + "Long-term statistics give each hour's average direction. That is only right across north when the sensor records direction as an angle (state class measurement_angle); otherwise an hour swinging between NNW and NNE averages to south. History is exact.", +}); + +/** The history fields with the wind rose's month as the default span, and no grouping. */ +const PERIOD_FIELDS: FieldBinding[] = HISTORY_FIELDS.filter((f) => f.name !== 'group_by' && f.name !== 'aggregate').map( + (f) => (f.name === 'period_last' ? { ...f, default: 30 } : f.name === 'period_unit' ? { ...f, default: 'day' } : f), +); + +const WIND_FIELDS: FieldBinding[] = [ + ...PERIOD_FIELDS, + { name: 'sectors', path: ['sectors'], default: '16' }, + { name: 'calm', path: ['calm'] }, + { name: 'tooltip', path: ['tooltip'], default: true }, + { name: 'compact', path: ['compact'], default: false }, +]; + +/** ha-form's dropdown hands back text, so sectors are shown as text and written back as a number. */ +function sectorsAsText(config: CardConfig): CardConfig { + return typeof config.sectors === 'number' ? { ...config, sectors: String(config.sectors) } : config; +} + +function sectorsAsNumber(config: CardConfig): CardConfig { + if (typeof config.sectors !== 'string') return config; + const n = Number(config.sectors); + return { ...config, sectors: Number.isFinite(n) ? n : config.sectors }; +} + +function entityName(ctx: ChartEditorContext, raw: CardConfig['entities'] extends (infer E)[] | undefined ? E : never) { + if (raw === undefined) return localize(ctx.hass, 'editor_wind_none'); + const id = typeof raw === 'string' ? raw : raw.entity; + const own = typeof raw === 'string' ? undefined : raw.name; + return own ?? (ctx.hass?.states[id]?.attributes?.friendly_name as string | undefined) ?? id; +} + +/** The wind rose's Chart section: which entity is which, the span, sectors, calm and layout. */ +export function windRoseEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`wind_rose|${historyVariant(ctx.config)}`, ctx.hass, () => [ + // Grouping readings means nothing to a rose: leave its row out. + ...historySchema(ctx.hass, ctx.config).filter( + (field) => !(field as { schema?: { name: string }[] }).schema?.some((f) => f.name === 'group_by'), + ), + { + type: 'grid', + name: '', + schema: [ + { name: 'sectors', selector: choice(ctx.hass, 'sectors', SECTOR_COUNTS.map(String)) }, + { name: 'calm', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'tooltip', selector: { boolean: {} } }, + { name: 'compact', selector: { boolean: {} } }, + ], + }, + ]); + const entities = ctx.config.entities ?? []; + const view: ChartEditorContext = { + ...ctx, + config: sectorsAsText(ctx.config), + commit: (config) => ctx.commit(sectorsAsNumber(config)), + }; + const note = 'margin: 0 0 12px; color: var(--secondary-text-color); font-size: 0.875em; line-height: 1.4;'; + return html`

+ ${localize(ctx.hass, 'editor_wind_roles')} +
${localize(ctx.hass, 'editor_wind_now', { + direction: entityName(ctx, entities[0]), + speed: entityName(ctx, entities[1]), + })} +

+ ${ + ctx.config.source === 'statistics' + ? html`

${localize(ctx.hass, 'editor_wind_statistics')}

` + : nothing + } + ${chartForm(view, 'chart', schema, WIND_FIELDS)}`; +} diff --git a/src/history/bearing.ts b/src/history/bearing.ts new file mode 100644 index 0000000..24da755 --- /dev/null +++ b/src/history/bearing.ts @@ -0,0 +1,50 @@ +/** The 16 compass points, clockwise from north, each 22.5° on from the last. */ +export const COMPASS_POINTS = [ + 'N', + 'NNE', + 'NE', + 'ENE', + 'E', + 'ESE', + 'SE', + 'SSE', + 'S', + 'SSW', + 'SW', + 'WSW', + 'W', + 'WNW', + 'NW', + 'NNW', +] as const; + +const BY_NAME = new Map(COMPASS_POINTS.map((name, i) => [name, i * 22.5])); +const WORDS: [RegExp, string][] = [ + [/north/g, 'n'], + [/south/g, 's'], + [/east/g, 'e'], + [/west/g, 'w'], +]; + +/** Degrees folded into 0 ≤ d < 360 (−90 is 270, 360 is 0). */ +export function normalizeDegrees(d: number): number { + const n = ((d % 360) + 360) % 360; + return n === 360 ? 0 : n; +} + +/** + * A wind direction reading as degrees clockwise from north (0 ≤ d < 360), or null when it can't + * be read. Takes a number of degrees ("225", "225°", -135) or a compass point in any case ("SW", + * "wsw", "North-East", "south west"). Anything else (unavailable, unknown, "variable") is null. + */ +export function parseBearing(raw: unknown): number | null { + if (typeof raw === 'number') return Number.isFinite(raw) ? normalizeDegrees(raw) : null; + if (typeof raw !== 'string') return null; + const text = raw.trim().toLowerCase(); + if (text === '') return null; + const number = Number(text.replace(/\s*°$/, '')); + if (Number.isFinite(number)) return normalizeDegrees(number); + let name = text.replace(/[\s_-]+/g, ''); + for (const [word, letter] of WORDS) name = name.replace(word, letter); + return BY_NAME.get(name.toUpperCase()) ?? null; +} diff --git a/src/history/fetch.ts b/src/history/fetch.ts index cd5ff97..5d45e8c 100644 --- a/src/history/fetch.ts +++ b/src/history/fetch.ts @@ -1,4 +1,5 @@ import type { HomeAssistant } from '../types'; +import { parseBearing } from './bearing'; import type { Point, Range, SeriesRequest } from './types'; /** @@ -23,28 +24,44 @@ interface CompressedState { lc?: number; } +/** How a series' states become numbers: plain readings, or wind bearings that may be compass points. */ +export function readerFor(s: SeriesRequest): (raw: unknown) => number | null { + return s.bearing ? parseBearing : toReading; +} + /** A state or attribute as a text point's words: numbers as written, anything else (absent, null) as none. */ export function toText(raw: unknown): string | undefined { if (typeof raw === 'string') return raw; return typeof raw === 'number' || typeof raw === 'boolean' ? String(raw) : undefined; } -/** One reading as a point: a number, or for a text series its words (with `v` null). */ -export function toPoint(t: number, raw: unknown, text = false): Point { - return text ? { t, v: null, s: toText(raw) } : { t, v: toReading(raw) }; +/** One reading as a point: a number (read with `read`), or for a text series its words (with `v` null). */ +export function toPoint( + t: number, + raw: unknown, + text = false, + read: (raw: unknown) => number | null = toReading, +): Point { + return text ? { t, v: null, s: toText(raw) } : { t, v: read(raw) }; } /** * One entity's history (Home Assistant's compressed format) as points, oldest first. `text` keeps - * the words instead of a number, for states like "heating" or "idle". + * the words instead of a number, for states like "heating" or "idle"; `read` turns a state into a + * number otherwise. */ -export function parseHistory(rows: CompressedState[] | undefined, attribute?: string, text = false): Point[] { +export function parseHistory( + rows: CompressedState[] | undefined, + attribute?: string, + text = false, + read: (raw: unknown) => number | null = toReading, +): Point[] { const points: Point[] = []; for (const row of rows ?? []) { const time = row.lu ?? row.lc; if (time === undefined) continue; const raw = attribute ? row.a?.[attribute] : row.s; - points.push(toPoint(Math.round(time * 1000), raw, text)); + points.push(toPoint(Math.round(time * 1000), raw, text, read)); } return points; } @@ -131,7 +148,7 @@ export async function loadHistory( }) .then((result) => { for (const s of series.filter((x) => !x.attribute)) { - out.set(key(s), parseHistory(result?.[s.entity], undefined, s.text)); + out.set(key(s), parseHistory(result?.[s.entity], undefined, s.text, readerFor(s))); } }), ); @@ -142,7 +159,7 @@ export async function loadHistory( .callWS>({ ...base, entity_ids: withAttributes, no_attributes: false }) .then((result) => { for (const s of series.filter((x) => x.attribute)) { - out.set(key(s), parseHistory(result?.[s.entity], s.attribute, s.text)); + out.set(key(s), parseHistory(result?.[s.entity], s.attribute, s.text, readerFor(s))); } }), ); @@ -175,6 +192,7 @@ export async function loadStatistics( /** The map key for a series: its entity, plus the attribute when there is one. */ export function key(s: SeriesRequest): string { - const k = s.attribute ? `${s.entity}|${s.attribute}` : s.entity; - return s.text ? `${k}|text` : k; + const base = s.attribute ? `${s.entity}|${s.attribute}` : s.entity; + if (s.text) return `${base}|text`; + return s.bearing ? `${base}#bearing` : base; } diff --git a/src/history/store.ts b/src/history/store.ts index 75631d4..15ca7c3 100644 --- a/src/history/store.ts +++ b/src/history/store.ts @@ -1,5 +1,5 @@ import type { HomeAssistant } from '../types'; -import { defaultStatisticsQuery, key, loadHistory, loadStatistics, toPoint } from './fetch'; +import { defaultStatisticsQuery, key, loadHistory, loadStatistics, readerFor, toPoint } from './fetch'; import type { StatisticsQuery } from './fetch'; import type { Point, Range, SeriesRequest, Source } from './types'; @@ -148,7 +148,7 @@ export class HistoryStore { const points = this.data.get(k) ?? []; const last = points[points.length - 1]; if (last && t <= last.t) continue; - points.push(toPoint(t, s.attribute ? state.attributes?.[s.attribute] : state.state, s.text)); + points.push(toPoint(t, s.attribute ? state.attributes?.[s.attribute] : state.state, s.text, readerFor(s))); this.data.set(k, points); } } diff --git a/src/history/types.ts b/src/history/types.ts index 0421dfc..451ded8 100644 --- a/src/history/types.ts +++ b/src/history/types.ts @@ -30,4 +30,6 @@ export interface SeriesRequest { attribute?: string; /** Keep the words ("heating", "idle") in `Point.s` instead of reading a number (history only). */ text?: boolean; + /** Read compass points (N, SSW) as degrees as well as numbers (history only; see `parseBearing`). */ + bearing?: boolean; } diff --git a/src/styles/index.ts b/src/styles/index.ts index 49f4755..90e9229 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -41,6 +41,7 @@ import { beeswarm } from './beeswarm'; import { sankey } from './sankey'; import { timeline } from './timeline'; import { climate } from './climate'; +import { windRoseStyle } from './wind-rose'; export interface RenderContext { show: ShowOptions; @@ -171,4 +172,5 @@ export const STYLES: Record = { sankey, timeline, climate, + wind_rose: windRoseStyle, }; diff --git a/src/styles/wind-rose.ts b/src/styles/wind-rose.ts new file mode 100644 index 0000000..69a6252 --- /dev/null +++ b/src/styles/wind-rose.ts @@ -0,0 +1,646 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { DEFAULT_BANDS } from '../config'; +import { disconnectHistory, frameMessage, frameStyles, periodNav, validateHistoryOptions } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { chartFrame, chartLayout, layoutStyles, validateLayout } from '../chart/layout'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + SECTOR_COUNTS, + pairWind, + petalPath, + polarPoint, + ringSteps, + sectorName, + speedDefaults, + strongest, + windRose, +} from '../chart/wind'; +import type { Rose, SectorCount } from '../chart/wind'; +import { windRoseEditor } from '../editor/wind-rose-editor'; +import { key } from '../history/fetch'; +import type { StatisticsQuery } from '../history/fetch'; +import { PERIOD_MS } from '../history/group'; +import { parsePeriod, periodRange, stepsFor } from '../history/period'; +import type { Period } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, Range, SeriesRequest, Source } from '../history/types'; +import { formatNumber, withUnit } from '../format'; +import { localize, registerStrings } from '../localize'; +import { resolveBands } from '../resolve'; +import type { CardConfig, MergedEntity, ResolvedBand } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_wind_rose: 'Wind rose', + wind_prevailing: 'Prevailing', + wind_mean_speed: 'Mean speed', + wind_strongest: 'Strongest', + wind_calm: 'Calm', + wind_speed: 'Speed', + wind_speed_unit: 'Speed ({unit})', + wind_share: 'Of the time', + wind_aria: 'Wind rose, prevailing {direction} {share}', + wind_aria_calm: 'Wind rose, prevailing {direction} {share}, calm {calm}', + wind_aria_still: 'Wind rose, calm {calm}', +}); + +/** A rolling month: long enough for the pattern to show, short enough to load quickly. */ +export const DEFAULT_WIND_PERIOD = { last: 30, unit: 'day' }; +const DEFAULT_SECTORS: SectorCount = 16; + +/** The card's period, with any key it leaves out taken from the wind rose's month. */ +export function windPeriod(raw: CardConfig): Period { + const own = raw.period; + if (own === undefined || own === null) return parsePeriod(DEFAULT_WIND_PERIOD as CardConfig['period']); + if (typeof own !== 'object' || Array.isArray(own)) return parsePeriod(own as CardConfig['period']); + return parsePeriod({ ...DEFAULT_WIND_PERIOD, ...(own as object) } as CardConfig['period']); +} + +/** `sectors` from YAML (or the editor, which hands back text): 4, 8 or 16. */ +export function windSectors(raw: CardConfig): SectorCount { + const v = raw.sectors; + if (v === undefined || v === null || v === '') return DEFAULT_SECTORS; + const n = typeof v === 'string' ? Number(v) : v; + if (!SECTOR_COUNTS.includes(n as SectorCount)) { + throw new Error(`"sectors" must be one of: ${SECTOR_COUNTS.join(', ')}`); + } + return n as SectorCount; +} + +function calmOption(raw: CardConfig): number | undefined { + const v = raw.calm; + if (v === undefined || v === null || v === '') return undefined; + const n = typeof v === 'string' ? Number(v) : v; + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) throw new Error('"calm" must be a number of 0 or more'); + return n; +} + +/** One of the two sensors a wind rose reads, and where it sits in the drawn entities (or -1). */ +interface Role { + id: string; + entity: MergedEntity; + index: number; +} + +/** + * Which entity is which: the first listed is the direction, the second the speed. Taken from the + * listed order, not the drawn one, so sorting or hiding unavailable entities can't swap them (a + * direction reported as compass points reads as unavailable to the rest of the card). + */ +export function windRoles(ctx: Pick): { direction?: Role; speed?: Role } { + const listed = ctx.config.entities.length ? ctx.config.entities : ctx.items.map((e) => e.config); + const role = (entity: MergedEntity | undefined): Role | undefined => + entity && { id: entity.entity, entity, index: ctx.items.findIndex((e) => e.entityId === entity.entity) }; + return { direction: role(listed[0]), speed: role(listed[1]) }; +} + +/** Everything the rose is drawn from. */ +interface WindFrame { + frame: HistoryFrame; + direction: Point[]; + speed?: Point[]; + until: number; + maxHold: number; +} + +/** Statistics rows to ask for: 5-minute ones for two days or less, else hourly. */ +function statisticsQuery(range: Range): StatisticsQuery { + const period = range.end - range.start <= 2 * 86_400_000 ? '5minute' : 'hour'; + return { period, types: ['mean', 'max'], value: 'mean' }; +} + +function loadWind(ctx: ChartContext, direction: Role, speed: Role | undefined, now = Date.now()): WindFrame { + const period = windPeriod(ctx.raw); + const source: Source = (ctx.raw.source as Source | undefined) ?? 'history'; + const zone = zoneFor(ctx.hass); + const span = periodRange(period, now, zone, period.navigate ? stepsFor(ctx.host, period) : 0); + const range: Range = { start: span.start, end: span.end }; + const requests: SeriesRequest[] = [ + { entity: direction.id, attribute: direction.entity.attribute, bearing: true }, + ...(speed ? [{ entity: speed.id, attribute: speed.entity.attribute }] : []), + ]; + const statistics = source === 'statistics' ? statisticsQuery(range) : undefined; + const rowMs = statistics ? PERIOD_MS[statistics.period] : Infinity; + const load = statistics ? { start: range.start - rowMs, end: range.end } : range; + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, requests, load, source, { + live: span.live, + statistics, + }); + const points = (r: SeriesRequest) => loaded.data.get(key(r)) ?? []; + const dirPoints = points(requests[0]); + const speedPoints = speed ? points(requests[1]) : undefined; + const frame: HistoryFrame = { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series: [], + live: span.live, + back: span.back, + zone, + }; + return { + frame, + direction: dirPoints, + // A speed sensor with no readings at all (missing, unavailable all along) leaves a rose of + // directions only, rather than nothing. + speed: speedPoints?.some((p) => p.v !== null) ? speedPoints : undefined, + until: Math.min(range.end, now), + maxHold: rowMs, + }; +} + +/** A speed band as drawn: where it starts, its colour and its legend text. */ +interface SpeedBand { + from: number; + color: string; + label: string; +} + +/** + * Sequential colours for the default bands, from the theme's primary colour: mixed towards the + * card's background for light winds and towards the text colour for strong ones, the strongest + * turning purple. In a dark theme the same mixes run dark to light, so strong winds still stand out. + */ +const RAMP = [ + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 35%, var(--card-background-color, #fff))', + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 62%, var(--card-background-color, #fff))', + 'var(--primary-color, #2f6fe4)', + 'color-mix(in srgb, var(--primary-color, #2f6fe4) 68%, var(--primary-text-color, #212121))', + 'color-mix(in srgb, var(--purple-color, #8b6cff) 80%, var(--primary-text-color, #212121))', +]; + +const numberText = (n: number, ctx: ChartContext) => formatNumber(n, Number.isInteger(n) ? 0 : 1, ctx.hass); + +function rangeLabel(from: number, next: number | undefined, ctx: ChartContext): string { + if (!Number.isFinite(from)) return next === undefined ? '' : `< ${numberText(next, ctx)}`; + return next === undefined ? `${numberText(from, ctx)}+` : `${numberText(from, ctx)}–${numberText(next, ctx)}`; +} + +/** The card's colour bands when it has any (on the speed entity or the card), else the defaults. */ +export function speedBands(bands: ResolvedBand[], defaults: number[], ctx: ChartContext): SpeedBand[] { + if (bands !== DEFAULT_BANDS && bands.length > 0) { + return bands.map((b, i) => ({ + from: b.from, + color: baseColor(b.color), + label: b.label ?? rangeLabel(b.from, bands[i + 1]?.from, ctx), + })); + } + return defaults.map((from, i) => ({ + from, + color: RAMP[i % RAMP.length], + label: rangeLabel(from, defaults[i + 1], ctx), + })); +} + +/** A share (0–1) as a percentage: whole numbers, with one decimal under 1 % (or with `fine`). */ +function shareText(share: number, ctx: ChartContext, fine = false): string { + const pct = share * 100; + if (pct === 0) return `${formatNumber(0, 0, ctx.hass)}%`; + return `${formatNumber(pct, fine || pct < 1 ? 1 : 0, ctx.hass)}%`; +} + +/** Which sector is under the pointer or focus, per card. */ +const hovered = new WeakMap(); + +function setHover(ctx: ChartContext, sector: number | undefined) { + if (hovered.get(ctx.host) === sector) return; + if (sector === undefined) hovered.delete(ctx.host); + else hovered.set(ctx.host, sector); + ctx.requestUpdate(); +} + +const R = 100; +const R0 = 10; +const BOX = 124; + +interface Drawn { + rose: Rose; + n: number; + bands: SpeedBand[]; + rings: number[]; + /** Radius for a share (0–1). */ + radius(share: number): number; +} + +/** + * Where to write the ring values: along the edge between two sectors, choosing the edge whose + * neighbouring petals are shortest, so the numbers sit in a gap rather than on a petal. + */ +function ringLabelAngle(totals: number[]): number { + const n = totals.length; + let best = 0; + let bestLength = Infinity; + // Start at the north-east and go round, so a tie keeps the labels top right. + for (let k = 0; k < n; k++) { + const edge = (Math.floor(n / 8) + k) % n; + const length = Math.max(totals[edge], totals[(edge + 1) % n]); + if (length < bestLength - 1e-9) { + best = edge; + bestLength = length; + } + } + return ((best + 0.5) * 2 * Math.PI) / n; +} + +function roseSvg(d: Drawn, direction: Role, label: string, ctx: ChartContext): TemplateResult { + const { rose, n, bands, rings } = d; + const step = (2 * Math.PI) / n; + const pad = step * 0.09; + const tooltip = tooltipEnabled(ctx.raw); + const petals = rose.sectors.map((row, i) => { + if (rose.totals[i] <= 0) return nothing; + const a0 = i * step - step / 2 + pad; + const a1 = i * step + step / 2 - pad; + let acc = 0; + const parts = row.map((share, b) => { + if (share <= 0) return nothing; + const path = petalPath(d.radius(acc), d.radius(acc + share), a0, a1); + acc += share; + return svg``; + }); + const name = sectorName(i, n); + const interactive = direction.index >= 0; + return svg` setHover(ctx, i) : nothing} + @pointerleave=${tooltip ? () => setHover(ctx, undefined) : nothing} + @focus=${tooltip ? () => setHover(ctx, i) : nothing} + @blur=${tooltip ? () => setHover(ctx, undefined) : nothing} + >${parts}`; + }); + const ringAngle = ringLabelAngle(rose.totals); + const compass = (['N', 'E', 'S', 'W'] as const).map((name, q) => { + const [x, y] = polarPoint(R + 13, (q * Math.PI) / 2); + return svg`${name}`; + }); + return html` + ${rings.map((p) => svg``)} + + + + ${petals} + + ${compass} + ${rings.map((p) => { + const [x, y] = polarPoint(d.radius(p / 100), ringAngle); + const anchor = Math.sin(ringAngle) >= 0 ? 'start' : 'end'; + return svg`${formatNumber( + p, + Number.isInteger(p) ? 0 : 1, + ctx.hass, + )}%`; + })} + `; +} + +/** The hovered sector's share and its split by speed band, beside the petal's tip. */ +function roseTooltip(d: Drawn, speedUnit: string | undefined, ctx: ChartContext): TemplateResult | typeof nothing { + const i = hovered.get(ctx.host); + if (i === undefined || !tooltipEnabled(ctx.raw) || !(d.rose.totals[i] > 0)) return nothing; + const angle = (i * 2 * Math.PI) / d.n; + const [x, y] = polarPoint(d.radius(d.rose.totals[i]), angle); + // The box goes towards the middle, so it stays over the rose rather than off the card. + const side = x > 0 ? 'left' : 'right'; + const rows = + speedUnit === undefined + ? [{ color: d.bands[0].color, name: localize(ctx.hass, 'wind_share'), share: d.rose.totals[i] }] + : d.rose.sectors[i] + .map((share, b) => ({ color: d.bands[b].color, name: withUnit(d.bands[b].label, speedUnit), share })) + .filter((r) => r.share > 0); + return html``; +} + +function stat(label: string, value: string, role: Role | undefined, kind: string): TemplateResult { + const interactive = role !== undefined && role.index >= 0; + return html`
+ ${label} + ${value} +
`; +} + +function readout( + d: Drawn, + roles: { direction: Role; speed?: Role }, + speed: { unit: string; decimals: number; points: Point[] } | undefined, + ctx: ChartContext, +): TemplateResult { + const t = (k: string, vars?: Record) => localize(ctx.hass, k, vars); + const { rose } = d; + const prevailing = + rose.prevailing < 0 ? '—' : `${sectorName(rose.prevailing, d.n)} · ${shareText(rose.totals[rose.prevailing], ctx)}`; + const speedText = (v: number | null) => + v === null ? '—' : withUnit(formatNumber(v, speed?.decimals ?? 1, ctx.hass), speed?.unit ?? ''); + return html`
+
+ ${stat(t('wind_prevailing'), prevailing, roles.direction, 'prevailing')} + ${ + speed + ? html`${stat(t('wind_mean_speed'), speedText(rose.meanSpeed), roles.speed, 'mean')} + ${stat(t('wind_strongest'), speedText(strongest(speed.points)), roles.speed, 'strongest')} + ${stat(t('wind_calm'), shareText(rose.calm, ctx), roles.speed, 'calm')}` + : nothing + } +
+ ${ + speed + ? html`
+
${speed.unit ? t('wind_speed_unit', { unit: speed.unit }) : t('wind_speed')}
+
+ ${d.bands.map( + (b) => + html`
+ + ${b.label} +
`, + )} +
+
` + : nothing + } +
`; +} + +export const windRoseStyle: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 280, + styles: css` + ${frameStyles} + ${tooltipStyles} + ${layoutStyles} + .wind { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + } + .rose-wrap { + position: relative; + flex: 1 1 200px; + max-width: 270px; + aspect-ratio: 1; + } + .rose { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .rose .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-dasharray: 3 3; + } + .rose .rim, + .rose .spoke { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose .calm-circle { + fill: var(--card-background-color, #fff); + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose text { + font-family: inherit; + fill: var(--secondary-text-color); + text-anchor: middle; + font-size: 12px; + } + .rose .compass.north { + fill: var(--primary-text-color); + font-weight: 500; + } + .rose .ring-label { + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .rose .petal { + outline: none; + transform-box: view-box; + transform-origin: 0 0; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .rose .petal { + transform: scale(1); + } + .rose .petal.entity { + cursor: pointer; + } + .rose .petal:focus-visible path { + stroke: var(--primary-text-color); + stroke-width: 1.5px; + } + .rose-tip { + top: 0; + white-space: nowrap; + } + .tooltip.rose-tip.right { + transform: translate(10px, -50%); + } + .tooltip.rose-tip.left { + transform: translate(calc(-100% - 10px), -50%); + } + .readout { + flex: 1 1 150px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + } + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px 16px; + } + .stat { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 4px; + } + .stat.entity { + cursor: pointer; + } + .stat:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .stat-label { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .stat-value { + font-size: 1.25em; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } + .keys { + display: flex; + gap: 4px; + margin-top: 6px; + } + .key { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + .key-swatch { + width: 100%; + height: 10px; + border-radius: 2px; + } + .key-label { + font-size: 11px; + color: var(--secondary-text-color); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .frame.compact .rose-wrap { + max-width: 170px; + flex-basis: 150px; + } + .frame.compact .wind { + gap: 4px 12px; + } + .frame.compact .readout { + gap: 8px; + } + .frame.compact .stats { + gap: 4px 12px; + } + .frame.compact .stat-value { + font-size: 1.05em; + } + `, + thumbnail: () => svg` + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + windPeriod(raw); + validateTooltipOptions(raw); + validateLayout(raw); + windSectors(raw); + calmOption(raw); + }, + editor: windRoseEditor, + disconnect(host) { + disconnectHistory(host); + hovered.delete(host); + }, + render(ctx) { + const layout = { ...chartLayout(ctx.raw), header: 'above' as const }; + const roles = windRoles(ctx); + const direction = roles.direction; + if (!direction) return html``; + const loaded = loadWind(ctx, direction, roles.speed); + const { frame } = loaded; + const nav = periodNav(frame, ctx); + const noData = html`
${localize(ctx.hass, 'chart_no_data')}
`; + const message = + frame.status !== 'ready' + ? frameMessage(frame, ctx) + : loaded.direction.every((p) => p.v === null) + ? noData + : undefined; + if (message) return chartFrame(layout, nav, message); + + const n = windSectors(ctx.raw); + const speedRole = loaded.speed ? roles.speed : undefined; + const unit = speedRole + ? (speedRole.entity.unit ?? + (speedRole.entity.attribute + ? '' + : String(ctx.hass.states[speedRole.id]?.attributes?.unit_of_measurement ?? ''))) + : undefined; + const defaults = speedDefaults(unit); + const bands: SpeedBand[] = speedRole + ? speedBands(resolveBands(speedRole.entity.severity, ctx.hass), defaults.from, ctx) + : [{ from: 0, color: 'var(--primary-color, #2f6fe4)', label: '' }]; + const calm = speedRole ? (calmOption(ctx.raw) ?? defaults.calm) : 0; + const samples = pairWind(loaded.direction, loaded.speed, loaded.until, loaded.maxHold); + const rose = windRose( + samples, + n, + bands.map((b) => b.from), + calm, + ); + if (rose.total <= 0) return chartFrame(layout, nav, noData); + const longest = Math.max(0, ...rose.totals) * 100; + const rings = ringSteps(longest); + const top = (rings[rings.length - 1] ?? 100) / 100; + const drawn: Drawn = { rose, n, bands, rings, radius: (share) => R0 + (share / top) * (R - R0) }; + + const pct = (s: number) => shareText(s, ctx); + const vars = { + direction: rose.prevailing < 0 ? '' : sectorName(rose.prevailing, n), + share: rose.prevailing < 0 ? '' : pct(rose.totals[rose.prevailing]), + calm: pct(rose.calm), + }; + const label = localize( + ctx.hass, + rose.prevailing < 0 ? 'wind_aria_still' : speedRole ? 'wind_aria_calm' : 'wind_aria', + vars, + ); + const speedInfo = speedRole + ? { + unit: unit ?? '', + decimals: speedRole.index >= 0 ? ctx.items[speedRole.index].decimals : 1, + points: loaded.speed ?? [], + } + : undefined; + return chartFrame( + layout, + nav, + html`
+
+ ${roseSvg(drawn, direction, label, ctx)} ${roseTooltip(drawn, speedInfo ? speedInfo.unit : undefined, ctx)} +
+ ${readout(drawn, { direction, speed: speedRole }, speedInfo, ctx)} +
`, + ); + }, +}; diff --git a/src/types.ts b/src/types.ts index 8a69eb3..7eb8f68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/wind-rose.test.ts b/test/wind-rose.test.ts new file mode 100644 index 0000000..e98b12e --- /dev/null +++ b/test/wind-rose.test.ts @@ -0,0 +1,613 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { parseBearing, normalizeDegrees } from '../src/history/bearing'; +import { key, loadHistory, parseHistory } from '../src/history/fetch'; +import { HistoryStore } from '../src/history/store'; +import { + bandIndex, + pairWind, + petalPath, + polarPoint, + ringSteps, + sectorName, + sectorOf, + speedDefaults, + strongest, + windRose, +} from '../src/chart/wind'; +import { STYLES } from '../src/styles/index'; +import { windPeriod, windSectors } from '../src/styles/wind-rose'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const M = 60_000; +const H = 3_600_000; + +describe('wind direction parsing', () => { + it('reads degrees, folding them into 0–360', () => { + expect(parseBearing('225')).toBe(225); + expect(parseBearing(' 225.5 ° ')).toBe(225.5); + expect(parseBearing(360)).toBe(0); + expect(parseBearing(-90)).toBe(270); + expect(parseBearing('725')).toBe(5); + expect(normalizeDegrees(-0.0)).toBe(0); + }); + + it('reads compass points in any case, and full names', () => { + expect(parseBearing('N')).toBe(0); + expect(parseBearing('nne')).toBe(22.5); + expect(parseBearing('SW')).toBe(225); + expect(parseBearing('wsw')).toBe(247.5); + expect(parseBearing('NNW')).toBe(337.5); + expect(parseBearing('North-East')).toBe(45); + expect(parseBearing('south west')).toBe(225); + expect(parseBearing('West')).toBe(270); + }); + + it('ignores what it cannot read', () => { + for (const raw of ['unavailable', 'unknown', '', 'variable', 'NX', null, undefined, NaN, {}]) { + expect(parseBearing(raw)).toBeNull(); + } + }); + + it('loads a direction series as bearings, under its own key', async () => { + expect( + parseHistory( + [ + { s: 'SW', lu: 1 }, + { s: 'calm', lu: 2 }, + ], + undefined, + parseBearing, + ), + ).toEqual([ + { t: 1000, v: 225 }, + { t: 2000, v: null }, + ]); + expect(key({ entity: 'sensor.d', bearing: true })).toBe('sensor.d#bearing'); + const hass = makeHass({}); + hass.callWS = vi.fn(async () => ({ 'sensor.d': [{ s: 'NE', lu: 1 }] })) as HomeAssistant['callWS']; + const out = await loadHistory(hass, [{ entity: 'sensor.d', bearing: true }, { entity: 'sensor.d' }], { + start: 0, + end: 2000, + }); + expect(out.get('sensor.d#bearing')).toEqual([{ t: 1000, v: 45 }]); + expect(out.get('sensor.d')).toEqual([{ t: 1000, v: null }]); + }); + + it('appends live compass states as bearings', async () => { + const hass = makeHass({ 'sensor.d': 'N' }); + hass.callWS = vi.fn(async () => ({ 'sensor.d': [{ s: 'N', lu: 1 }] })) as HomeAssistant['callWS']; + const store = new HistoryStore(() => undefined); + const req = [{ entity: 'sensor.d', bearing: true }]; + store.get(hass, req, { start: 0, end: Date.now() }, 'history'); + await new Promise((r) => setTimeout(r, 0)); + const next = { + ...hass, + states: { + 'sensor.d': { entity_id: 'sensor.d', state: 'ssw', attributes: {}, last_updated: new Date().toISOString() }, + }, + }; + const got = store.get(next, req, { start: 0, end: Date.now() }, 'history'); + expect(got.data.get('sensor.d#bearing')!.map((p) => p.v)).toEqual([0, 202.5]); + }); +}); + +describe('wind rose sectors', () => { + it('centres sector i on i × 360 / n, north running 348.75 up to 11.25', () => { + expect(sectorOf(0, 16)).toBe(0); + expect(sectorOf(360, 16)).toBe(0); + expect(sectorOf(348.75, 16)).toBe(0); + expect(sectorOf(348.7, 16)).toBe(15); + expect(sectorOf(11.2, 16)).toBe(0); + expect(sectorOf(11.25, 16)).toBe(1); + expect(sectorOf(247.5, 16)).toBe(11); + expect(sectorOf(-10, 16)).toBe(0); + expect(sectorOf(337.5, 8)).toBe(0); + expect(sectorOf(337.4, 8)).toBe(7); + expect(sectorOf(225, 4)).toBe(3); + expect(sectorOf(224.9, 4)).toBe(2); + }); + + it('names sectors for 16, 8 and 4', () => { + expect(sectorName(11, 16)).toBe('WSW'); + expect(sectorName(5, 8)).toBe('SW'); + expect(sectorName(3, 4)).toBe('W'); + }); +}); + +describe('pairing direction with speed', () => { + it('weights by how long each pair held, on the union of change times', () => { + // Direction changes at 0 and 30 min; speed at 0, 10 and 50 min; until 60 min. + const dir = [ + { t: 0, v: 270 }, + { t: 30 * M, v: 180 }, + ]; + const speed = [ + { t: 0, v: 2 }, + { t: 10 * M, v: 8 }, + { t: 50 * M, v: 12 }, + ]; + expect(pairWind(dir, speed, 60 * M)).toEqual([ + { dir: 270, speed: 2, ms: 10 * M }, + { dir: 270, speed: 8, ms: 20 * M }, + { dir: 180, speed: 8, ms: 20 * M }, + { dir: 180, speed: 12, ms: 10 * M }, + ]); + }); + + it('counts time, not rows: a gusty minute of many readings weighs a minute', () => { + const dir = [ + { t: 0, v: 90 }, + { t: 59 * M, v: 270 }, + ]; + // Ten readings from the west in the last minute, one steady hour from the east before. + const speed = [{ t: 0, v: 5 }, ...Array.from({ length: 10 }, (_, i) => ({ t: 59 * M + i * 6000, v: 10 + i }))]; + const rose = windRose(pairWind(dir, speed, 60 * M), 4, [0, 9]); + expect(rose.totals[1]).toBeCloseTo(59 / 60); + expect(rose.totals[3]).toBeCloseTo(1 / 60); + expect(rose.prevailing).toBe(1); + }); + + it('drops gaps in either series, and merges repeats', () => { + const dir = [ + { t: 0, v: 0 }, + { t: 10 * M, v: null }, + { t: 20 * M, v: 0 }, + ]; + const speed = [ + { t: 0, v: 4 }, + { t: 25 * M, v: null }, + { t: 28 * M, v: 4 }, + ]; + // 0–10 min, then 20–25 and 28–30: the same pair, so one sample of 17 minutes. + expect(pairWind(dir, speed, 30 * M)).toEqual([{ dir: 0, speed: 4, ms: 17 * M }]); + expect( + pairWind( + [ + { t: 0, v: 90 }, + { t: 5 * M, v: 90 }, + ], + undefined, + 10 * M, + ), + ).toEqual([{ dir: 90, speed: null, ms: 10 * M }]); + expect(pairWind([], [{ t: 0, v: 3 }], 10 * M)).toEqual([]); + }); + + it('holds a statistics row only for its own period', () => { + const dir = [ + { t: 0, v: 90 }, + { t: 3 * H, v: 90 }, + ]; + // Hours 0 and 3 count; the two missing hours between don't. + expect(pairWind(dir, undefined, 4 * H, H)).toEqual([{ dir: 90, speed: null, ms: 2 * H }]); + }); +}); + +describe('wind rose numbers', () => { + it('has unit defaults: m/s bands, Beaufort-ish for km/h, mph and knots, calm 0.5 m/s', () => { + expect(speedDefaults('m/s')).toEqual({ from: [0, 3, 6, 9, 12], calm: 0.5 }); + expect(speedDefaults('km/h')).toEqual({ from: [0, 12, 20, 29, 39], calm: 1.8 }); + expect(speedDefaults('mph').from).toEqual([0, 8, 13, 19, 25]); + expect(speedDefaults('kn').from).toEqual([0, 7, 11, 17, 22]); + expect(speedDefaults('Knots').calm).toBe(1); + expect(speedDefaults('ft/s').from).toEqual([0, 10, 20, 30, 40]); + expect(speedDefaults(undefined)).toEqual(speedDefaults('m/s')); + expect(speedDefaults('kph')).toEqual(speedDefaults('km/h')); + }); + + it('bins speeds into bands', () => { + const from = [0, 3, 6, 9, 12]; + expect(bandIndex(0, from)).toBe(0); + expect(bandIndex(2.99, from)).toBe(0); + expect(bandIndex(3, from)).toBe(1); + expect(bandIndex(30, from)).toBe(4); + expect(bandIndex(-1, [2, 5])).toBe(0); + }); + + it('counts calm apart from the petals, as a share of all the time', () => { + const rose = windRose( + [ + { dir: 0, speed: 0.2, ms: 10 }, + { dir: 270, speed: 4, ms: 60 }, + { dir: 270, speed: 13, ms: 20 }, + { dir: 90, speed: 7, ms: 10 }, + ], + 16, + [0, 3, 6, 9, 12], + 0.5, + ); + expect(rose.total).toBe(100); + expect(rose.calm).toBeCloseTo(0.1); + expect(rose.totals[0]).toBe(0); + expect(rose.sectors[12]).toEqual([0, 0.6, 0, 0, 0.2]); + expect(rose.totals[4]).toBeCloseTo(0.1); + expect(rose.prevailing).toBe(12); + expect(rose.meanSpeed).toBeCloseTo((0.2 * 10 + 4 * 60 + 13 * 20 + 7 * 10) / 100); + const still = windRose([], 8, [0]); + expect(still.prevailing).toBe(-1); + expect(still.meanSpeed).toBeNull(); + expect(still.calm).toBe(0); + }); + + it('finds the strongest speed, from statistics max when there is one', () => { + expect( + strongest([ + { t: 0, v: 3 }, + { t: 1, v: null }, + { t: 2, v: 9 }, + ]), + ).toBe(9); + expect(strongest([{ t: 0, v: 3, max: 14 }])).toBe(14); + expect(strongest([])).toBeNull(); + }); + + it('picks round ring steps', () => { + expect(ringSteps(11)).toEqual([5, 10, 15]); + expect(ringSteps(12)).toEqual([5, 10, 15]); + expect(ringSteps(6.5)).toEqual([2.5, 5, 7.5]); + expect(ringSteps(100)).toEqual([50, 100]); + expect(ringSteps(0.9)).toEqual([0.5, 1]); + expect(ringSteps(0)).toEqual([]); + }); + + it('draws petals clockwise from north', () => { + expect(polarPoint(10, Math.PI / 2)).toEqual([10, 0]); + expect(petalPath(0, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L0 0Z'); + expect(petalPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(petalPath(10, 10, 0, 1)).toBe(''); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const D = 24 * H; + +function windHass(history: Record, fail = false, extra = {}) { + const hass = makeHass({ + 'sensor.dir': ['250', { friendly_name: 'Direction', unit_of_measurement: '°' }], + 'sensor.compass': ['SW', { friendly_name: 'Compass' }], + 'sensor.speed': ['7', { friendly_name: 'Speed', unit_of_measurement: 'm/s' }], + 'sensor.kmh': ['20', { friendly_name: 'Speed', unit_of_measurement: 'km/h' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off', unit_of_measurement: 'm/s' }], + ...extra, + }); + hass.callWS = vi.fn(async (msg: Record) => { + if (fail) throw new Error('boom'); + if (msg.type === 'recorder/statistics_during_period') { + return { + 'sensor.dir': [ + { start: now - 3 * H, mean: 270, max: 270 }, + { start: now - 2 * H, mean: 90, max: 90 }, + ], + 'sensor.speed': [ + { start: now - 3 * H, mean: 8, max: 20 }, + { start: now - 2 * H, mean: 4, max: 6 }, + ], + }; + } + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +// 10 days of WSW at 8 m/s, 2 of calm, 2 from the east at 4 m/s. +const history = { + 'sensor.dir': [ + { s: '250', lu: sec(14 * D) }, + { s: '90', lu: sec(4 * D) }, + { s: '0', lu: sec(2 * D) }, + ], + 'sensor.compass': [ + { s: 'WSW', lu: sec(14 * D) }, + { s: 'e', lu: sec(4 * D) }, + { s: 'N', lu: sec(2 * D) }, + ], + 'sensor.speed': [ + { s: '8', lu: sec(14 * D) }, + { s: '4', lu: sec(4 * D) }, + { s: '0.1', lu: sec(2 * D) }, + ], + 'sensor.kmh': [ + { s: '30', lu: sec(14 * D) }, + { s: '1', lu: sec(2 * D) }, + ], + 'sensor.off': [{ s: 'unavailable', lu: sec(14 * D) }], +}; + +async function mount(config: Partial, hass = windHass(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'wind_rose', + entities: ['sensor.dir', 'sensor.speed'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const stats = (card: Card) => + Object.fromEntries( + qa(card, '.stat').map((e) => [ + e.querySelector('.stat-label')!.textContent!.trim(), + e.querySelector('.stat-value')!.textContent!.trim(), + ]), + ); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('wind rose card', () => { + it('is in the Distributions group and loads the last 30 days by default', async () => { + expect(STYLES.wind_rose.group).toBe('distribution'); + const card = await mount({}); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0] as Record; + const days = (Date.parse(msg.end_time) - Date.parse(msg.start_time)) / D; + expect(days).toBeCloseTo(30, 1); + expect(msg.entity_ids).toEqual(['sensor.dir', 'sensor.speed']); + }); + + it('draws petals stacked by speed band, the readout and the legend', async () => { + const card = await mount({}); + const petals = qa(card, '.petal'); + // WSW, E and (calm, so no) N. + expect(petals.map((p) => p.getAttribute('data-sector'))).toEqual(['4', '11']); + expect(petals.every((p) => p.classList.contains('entity') && p.getAttribute('data-index') === '0')).toBe(true); + expect(petals[1].querySelectorAll('path')).toHaveLength(1); + expect(stats(card)).toEqual({ + Prevailing: 'WSW · 71%', + 'Mean speed': '6.3 m/s', + Strongest: '8.0 m/s', + Calm: '14%', + }); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–3', '3–6', '6–9', '9–12', '12+']); + expect(q(card, '.speed-legend .stat-label')!.textContent).toBe('Speed (m/s)'); + expect(q(card, 'svg.rose')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toBe('Wind rose, prevailing WSW 71%, calm 14%'); + expect(qa(card, '.ring-label').map((e) => e.textContent)).toEqual(['25%', '50%', '75%']); + expect(qa(card, '.compass').map((e) => e.textContent)).toEqual(['N', 'E', 'S', 'W']); + expect(q(card, '.stat-mean')!.getAttribute('data-index')).toBe('1'); + }); + + it('reads compass points, 8 sectors and km/h bands', async () => { + const card = await mount({ entities: ['sensor.compass', 'sensor.kmh'], sectors: 8 } as Partial); + expect(qa(card, '.petal').map((p) => p.getAttribute('data-sector'))).toEqual(['2', '6']); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–12', '12–20', '20–29', '29–39', '39+']); + expect(stats(card).Prevailing).toBe('W · 71%'); + }); + + it('uses the card colour bands for speed when set', async () => { + const card = await mount({ + severity: [ + { from: 0, color: 'green' }, + { from: 5, color: 'red', label: 'Windy' }, + ], + } as Partial); + expect(qa(card, '.key-label').map((e) => e.textContent)).toEqual(['0–5', 'Windy']); + expect((q(card, '.key-swatch') as HTMLElement).style.background).toContain('--success-color'); + }); + + it('draws directions only without a speed, or when the speed has no readings', async () => { + for (const entities of [['sensor.dir'], ['sensor.dir', 'sensor.off'], ['sensor.dir', 'sensor.gone']]) { + const card = await mount({ entities } as Partial); + expect(Object.keys(stats(card))).toEqual(['Prevailing']); + expect(q(card, '.speed-legend')).toBeNull(); + expect(qa(card, '.petal').map((p) => p.getAttribute('data-sector'))).toEqual(['0', '4', '11']); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toMatch(/^Wind rose, prevailing WSW 71%$/); + } + }); + + it('says when loading, failed, or there is nothing to draw', async () => { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'x', style: 'wind_rose', entities: ['sensor.dir'] } as CardConfig); + card.hass = windHass(history); + document.body.appendChild(card); + await card.updateComplete; + expect(q(card, '.chart-message')!.textContent).toBe('Loading history…'); + const failed = await mount({}, windHass(history, true)); + expect(q(failed, '.chart-message.error')!.textContent!.trim()).toBe("Couldn't load history: boom"); + const missing = await mount({ entities: ['sensor.nothing', 'sensor.speed'] } as Partial); + expect(q(missing, '.chart-message')!.textContent).toBe('No readings in this period.'); + const unreadable = await mount( + { entities: ['sensor.dir'] } as Partial, + windHass({ 'sensor.dir': [{ s: 'variable', lu: sec(D) }] }), + ); + expect(q(unreadable, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('shows all calm as an empty rose', async () => { + const card = await mount( + {}, + windHass({ 'sensor.dir': [{ s: '10', lu: sec(D) }], 'sensor.speed': [{ s: '0', lu: sec(D) }] }), + ); + expect(qa(card, '.petal')).toHaveLength(0); + expect(stats(card).Calm).toBe('100%'); + expect(q(card, 'svg.rose')!.getAttribute('aria-label')).toBe('Wind rose, calm 100%'); + }); + + it('keeps roles by listed order when sorted or hiding unavailable', async () => { + const card = await mount({ + entities: ['sensor.compass', 'sensor.speed'], + hide_unavailable: true, + sort: 'descending', + } as Partial); + expect(stats(card).Prevailing).toBe('WSW · 71%'); + // The compass sensor isn't drawn as an entity (it has no number), so its petals don't open it. + expect(qa(card, '.petal.entity')).toHaveLength(0); + }); + + it('shows a petal’s share and band split on hover or focus', async () => { + const card = await mount({}); + const petal = q(card, '.petal[data-sector="11"]')!; + petal.dispatchEvent(new Event('pointerenter')); + await card.updateComplete; + expect(q(card, '.rose-tip .tooltip-time')!.textContent).toBe('WSW · 71.4%'); + expect(qa(card, '.rose-tip .tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + '6–9 m/s 71.4%', + ]); + expect(q(card, '.rose-tip')!.classList.contains('right')).toBe(true); + petal.dispatchEvent(new Event('pointerleave')); + await card.updateComplete; + expect(q(card, '.rose-tip')).toBeNull(); + const east = q(card, '.petal[data-sector="4"]')!; + east.dispatchEvent(new Event('focus')); + await card.updateComplete; + expect(q(card, '.rose-tip')!.classList.contains('left')).toBe(true); + east.dispatchEvent(new Event('blur')); + const off = await mount({ tooltip: false } as Partial); + q(off, '.petal')!.dispatchEvent(new Event('pointerenter')); + await off.updateComplete; + expect(q(off, '.rose-tip')).toBeNull(); + const only = await mount({ entities: ['sensor.dir'] } as Partial); + q(only, '.petal[data-sector="11"]')!.dispatchEvent(new Event('pointerenter')); + await only.updateComplete; + expect(q(only, '.rose-tip .tooltip-name')!.textContent).toBe('Of the time'); + }); + + it('opens the direction from a petal and the speed from its readout', async () => { + const card = await mount({}); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + for (const el of [q(card, '.petal')!, q(card, '.stat-strongest')!]) { + el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + } + expect(fired).toEqual(['sensor.dir', 'sensor.speed']); + }); + + it('reads hourly statistics, with the speed max as the strongest', async () => { + const card = await mount({ source: 'statistics', compact: true } as Partial); + const msg = (card.hass!.callWS as ReturnType).mock.calls[0][0] as Record; + expect(msg.period).toBe('hour'); + expect(msg.types).toEqual(['mean', 'max']); + expect(stats(card)).toMatchObject({ Prevailing: 'E · 50%', Strongest: '20.0 m/s', 'Mean speed': '6.0 m/s' }); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('steps back a period with the buttons', async () => { + const card = await mount({ period: { calendar: 'week', navigate: true } } as Partial); + expect(q(card, '.period-label')!.textContent).toBe('This week'); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'wind_rose', entities: ['sensor.dir'], ...extra } as CardConfig); + expect(set({ sectors: 12 })).toThrow('"sectors" must be one of: 4, 8, 16'); + expect(set({ calm: -1 })).toThrow(/calm/); + expect(set({ calm: 'still' })).toThrow(/calm/); + expect(set({ period: { last: 0 } })).toThrow(/period/); + expect(set({ period: 'week' })).toThrow(/period/); + expect(set({ tooltip: 'yes' })).toThrow(/tooltip/); + expect(set({ sectors: '8', calm: '1' })).not.toThrow(); + expect(windSectors({ type: 'x', sectors: '4' })).toBe(4); + expect(windPeriod({ type: 'x', period: { last: 7 } })).toMatchObject({ last: 7, unit: 'day' }); + }); + + it('pauses on disconnect', async () => { + const card = await mount({}); + card.remove(); + expect(q(card, '.petal')).not.toBeNull(); + }); +}); + +describe('wind rose editor', () => { + it('says which entity is which, and writes period, sectors and calm', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = windHass(history); + const raw = { + type: 'custom:chartdeck-card', + style: 'wind_rose', + entities: ['sensor.dir', { entity: 'sensor.speed', name: 'Gusts' }], + sectors: 8, + } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.wind-roles')!.textContent).toContain('Direction: Direction. Speed: Gusts.'); + expect(ed.shadowRoot!.querySelector('.wind-statistics')).toBeNull(); + expect( + ed + .shadowRoot!.querySelector('button[data-style="wind_rose"]')! + .closest('[data-group]')! + .getAttribute('data-group'), + ).toBe('distribution'); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + period_calendar: 'rolling', + period_last: 30, + period_unit: 'day', + period_offset: 0, + period_until: 'now', + period_navigate: false, + source: 'history', + sectors: '8', + tooltip: true, + compact: false, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('group_by'); + expect(names).toContain('"calm"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), sectors: '16', period_last: 7, calm: 1 } }, + }), + ); + const rest: CardConfig = { ...raw }; + delete rest.sectors; + expect(events.at(-1)).toEqual({ ...rest, period: { last: 7 }, calm: 1 }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sectors: '4' } } }), + ); + expect(events.at(-1)!.sectors).toBe(4); + }); + + it('warns about statistics and names a missing speed', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + ed.hass = windHass(history); + ed.setConfig({ type: 'x', style: 'wind_rose', source: 'statistics', entities: ['sensor.nowhere'] } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + expect(ed.shadowRoot!.querySelector('.wind-roles')!.textContent).toContain( + 'Direction: sensor.nowhere. Speed: none.', + ); + expect(ed.shadowRoot!.querySelector('.wind-statistics')!.textContent).toContain('measurement_angle'); + }); +}); From 9e8be4d785e25c1a08e13aacbfdd6778836fa177 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 20:45:30 +0000 Subject: [PATCH 25/41] feat: 24-hour clock of when each entity was on, and its numbers by the hour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new history chart, `style: day_clock` ("24-hour clock" in the picker, under History charts). A round dial with midnight at the top and the day running clockwise, 00/06/12/18 labels (12-hour labels when the profile or language uses a 12-hour clock), and one ring per entity, the first outermost. State entities (switches, lights, binary sensors, climate, people, covers…, or anything with its own `active` list) get arcs for the time they spent in an active state, over a grey track. By default on, open, home, playing, heating, cooling and similar count as active; each entity's "Counts as on" option (`active`, one state or a list) replaces that. Climate entities follow what they are doing (`hvac_action`), not their mode. Unavailable and unknown leave a gap. Numeric entities get short radial bars, one per hour (or per 15 or 30 minutes with `bucket`). A bar is the hour's time-weighted mean, or for a meter (state_class total_increasing) how much it went up; the card's shared `aggregate` option overrides that. By default the clock shows today from midnight in Home Assistant's time zone, with a hand at the current time, the rest of the day dimmed, and a redraw each minute (stopped when the card leaves the page). `days: 7` instead shows the average day over the last 7 days: arc strength is the share of days each minute was on (counting only days it was recorded and had happened), and bars are the mean across days. `period.offset` and `period.navigate` step back a day (or N days) at a time. The middle shows the first state entity's time on ("4 h 10 m", "Heating on"); `center: now` shows the time and the first entity's state, `center: none` nothing. The legend beside the dial (below it on narrow cards) gives each ring's time on and how often it switched on, or its total and busiest hour, then the current time and state. Hovering, touching or focusing an arc shows its start, end and length; a bar shows its hour and value. Clock changes: the dial is always 24 hours of wall clock. On the 23-hour day the skipped hour stays empty and an arc across it keeps its real length; on the 25-hour day the repeated hour draws over itself, and its two bars are added together (totals) or averaged (levels). The shared history loader can now keep states as words (`text` series), the same change as the state timeline's, so both charts load the same way. Everything is in the editor: the Chart section has days, periods back, the previous/next buttons, the middle, the bar length, how bars combine and the tooltip; each entity has its colour and which states count as on. --- src/chart/day-clock.ts | 390 ++++++++++++++ src/editor/day-clock-editor.ts | 102 ++++ src/styles/day-clock.ts | 953 +++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 4 +- test/day-clock.test.ts | 711 ++++++++++++++++++++++++ 6 files changed, 2160 insertions(+), 2 deletions(-) create mode 100644 src/chart/day-clock.ts create mode 100644 src/editor/day-clock-editor.ts create mode 100644 src/styles/day-clock.ts create mode 100644 test/day-clock.test.ts diff --git a/src/chart/day-clock.ts b/src/chart/day-clock.ts new file mode 100644 index 0000000..e1cd5ec --- /dev/null +++ b/src/chart/day-clock.ts @@ -0,0 +1,390 @@ +import { groupHistory } from '../history/group'; +import type { Aggregate } from '../history/group'; +import { addUnits, offsetAt, startOf } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Point, Range } from '../history/types'; + +/** + * Maths for the 24-hour clock: when each entity was "active" on a day, where that falls on a + * 24-hour dial, and its numbers bucketed by the hour. Angles are radians clockwise from midnight + * at the top. Pure functions, so the drawing code stays thin. + */ + +export const TAU = Math.PI * 2; +export const DAY_MINUTES = 1440; +const MINUTE = 60_000; + +/** States that count as "active" when an entity sets no `active` list of its own. */ +export const DEFAULT_ACTIVE = [ + 'on', + 'open', + 'opening', + 'closing', + 'home', + 'playing', + 'heating', + 'cooling', + 'drying', + 'active', + 'running', + 'cleaning', + 'detected', +]; + +/** States that mean "no reading": a gap on the dial, neither active nor not. */ +const GAP_STATES = new Set(['unavailable', 'unknown']); + +export function isGap(state: string | undefined): boolean { + return state === undefined || state === '' || GAP_STATES.has(state); +} + +/** An entity's `active` option (one state or a list) as a set, or the defaults when unset. */ +export function activeSet(raw: unknown): Set { + if (typeof raw === 'string' && raw.trim() !== '') return new Set([raw.trim()]); + if (Array.isArray(raw)) { + const list = raw.filter((s): s is string => typeof s === 'string' && s.trim() !== '').map((s) => s.trim()); + if (list.length) return new Set(list); + } + return new Set(DEFAULT_ACTIVE); +} + +/** A stretch of time, ms since the epoch. `from` is set when it began before the range (carried in). */ +export interface Interval { + start: number; + end: number; + /** Began before the range: not a switch-on inside it. */ + carried?: boolean; +} + +export interface StateIntervals { + /** When the entity was in an active state. */ + active: Interval[]; + /** When its state was known at all (not unavailable, unknown or unrecorded). */ + known: Interval[]; + /** The last active state seen ("heating"), for the centre's label. */ + word?: string; +} + +function push(list: Interval[], start: number, end: number, carried: boolean): void { + const last = list[list.length - 1]; + if (last && last.end === start) last.end = end; + else list.push(carried ? { start, end, carried } : { start, end }); +} + +/** + * Text points (oldest first) as active and known intervals inside `range`. Each state lasts until + * the next point, the last one until `range.end`; repeats join. A point carried in from before the + * range (the store marks it) makes its interval `carried`, so a heater already on at midnight is + * not counted as switching on at midnight. + */ +export function stateIntervals(points: readonly Point[], range: Range, active: Set): StateIntervals { + const out: StateIntervals = { active: [], known: [] }; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + const next = i + 1 < points.length ? points[i + 1].t : range.end; + const start = Math.max(range.start, p.t); + const end = Math.min(range.end, next); + if (end <= start || isGap(p.s)) continue; + const carried = p.carried === true || p.t < range.start; + push(out.known, start, end, carried); + if (active.has(p.s as string)) { + push(out.active, start, end, carried); + out.word = p.s; + } + } + return out; +} + +/** Intervals cut to `range`, empty ones left out. A cut start counts as carried in. */ +export function clip(intervals: readonly Interval[], range: Range): Interval[] { + const out: Interval[] = []; + for (const iv of intervals) { + const start = Math.max(range.start, iv.start); + const end = Math.min(range.end, iv.end); + if (end <= start) continue; + const carried = iv.carried === true || iv.start < range.start; + out.push(carried ? { start, end, carried } : { start, end }); + } + return out; +} + +export function totalMs(intervals: readonly Interval[]): number { + return intervals.reduce((n, iv) => n + iv.end - iv.start, 0); +} + +/** How many times it switched on: intervals that began inside the range. */ +export function switchOns(intervals: readonly Interval[]): number { + return intervals.filter((iv) => !iv.carried).length; +} + +/** + * Minutes on the wall clock from the day's midnight to `t`, in the zone: what the dial shows. + * On the day the clocks go forward, 01:59 is followed by 03:00, so the hour between is never + * reached; on the day they go back, 01:00–02:00 comes round twice. + */ +export function wallMinute(t: number, dayStart: number, timeZone: string): number { + const wall = t + offsetAt(t, timeZone); + const midnight = dayStart + offsetAt(dayStart, timeZone); + return (wall - midnight) / MINUTE; +} + +/** The instant (to the second) in (lo, hi] where the zone's offset changes from its value at lo. */ +function jump(lo: number, hi: number, timeZone: string): number { + const before = offsetAt(lo, timeZone); + while (hi - lo > 1000) { + const mid = Math.floor((lo + hi) / 2); + if (offsetAt(mid, timeZone) === before) lo = mid; + else hi = mid; + } + // Offsets change on a whole second, and `offsetAt` reads whole seconds: snap to it. + return Math.floor(hi / 1000) * 1000; +} + +/** + * An interval within one day as wall-clock minute spans [from, to] on the dial (0–1440). Usually + * one span; split in two when the clocks change inside it. Each span uses the offset in force + * during it, so an hour of heating across the clock change still draws as an hour. + */ +export function wallSpans(iv: Interval, dayStart: number, timeZone: string): [number, number][] { + const clamp = (m: number) => Math.min(DAY_MINUTES, Math.max(0, m)); + const a = iv.start; + const b = iv.end; + const oa = offsetAt(a, timeZone); + // The offset just before the end: the end itself may be the first instant after a change. + const ob = offsetAt(Math.max(a, b - 1000), timeZone); + const base = dayStart + offsetAt(dayStart, timeZone); + const at = (t: number, offset: number) => clamp((t + offset - base) / MINUTE); + if (oa === ob) return [[at(a, oa), at(b, oa)]]; + const j = jump(a, b - 1000, timeZone); + return [ + [at(a, oa), at(j, oa)], + [at(j, ob), at(b, ob)], + ].filter(([x, y]) => y > x) as [number, number][]; +} + +/** Wall-clock minutes as an angle on the dial: midnight at the top, clockwise. */ +export function minuteAngle(m: number): number { + return (m / DAY_MINUTES) * TAU; +} + +/** The days a clock covers: `days` whole days ending with the day `back` × `days` days ago. */ +export function clockDays(now: number, zone: Zone, days: number, back: number): number[] { + const today = startOf('day', now, zone); + const last = addUnits('day', today, -back * days, zone); + const out: number[] = []; + for (let i = days - 1; i >= -1; i--) out.push(addUnits('day', last, -i, zone)); + // Each day's start, then the end of the last one. + return out; +} + +/** One run of minutes on the average day: active on `on` of the `of` days it was known. */ +export interface AverageRun { + from: number; + to: number; + on: number; + of: number; +} + +/** Mark wall minutes (by their middle) a span covers. */ +function mark(slots: Uint8Array, [from, to]: [number, number]): void { + const first = Math.max(0, Math.ceil(from - 0.5)); + const last = Math.min(DAY_MINUTES, Math.ceil(to - 0.5)); + for (let i = first; i < last; i++) slots[i] = 1; +} + +/** + * The average day: for each wall minute, on how many of the days it was active, out of the days + * it was known (recorded and already happened). Runs of minutes with the same counts are joined. + * Minutes active on no day are left out. `bounds` are each day's start, then the last day's end. + */ +export function averageDay( + active: readonly Interval[], + known: readonly Interval[], + bounds: readonly number[], + timeZone: string, +): AverageRun[] { + const on = new Uint16Array(DAY_MINUTES); + const of = new Uint16Array(DAY_MINUTES); + for (let d = 0; d + 1 < bounds.length; d++) { + const day = { start: bounds[d], end: bounds[d + 1] }; + const a = new Uint8Array(DAY_MINUTES); + const k = new Uint8Array(DAY_MINUTES); + for (const iv of clip(known, day)) for (const s of wallSpans(iv, day.start, timeZone)) mark(k, s); + for (const iv of clip(active, day)) for (const s of wallSpans(iv, day.start, timeZone)) mark(a, s); + for (let i = 0; i < DAY_MINUTES; i++) { + of[i] += k[i]; + on[i] += a[i] & k[i]; + } + } + const runs: AverageRun[] = []; + for (let i = 0; i < DAY_MINUTES; i++) { + if (on[i] === 0) continue; + const last = runs[runs.length - 1]; + if (last && last.to === i && last.on === on[i] && last.of === of[i]) last.to = i + 1; + else runs.push({ from: i, to: i + 1, on: on[i], of: of[i] }); + } + return runs; +} + +/** Bucket lengths in minutes the bars can use. */ +export const BUCKETS = [15, 30, 60] as const; +export type Bucket = (typeof BUCKETS)[number]; + +/** + * Bucket boundaries across `range`, every `minutes` of real time from its start. Real time, not + * the wall clock, so the hour that repeats when the clocks go back is two buckets (both fold onto + * the same place on the dial, see `foldSlots`), and every bucket is the same length. + */ +export function bucketBounds(range: Range, minutes: number): number[] { + const step = minutes * MINUTE; + const out: number[] = []; + for (let t = range.start; t < range.end; t += step) out.push(t); + out.push(range.end); + return out; +} + +/** One bar on the dial: a slot of the day and its value. */ +export interface Slot { + /** Index round the dial: slot × bucket minutes is its wall-clock start. */ + index: number; + v: number; + /** Days that had a value here (1 on a single day). */ + days: number; +} + +const ADDITIVE: ReadonlySet = new Set(['sum', 'change']); + +function combine(values: number[], aggregate: Aggregate): number { + if (aggregate === 'max') return Math.max(...values); + if (aggregate === 'min') return Math.min(...values); + if (aggregate === 'first') return values[0]; + if (aggregate === 'last') return values[values.length - 1]; + const sum = values.reduce((a, b) => a + b, 0); + return ADDITIVE.has(aggregate) ? sum : sum / values.length; +} + +/** + * Numeric readings as bars round the dial, one per `bucket` minutes. Each day is grouped with the + * shared `groupHistory` (time-weighted mean, sum, change for meters…), up to `until` (now). A + * bucket lands on the dial by its wall-clock start; two buckets on one slot (the repeated hour + * when the clocks go back) are combined as their aggregate is (summed, or averaged). Over several + * days each slot is the mean of the days that had a value there. + */ +export function foldSlots( + points: readonly Point[], + bounds: readonly number[], + bucket: number, + aggregate: Aggregate, + until: number, + timeZone: string, +): Slot[] { + const perSlot = new Map(); + for (let d = 0; d + 1 < bounds.length; d++) { + const day = { start: bounds[d], end: bounds[d + 1] }; + if (day.start >= until) break; + const groups = groupHistory(points as Point[], bucketBounds(day, bucket), aggregate, until); + const today = new Map(); + for (const g of groups) { + if (g.v === null) continue; + const index = Math.floor(wallMinute(g.t, day.start, timeZone) / bucket); + if (index < 0 || index * bucket >= DAY_MINUTES) continue; + today.set(index, [...(today.get(index) ?? []), g.v]); + } + for (const [index, values] of today) { + perSlot.set(index, [...(perSlot.get(index) ?? []), combine(values, aggregate)]); + } + } + return [...perSlot.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([index, values]) => ({ index, v: values.reduce((a, b) => a + b, 0) / values.length, days: values.length })); +} + +/** Whether an aggregate adds up (a total for the day makes sense) rather than averaging. */ +export function isAdditive(aggregate: Aggregate): boolean { + return ADDITIVE.has(aggregate); +} + +/** + * Bar heights 0–1. Totals (sum, change) are drawn from zero, so an empty hour is no bar; levels + * (mean, min, max) from the day's lowest to highest, with the lowest still a sliver, since a room + * at 19–21 °C would otherwise draw 24 bars of the same height. + */ +export function barHeights(slots: readonly Slot[], additive: boolean): number[] { + if (slots.length === 0) return []; + const values = slots.map((s) => s.v); + if (additive) { + const max = Math.max(...values.map(Math.abs)); + return values.map((v) => (max > 0 ? Math.abs(v) / max : 0)); + } + const lo = Math.min(...values); + const hi = Math.max(...values); + return values.map((v) => (hi > lo ? 0.15 + (0.85 * (v - lo)) / (hi - lo) : 1)); +} + +/** Ring radii, outermost first: each ring's inner and outer radius, and the hole left in the middle. */ +export interface RingLayout { + rings: { r0: number; r1: number }[]; + hole: number; +} + +/** + * Rings from `outer` inwards, state rings thinner than bar rings, with a gap between. Too many to + * fit and they all shrink alike, so the centre keeps at least `minHole`. + */ +export function ringLayout(kinds: readonly ('state' | 'numeric')[], outer = 92, minHole = 44): RingLayout { + const GAP = 4; + const want = kinds.map((k) => (k === 'state' ? 15 : 22)); + const total = want.reduce((a, b) => a + b, 0) + GAP * Math.max(0, kinds.length - 1); + const room = outer - minHole; + const scale = total > room ? room / total : 1; + const rings: { r0: number; r1: number }[] = []; + let r = outer; + for (const w of want) { + const width = w * scale; + rings.push({ r0: r - width, r1: r }); + r -= width + GAP * scale; + } + return { rings, hole: rings.length ? rings[rings.length - 1].r0 : outer }; +} + +/** "4 h 10 m", "25 m", "0 m": a length of time as the dial's centre and legend show it. */ +export function formatClockDuration(ms: number): string { + const m = Math.max(0, Math.round(ms / MINUTE)); + const h = Math.floor(m / 60); + const rest = m - h * 60; + if (h === 0) return `${rest} m`; + return rest ? `${h} h ${rest} m` : `${h} h`; +} + +/* Sector geometry, copied from the polar-area chart (feat/polar-area src/chart/polar.ts). */ + +/** The point at radius `r` and `angle` from the centre (0, 0), in SVG coordinates (y down). */ +export function polar(r: number, angle: number): [number, number] { + return [round(r * Math.sin(angle)), round(-r * Math.cos(angle))]; +} + +function round(n: number): number { + return Math.round(n * 100) / 100 || 0; +} + +/** The SVG path of a ring sector from `a0` to `a1` between radii `r0` (inner) and `r1` (outer). */ +export function sectorPath(r0: number, r1: number, a0: number, a1: number): string { + if (r1 <= 0 || a1 <= a0) return ''; + const inner = Math.max(0, r0); + if (a1 - a0 >= TAU - 1e-6) { + const ring = (r: number, sweep: 0 | 1) => { + const [x0, y0] = polar(r, 0); + const [x1, y1] = polar(r, Math.PI); + return `M${x0} ${y0}A${r} ${r} 0 1 ${sweep} ${x1} ${y1}A${r} ${r} 0 1 ${sweep} ${x0} ${y0}Z`; + }; + return inner > 0 ? `${ring(r1, 1)}${ring(inner, 0)}` : ring(r1, 1); + } + const large = a1 - a0 > Math.PI ? 1 : 0; + const [ox0, oy0] = polar(r1, a0); + const [ox1, oy1] = polar(r1, a1); + const outer = `M${ox0} ${oy0}A${r1} ${r1} 0 ${large} 1 ${ox1} ${oy1}`; + if (inner === 0) return `${outer}L0 0Z`; + const [ix1, iy1] = polar(inner, a1); + const [ix0, iy0] = polar(inner, a0); + return `${outer}L${ix1} ${iy1}A${inner} ${inner} 0 ${large} 0 ${ix0} ${iy0}Z`; +} diff --git a/src/editor/day-clock-editor.ts b/src/editor/day-clock-editor.ts new file mode 100644 index 0000000..92bb543 --- /dev/null +++ b/src/editor/day-clock-editor.ts @@ -0,0 +1,102 @@ +import type { TemplateResult } from 'lit'; +import { BUCKETS } from '../chart/day-clock'; +import { AGGREGATES } from '../history/group'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { cachedSchema, chartForm, choice } from './chart-editors'; +import type { FieldBinding } from './schemas'; + +registerStrings('en', { + editor_days: 'Days (more than 1 shows the average day)', + editor_center: 'In the middle', + editor_center_total: 'Time on', + editor_center_now: 'The time now', + editor_center_none: 'Nothing', + editor_bucket: 'One bar for every', + editor_bucket_15: '15 minutes', + editor_bucket_30: '30 minutes', + editor_bucket_60: 'Hour', + editor_aggregate_auto: 'Automatic (change for meters, else average)', + editor_ring_color: 'Colour', + editor_active: 'Counts as on (empty: on, open, heating, home…)', +}); + +export const DAY_CLOCK_FIELDS: FieldBinding[] = [ + { name: 'days', path: ['days'], default: 1 }, + { name: 'period_offset', path: ['period', 'offset'], default: 0 }, + { name: 'period_navigate', path: ['period', 'navigate'], default: false }, + { name: 'center', path: ['center'], default: 'total' }, + { name: 'bucket', path: ['bucket'], default: '60' }, + { name: 'aggregate', path: ['aggregate'], default: 'auto' }, + { name: 'tooltip', path: ['tooltip'], default: true }, +]; + +function schema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'days', selector: { number: { min: 1, max: 31, mode: 'box' } } }, + { name: 'period_offset', selector: { number: { min: 0, mode: 'box' } } }, + ], + }, + { name: 'period_navigate', selector: { boolean: {} } }, + { name: 'center', selector: choice(hass, 'center', ['total', 'now', 'none']) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'bucket', selector: choice(hass, 'bucket', BUCKETS.map(String)) }, + { name: 'aggregate', selector: choice(hass, 'aggregate', ['auto', ...AGGREGATES]) }, + ], + }, + { name: 'tooltip', selector: { boolean: {} } }, + ]; +} + +/** The dropdown speaks strings ("30"); the config keeps a number (30). */ +function bucketAsText(config: CardConfig): CardConfig { + return typeof config.bucket === 'number' ? { ...config, bucket: String(config.bucket) } : config; +} + +function bucketAsNumber(config: CardConfig): CardConfig { + return typeof config.bucket === 'string' ? { ...config, bucket: Number(config.bucket) } : config; +} + +/** The Chart section: which days, what the middle says, and how numbers become bars. */ +export function dayClockEditor(ctx: ChartEditorContext): TemplateResult { + const view: ChartEditorContext = { + ...ctx, + config: bucketAsText(ctx.config), + commit: (config) => ctx.commit(bucketAsNumber(config)), + }; + return chartForm( + view, + 'chart', + cachedSchema('day_clock', ctx.hass, () => schema(ctx.hass)), + DAY_CLOCK_FIELDS, + ); +} + +const ENTITY_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'ring_color', selector: { text: {} } }, + { name: 'active', selector: { text: { multiple: true } } }, + ], + }, +]; + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'ring_color', path: ['color'] }, + { name: 'active', path: ['active'] }, +]; + +/** Each entity's ring colour, and which of its states count as on. */ +export function dayClockEntityFields(): EntityFields { + return { schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }; +} diff --git a/src/styles/day-clock.ts b/src/styles/day-clock.ts new file mode 100644 index 0000000..5b6ed85 --- /dev/null +++ b/src/styles/day-clock.ts @@ -0,0 +1,953 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatTime, hour12Of } from '../chart/axes'; +import { + BUCKETS, + DAY_MINUTES, + TAU, + activeSet, + averageDay, + barHeights, + clip, + clockDays, + foldSlots, + formatClockDuration, + isAdditive, + minuteAngle, + polar, + ringLayout, + sectorPath, + stateIntervals, + switchOns, + totalMs, + wallMinute, + wallSpans, +} from '../chart/day-clock'; +import type { AverageRun, Bucket, Interval, Slot } from '../chart/day-clock'; +import { SERIES_COLORS, disconnectHistory, frameStyles, periodNav } from '../chart/frame'; +import type { HistoryFrame } from '../chart/frame'; +import { TOUCH_LINGER_MS, tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { dayClockEditor, dayClockEntityFields } from '../editor/day-clock-editor'; +import { formatNumber, withUnit } from '../format'; +import { key, toReading } from '../history/fetch'; +import { AGGREGATES } from '../history/group'; +import type { Aggregate } from '../history/group'; +import { parsePeriod, stepsFor } from '../history/period'; +import type { Period } from '../history/period'; +import { storeFor } from '../history/store'; +import { zoneFor } from '../history/timezone'; +import type { Point, SeriesRequest } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig, HomeAssistant } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +registerStrings('en', { + editor_style_day_clock: '24-hour clock', + day_clock_times_one: 'once', + day_clock_times: '{n} times', + day_clock_a_day: '{text} a day', + day_clock_busiest: '{total} · busiest {time}', + day_clock_peak: 'peak {value} at {time}', + day_clock_state: '{name} {state}', + day_clock_per_day: '{label} per day', + day_clock_no_data: 'Nothing recorded', + day_clock_now: 'Now', + day_clock_on: 'on', + day_clock_average: 'Average day · last {n} days', + day_clock_on_days: 'on {on} of {of} days', + day_clock_so_far: '{duration} so far', + day_clock_range: '{from} – {to}', + day_clock_average_value: '{value} on average', +}); + +export const CENTER = ['total', 'now', 'none'] as const; +export type Center = (typeof CENTER)[number]; + +/** Most days an average day may cover: a month of history is already a lot to load. */ +export const MAX_DAYS = 31; + +/** Arcs or bars per ring above which they are no longer each a tab stop. */ +const MAX_FOCUSABLE = 48; + +/** Domains whose states are words (on, heating, home), drawn as arcs rather than bars. */ +const STATE_DOMAINS = new Set([ + 'alarm_control_panel', + 'automation', + 'binary_sensor', + 'calendar', + 'climate', + 'cover', + 'device_tracker', + 'fan', + 'humidifier', + 'input_boolean', + 'lawn_mower', + 'light', + 'lock', + 'media_player', + 'person', + 'remote', + 'schedule', + 'script', + 'siren', + 'switch', + 'timer', + 'vacuum', + 'valve', + 'water_heater', +]); + +type Own = { color?: string; active?: unknown }; + +const domainOf = (id: string) => id.split('.')[0]; + +/** + * Arcs (time in a state) or bars (numbers by the hour). An entity with its own `active` list is + * always a state ring; else its domain decides, then whether it has a unit or reads as a number. + */ +export function ringKind(e: ResolvedEntity): 'state' | 'numeric' { + const active = (e.config as Own).active; + if ((typeof active === 'string' && active.trim() !== '') || (Array.isArray(active) && active.length > 0)) { + return 'state'; + } + if (STATE_DOMAINS.has(domainOf(e.entityId))) return 'state'; + const attrs = e.stateObj?.attributes ?? {}; + if (!e.config.attribute && (attrs.unit_of_measurement !== undefined || attrs.state_class !== undefined)) { + return 'numeric'; + } + return e.available ? 'numeric' : 'state'; +} + +/** A climate entity's state is its mode (heat all day); what it is doing is its `hvac_action`. */ +function attributeOf(e: ResolvedEntity, kind: 'state' | 'numeric'): string | undefined { + if (e.config.attribute) return e.config.attribute; + return kind === 'state' && domainOf(e.entityId) === 'climate' ? 'hvac_action' : undefined; +} + +/** The bars' aggregate: the card's, else change for meters, else a time-weighted mean. */ +function aggregateOf(e: ResolvedEntity, raw: CardConfig): Aggregate { + if (raw.aggregate !== undefined) return raw.aggregate as Aggregate; + const sc = e.stateObj?.attributes?.state_class; + return sc === 'total_increasing' || sc === 'total' ? 'change' : 'mean'; +} + +export function validateDayClock(raw: CardConfig): void { + validateTooltipOptions(raw); + parsePeriod(raw.period as CardConfig['period']); + const days = raw.days; + if (days !== undefined && (typeof days !== 'number' || !Number.isInteger(days) || days < 1 || days > MAX_DAYS)) { + throw new Error(`"days" must be a whole number from 1 to ${MAX_DAYS}`); + } + if (raw.center !== undefined && !CENTER.includes(raw.center as Center)) { + throw new Error(`"center" must be one of: ${CENTER.join(', ')}`); + } + if (raw.bucket !== undefined && !BUCKETS.includes(Number(raw.bucket) as Bucket)) { + throw new Error(`"bucket" must be one of: ${BUCKETS.join(', ')}`); + } + if (raw.aggregate !== undefined && !AGGREGATES.includes(raw.aggregate as Aggregate)) { + throw new Error(`"aggregate" must be one of: ${AGGREGATES.join(', ')}`); + } + for (const e of raw.entities ?? []) { + if (typeof e !== 'object' || e === null) continue; + const active = (e as Own).active; + if (active === undefined) continue; + const ok = + (typeof active === 'string' && active.trim() !== '') || + (Array.isArray(active) && active.every((s) => typeof s === 'string')); + if (!ok) throw new Error('"active" must be a state or a list of states'); + } +} + +/** One ring, worked out: what it draws and what the legend says about it. */ +export interface Ring { + index: number; + entity: ResolvedEntity; + kind: 'state' | 'numeric'; + color: string; + missing: boolean; + hasData: boolean; + /** State rings on a single day: the active stretches. */ + intervals: Interval[]; + /** State rings over several days: the average day. */ + runs: AverageRun[]; + /** Numeric rings: one bar per bucket, with heights 0–1. */ + slots: Slot[]; + heights: number[]; + aggregate: Aggregate; + /** Active time (a day, on average), and switch-ons (a day). */ + totalMs: number; + count: number; + /** The last active state seen, for "heating on". */ + word?: string; + /** The legend's line under the name. */ + stat: string; +} + +export interface Clock { + status: 'loading' | 'ready' | 'error'; + error?: string; + rings: Ring[]; + /** Each day's start, then the end of the last one. */ + bounds: number[]; + days: number; + bucket: number; + live: boolean; + now: number; + period: Period; + back: number; + zone: ReturnType; +} + +/** The period the buttons step: one day, or `days` days at a time. */ +function clockPeriod(raw: CardConfig, days: number): Period { + const own = parsePeriod(raw.period as CardConfig['period']); + return days === 1 + ? { last: 1, unit: 'day', calendar: 'day', offset: own.offset, navigate: own.navigate, until: 'end' } + : { last: days, unit: 'day', offset: own.offset, navigate: own.navigate, until: 'end' }; +} + +/** A state word as the user reads it ("on", "heating"), lower case to follow a name. */ +function stateWord(hass: HomeAssistant, e: ResolvedEntity, word: string, attribute: string | undefined): string { + const s = e.stateObj; + let text = word.replace(/_/g, ' '); + if (s) { + const formatted = attribute + ? hass.formatEntityAttributeValue?.(s, attribute, word) + : hass.formatEntityState?.(s, word); + if (formatted) text = formatted; + } + return text.toLocaleLowerCase(); +} + +/** A wall-clock minute of the day as the user's clock shows it: "07:00", "7:00 AM". */ +export function minuteLabel(minutes: number, hass?: HomeAssistant): string { + const m = Math.round(minutes) % DAY_MINUTES; + const t = Date.UTC(2000, 0, 1, Math.floor(m / 60), m % 60); + return new Intl.DateTimeFormat(hass?.locale?.language ?? 'en', { + hour: 'numeric', + minute: '2-digit', + hour12: hour12Of(hass), + timeZone: 'UTC', + }).format(t); +} + +/** The dial's four labels: "00 06 12 18", or "12 AM 6 AM…" on a 12-hour clock. */ +export function dialLabels(hass?: HomeAssistant): string[] { + const language = hass?.locale?.language ?? 'en'; + let twelve = hour12Of(hass); + if (twelve === undefined) { + try { + twelve = new Intl.DateTimeFormat(language, { hour: 'numeric' }).resolvedOptions().hour12 === true; + } catch { + twelve = false; + } + } + if (!twelve) return ['00', '06', '12', '18']; + return [0, 6, 12, 18].map((h) => + new Intl.DateTimeFormat(language, { hour: 'numeric', hour12: true, timeZone: 'UTC' }).format( + Date.UTC(2000, 0, 1, h), + ), + ); +} + +function valueText(v: number, e: ResolvedEntity, hass: HomeAssistant): string { + return withUnit(formatNumber(v, e.decimals, hass), e.unit); +} + +function timesText(hass: HomeAssistant, n: number): string { + const rounded = Math.round(n * 10) / 10; + return rounded === 1 ? localize(hass, 'day_clock_times_one') : localize(hass, 'day_clock_times', { n: rounded }); +} + +/** Load the card's history and work out every ring. */ +export function dayClock(ctx: ChartContext, now = Date.now()): Clock { + const { raw, hass } = ctx; + const days = (raw.days as number | undefined) ?? 1; + const bucket = Number(raw.bucket ?? 60); + const period = clockPeriod(raw, days); + const back = Math.max(0, period.offset + (period.navigate ? stepsFor(ctx.host, period) : 0)); + const zone = zoneFor(hass); + const bounds = clockDays(now, zone, days, back); + const live = back === 0; + const end = bounds[bounds.length - 1]; + const until = Math.min(end, now); + const kinds = ctx.items.map(ringKind); + const requests: SeriesRequest[] = ctx.items.map((e, i) => ({ + entity: e.entityId, + attribute: attributeOf(e, kinds[i]), + text: true, + })); + const store = storeFor(ctx.host, ctx.requestUpdate); + const loaded = store.get(hass, requests, { start: bounds[0], end: live ? now : end }, 'history', { live }); + const range = { start: bounds[0], end: until }; + // Days that have begun: the average is per day of those, today counting as one once it starts. + const daysSoFar = Math.max(1, bounds.slice(0, -1).filter((b) => b < until).length); + const rings = ctx.items.map((entity, index): Ring => { + const kind = kinds[index]; + const own = entity.config as typeof entity.config & Own; + // A missing entity has no history worth drawing: an empty ring and "Entity not found". + const points = entity.stateObj ? (loaded.data.get(key(requests[index])) ?? []) : []; + const ring: Ring = { + index, + entity, + kind, + color: baseColor(own.color ?? SERIES_COLORS[index % SERIES_COLORS.length]), + missing: !entity.stateObj, + hasData: false, + intervals: [], + runs: [], + slots: [], + heights: [], + aggregate: aggregateOf(entity, raw), + totalMs: 0, + count: 0, + stat: '', + }; + if (kind === 'state') { + const active = activeSet(own.active); + const spans = stateIntervals(points, range, active); + ring.hasData = spans.known.length > 0; + ring.word = spans.word; + const on = spans.active; + if (days === 1) ring.intervals = on; + else ring.runs = averageDay(on, spans.known, bounds, zone.timeZone); + ring.totalMs = totalMs(on) / daysSoFar; + let count = 0; + for (let d = 0; d + 1 < bounds.length; d++) { + count += switchOns(clip(on, { start: bounds[d], end: bounds[d + 1] })); + } + ring.count = count / daysSoFar; + // Over several days, each figure is a day's average: "30 m a day · 0.7 times a day". + const daily = (text: string) => (days === 1 ? text : localize(hass, 'day_clock_a_day', { text })); + const parts = [daily(formatClockDuration(ring.totalMs))]; + if (ring.count > 0) parts.push(daily(timesText(hass, ring.count))); + ring.stat = parts.join(' · '); + } else { + const numbers: Point[] = points.map((p) => ({ ...p, v: toReading(p.s), s: undefined })); + ring.slots = foldSlots(numbers, bounds, bucket, ring.aggregate, until, zone.timeZone); + const additive = isAdditive(ring.aggregate); + ring.heights = barHeights(ring.slots, additive); + ring.hasData = ring.slots.length > 0; + if (ring.hasData) { + const top = ring.slots.reduce((a, b) => (b.v > a.v ? b : a)); + const time = minuteLabel(top.index * bucket, hass); + if (additive) { + const total = valueText( + ring.slots.reduce((a, s) => a + s.v, 0), + entity, + hass, + ); + ring.stat = localize(hass, 'day_clock_busiest', { + total: days === 1 ? total : localize(hass, 'day_clock_a_day', { text: total }), + time, + }); + } else { + ring.stat = localize(hass, 'day_clock_peak', { value: valueText(top.v, entity, hass), time }); + } + } + } + if (ring.missing) ring.stat = localize(hass, 'entity_not_found'); + else if (!ring.hasData && loaded.status === 'ready') ring.stat = localize(hass, 'day_clock_no_data'); + return ring; + }); + return { + status: loaded.status, + error: loaded.error, + rings, + bounds, + days, + bucket, + live, + now, + period, + back, + zone, + }; +} + +/* ------------------------------------------------------------------ hover */ + +/** Which arc or bar the pointer (or focus) is on. */ +interface HoverState { + ring?: number; + item?: number; + timer?: ReturnType; +} +const hovers = new WeakMap(); + +function hoverOf(host: HTMLElement): HoverState { + let h = hovers.get(host); + if (!h) { + h = {}; + hovers.set(host, h); + } + return h; +} + +function setHover(ctx: ChartContext, ring: number | undefined, item: number | undefined): void { + const h = hoverOf(ctx.host); + if (h.timer !== undefined) clearTimeout(h.timer); + h.timer = undefined; + if (h.ring === ring && h.item === item) return; + h.ring = ring; + h.item = item; + ctx.requestUpdate(); +} + +function leave(ctx: ChartContext, ev: Event): void { + const touch = (ev as PointerEvent).pointerType === 'touch' || (ev as PointerEvent).pointerType === 'pen'; + if (!touch) { + setHover(ctx, undefined, undefined); + return; + } + const h = hoverOf(ctx.host); + if (h.timer !== undefined) clearTimeout(h.timer); + h.timer = setTimeout(() => { + h.timer = undefined; + setHover(ctx, undefined, undefined); + }, TOUCH_LINGER_MS); +} + +/* ------------------------------------------------------------------ live hand */ + +const ticks = new WeakMap>(); + +/** Redraw at the start of the next minute, so the hand and the dimmed future keep up. */ +function tickNextMinute(ctx: ChartContext, now: number): void { + if (ticks.has(ctx.host)) return; + const wait = 60_000 - (now % 60_000) + 50; + ticks.set( + ctx.host, + setTimeout(() => { + ticks.delete(ctx.host); + // Hidden tabs skip the redraw; the next visible minute catches up. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') tickNextMinute(ctx, Date.now()); + else ctx.requestUpdate(); + }, wait), + ); +} + +function stopTick(host: HTMLElement): void { + const t = ticks.get(host); + if (t !== undefined) clearTimeout(t); + ticks.delete(host); +} + +/* ------------------------------------------------------------------ drawing */ + +/** The dial's drawing box: centre at 0, 0. Wider than tall so side labels fit. */ +const VIEW = { x: -132, y: -120, w: 264, h: 240 }; +const OUTER = 92; + +interface Mark { + d: string; + /** Mid-angle and radius, for placing the tooltip. */ + angle: number; + r: number; + opacity?: number; + /** The interval, run or slot it draws (its index in the ring's list). */ + item: number; +} + +function stateMarks(ring: Ring, clock: Clock, r0: number, r1: number): Mark[] { + const tz = clock.zone.timeZone; + if (clock.days === 1) { + return ring.intervals.map((iv, item) => { + const spans = wallSpans(iv, clock.bounds[0], tz); + const d = spans.map(([a, b]) => sectorPath(r0, r1, minuteAngle(a), minuteAngle(b))).join(''); + const mid = spans.length ? (spans[0][0] + spans[spans.length - 1][1]) / 2 : 0; + return { d, angle: minuteAngle(mid), r: r1, item }; + }); + } + return ring.runs.map((run, item) => ({ + d: sectorPath(r0, r1, minuteAngle(run.from), minuteAngle(run.to)), + angle: minuteAngle((run.from + run.to) / 2), + r: r1, + opacity: Math.max(0.12, run.on / Math.max(1, run.of)), + item, + })); +} + +function barMarks(ring: Ring, clock: Clock, r0: number, r1: number): Mark[] { + const per = DAY_MINUTES / clock.bucket; + // A small gap either side of each bar, less when the bars are thin. + const pad = Math.min(0.03, TAU / per / 8); + const additive = isAdditive(ring.aggregate); + const marks: Mark[] = []; + ring.slots.forEach((slot, i) => { + // An hour with nothing added (no motion, no energy) has no bar, as on a bar chart. + if (additive && slot.v === 0) return; + const a0 = (slot.index / per) * TAU + pad; + const a1 = ((slot.index + 1) / per) * TAU - pad; + const r = r0 + Math.max(0.04, ring.heights[i]) * (r1 - r0); + marks.push({ d: sectorPath(r0, r, a0, a1), angle: (a0 + a1) / 2, r, item: i }); + }); + return marks; +} + +/** The tooltip's lines for the hovered arc or bar. */ +function tipText(ring: Ring, item: number, clock: Clock, hass: HomeAssistant): { time: string; value: string } { + const range = (from: string, to: string) => localize(hass, 'day_clock_range', { from, to }); + if (ring.kind === 'numeric') { + const slot = ring.slots[item]; + const time = range( + minuteLabel(slot.index * clock.bucket, hass), + minuteLabel((slot.index + 1) * clock.bucket, hass), + ); + const value = valueText(slot.v, ring.entity, hass); + return { time, value: clock.days === 1 ? value : localize(hass, 'day_clock_average_value', { value }) }; + } + if (clock.days > 1) { + const run = ring.runs[item]; + return { + time: range(minuteLabel(run.from, hass), minuteLabel(run.to, hass)), + value: localize(hass, 'day_clock_on_days', { on: run.on, of: run.of }), + }; + } + const iv = ring.intervals[item]; + const running = clock.live && iv.end >= clock.now - 1000; + const to = running ? localize(hass, 'day_clock_now').toLocaleLowerCase() : formatTime(iv.end, hass); + const duration = formatClockDuration(iv.end - iv.start); + return { + time: range(formatTime(iv.start, hass), to), + value: running ? localize(hass, 'day_clock_so_far', { duration }) : duration, + }; +} + +function tooltip(ctx: ChartContext, clock: Clock, marks: Mark[][]): TemplateResult | typeof nothing { + const h = hovers.get(ctx.host); + if (h?.ring === undefined || h.item === undefined) return nothing; + const ring = clock.rings[h.ring]; + const mark = marks[h.ring]?.[h.item]; + if (!ring || !mark) return nothing; + const { time, value } = tipText(ring, mark.item, clock, ctx.hass); + const [x, y] = polar(mark.r + 4, mark.angle); + const left = ((x - VIEW.x) / VIEW.w) * 100; + const top = ((y - VIEW.y) / VIEW.h) * 100; + // Hang the box inwards, over the dial, so it stays inside the card even when the dial fills it. + const side = `${x < 0 ? 'tip-right' : 'tip-left'} ${y < 0 ? 'tip-down' : 'tip-up'}`; + return html``; +} + +/** + * "Heating on", "Alex home": a name and a state word. A name that already says the state + * ("Heating" heating) reads "Heating on" instead. + */ +function stateLabel(hass: HomeAssistant, name: string, word: string): string { + const state = name.toLocaleLowerCase().includes(word.toLocaleLowerCase()) ? localize(hass, 'day_clock_on') : word; + return localize(hass, 'day_clock_state', { name, state }); +} + +/** The current state of a ring's entity, as "Heating on" or "Power 1.2 kW". */ +function nowText(ring: Ring, hass: HomeAssistant): string { + const e = ring.entity; + if (!e.stateObj) return e.display; + if (ring.kind === 'numeric') return localize(hass, 'day_clock_state', { name: e.name, state: e.display }); + const attribute = attributeOf(e, 'state'); + const raw = attribute ? e.stateObj.attributes?.[attribute] : e.stateObj.state; + if (typeof raw !== 'string') return localize(hass, 'day_clock_state', { name: e.name, state: e.display }); + return stateLabel(hass, e.name, stateWord(hass, e, raw, attribute)); +} + +/** The ring the "now" line describes: the first entity that exists. */ +function firstFound(clock: Clock): Ring | undefined { + return clock.rings.find((r) => !r.missing); +} + +/** What the middle of the dial says: a big line and a small one, or nothing. */ +function centreText(ctx: ChartContext, clock: Clock): [string, string] | undefined { + const center = (ctx.raw.center as Center | undefined) ?? 'total'; + if (center === 'none' || clock.rings.length === 0) return undefined; + const { hass } = ctx; + const found = firstFound(clock); + if (center === 'now' && clock.live && clock.days === 1 && found) { + return [formatTime(clock.now, hass), nowText(found, hass)]; + } + const first = clock.rings.find((r) => r.kind === 'state' && r.hasData); + if (first) { + const word = first.word + ? stateWord(hass, first.entity, first.word, attributeOf(first.entity, 'state')) + : localize(hass, 'day_clock_on'); + const label = stateLabel(hass, first.entity.name, word); + return [ + formatClockDuration(first.totalMs), + clock.days === 1 ? label : localize(hass, 'day_clock_per_day', { label }), + ]; + } + const bars = clock.rings.find((r) => r.kind === 'numeric' && r.hasData); + if (!bars) return undefined; + const total = isAdditive(bars.aggregate) + ? valueText( + bars.slots.reduce((a, s) => a + s.v, 0), + bars.entity, + hass, + ) + : bars.entity.display; + return [total, bars.entity.name]; +} + +/** Fit text in the hole: a font size (in dial units) and the text, cut short with an ellipsis if need be. */ +export function fitText(text: string, width: number, max: number): { size: number; text: string } { + // About 0.56 em per character in the card's sans-serif. + const size = Math.min(max, width / Math.max(1, text.length * 0.56)); + const floor = max * 0.62; + if (size >= floor) return { size, text }; + const chars = Math.max(3, Math.floor(width / (floor * 0.56)) - 1); + return { size: floor, text: `${text.slice(0, chars).trimEnd()}…` }; +} + +function centre(ctx: ChartContext, clock: Clock, hole: number): SVGTemplateResult | typeof nothing { + const lines = centreText(ctx, clock); + if (!lines) return nothing; + const maxBig = Math.min(18, hole * 0.4); + const maxSmall = Math.min(11, hole * 0.25); + // The two lines are a block (value, then label) centred in the hole. Each may be as wide as the + // hole is at the block's top and bottom edge, less a margin, so neither reaches the rings or the + // hand (which starts at the hole's edge). + const half = (1.05 * maxBig + 1.3 * maxSmall) / 2; + const width = 2 * Math.sqrt(Math.max(0, hole * hole - half * half)) - 8; + const big = fitText(lines[0], width, maxBig); + const small = fitText(lines[1], width, maxSmall); + // Value baseline at 0 (its glyphs reach up about 0.75 em), label baseline below it. + const labelY = 0.3 * big.size + small.size; + const shift = (-0.75 * big.size + labelY + 0.3 * small.size) / 2; + return svg``; +} + +function dial(ctx: ChartContext, clock: Clock): TemplateResult { + const layout = ringLayout( + clock.rings.map((r) => r.kind), + OUTER, + ); + const tip = tooltipEnabled(ctx.raw); + const marks = clock.rings.map((ring, i) => { + const { r0, r1 } = layout.rings[i]; + return ring.kind === 'state' ? stateMarks(ring, clock, r0, r1) : barMarks(ring, clock, r0, r1); + }); + const h = hovers.get(ctx.host); + const rings = clock.rings.map((ring, i) => { + const { r0, r1 } = layout.rings[i]; + const focusable = clock.days === 1 && marks[i].length <= MAX_FOCUSABLE; + const track = + ring.kind === 'state' + ? svg`` + : svg``; + return svg` + ${track} + ${marks[i].map( + (m, j) => + svg` setHover(ctx, i, j) : nothing} + @pointerdown=${tip ? () => setHover(ctx, i, j) : nothing} + @pointerleave=${tip ? (ev: Event) => leave(ctx, ev) : nothing} + @focus=${tip ? () => setHover(ctx, i, j) : nothing} + @blur=${tip ? () => setHover(ctx, undefined, undefined) : nothing} + >`, + )} + `; + }); + const labels = dialLabels(ctx.hass); + const LABEL_R = OUTER + 15; + const tickMarks = Array.from({ length: 24 }, (_, hour) => { + const a = (hour / 24) * TAU; + const major = hour % 6 === 0; + const [x0, y0] = polar(OUTER + 3, a); + const [x1, y1] = polar(OUTER + (major ? 9 : 6), a); + return svg``; + }); + const dialText = svg` + ${labels[0]} + ${labels[1]} + ${labels[2]} + ${labels[3]}`; + let hand: SVGTemplateResult | typeof nothing = nothing; + if (clock.live && clock.days === 1) { + const a = minuteAngle(wallMinute(clock.now, clock.bounds[0], clock.zone.timeZone)); + const [x0, y0] = polar(layout.hole, a); + const [x1, y1] = polar(OUTER + 9, a); + hand = svg` + + + `; + } + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_day_clock'), + names: clock.rings.map((r) => r.entity.name).join(', '), + }); + return html`
+ + ${tickMarks}${dialText} + ${rings} + ${hand} ${centre(ctx, clock, layout.hole)} + + ${tip ? tooltip(ctx, clock, marks) : nothing} +
`; +} + +function legend(ctx: ChartContext, clock: Clock): TemplateResult { + const { hass } = ctx; + let footer: TemplateResult | typeof nothing = nothing; + if (clock.days > 1) { + footer = html`
+
${localize(hass, 'day_clock_average', { n: clock.days })}
+
`; + } else if (clock.live && firstFound(clock)) { + footer = html`
+
${localize(hass, 'day_clock_now')}
+
${formatTime(clock.now, hass)} · ${nowText(firstFound(clock) as Ring, hass)}
+
`; + } + return html`
+ ${clock.rings.map( + (ring) => + html`
+ +
+
${ring.entity.name}
+
${ring.stat}
+
+
`, + )} + ${footer} +
`; +} + +function message(nav: TemplateResult | typeof nothing, text: string, error = false): TemplateResult { + return html`
+ ${nav} +
${text}
+
`; +} + +export const day_clock: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 280, + styles: css` + ${frameStyles} + ${tooltipStyles} + .dc-body { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + padding-bottom: 4px; + } + .dc-dial { + position: relative; + flex: 0 1 280px; + min-width: 200px; + aspect-ratio: 264 / 240; + } + .dc-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .dc-track { + fill: var(--cd-track); + } + .dc-base { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-width: 1; + } + .dc-mark { + outline: none; + transition: fill-opacity 0.15s ease; + } + .dc-ring:hover .dc-mark:not(.hover), + .dc-ring:focus-within .dc-mark:not(.hover) { + fill-opacity: 0.75; + } + .dc-mark.hover { + stroke: var(--primary-text-color); + stroke-width: 1.2; + paint-order: stroke; + } + .dc-ring { + cursor: pointer; + outline: none; + } + .dc-ring:focus-visible .dc-track, + .dc-ring:focus-visible .dc-base { + stroke: var(--primary-color); + stroke-width: 1.5; + } + .dc-tick { + stroke: var(--secondary-text-color); + stroke-opacity: 0.5; + stroke-width: 1; + } + .dc-tick.major { + stroke-opacity: 1; + stroke-width: 1.5; + } + .dc-label { + fill: var(--secondary-text-color); + font-size: 10px; + } + .dc-future { + fill: var(--card-background-color, #fff); + fill-opacity: 0.55; + pointer-events: none; + } + .dc-hand { + stroke: var(--primary-text-color); + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; + } + .dc-hand-dot { + fill: var(--primary-text-color); + } + .dc-centre text { + text-anchor: middle; + pointer-events: none; + } + .dc-centre-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .dc-centre-label { + fill: var(--secondary-text-color); + } + .dc-marks { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .dc-marks { + opacity: 1; + } + :host([static]) .dc-marks, + :host([static]) .dc-mark { + transition: none; + } + .dc-legend { + flex: 1 1 160px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .dc-item { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; + } + .dc-swatch { + flex: none; + width: 10px; + height: 10px; + border-radius: 2px; + } + .dc-text { + min-width: 0; + } + .dc-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dc-stat { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .dc-item.missing .dc-name { + color: var(--secondary-text-color); + } + .dc-foot { + margin-top: 4px; + } + .dc-now { + font-weight: 500; + font-size: 1.1em; + } + .dc-tip { + position: absolute; + pointer-events: none; + z-index: 1; + white-space: nowrap; + } + .dc-tip.tip-right { + transform: translate(6px, 0); + } + .dc-tip.tip-left { + transform: translate(calc(-100% - 6px), 0); + } + .dc-tip.tip-right.tip-up { + transform: translate(6px, -100%); + } + .dc-tip.tip-left.tip-up { + transform: translate(calc(-100% - 6px), -100%); + } + `, + thumbnail: () => svg` + + + + + + `, + validate: validateDayClock, + editor: dayClockEditor, + entityFields: dayClockEntityFields, + disconnect(host) { + disconnectHistory(host); + stopTick(host); + const h = hovers.get(host); + if (h?.timer !== undefined) clearTimeout(h.timer); + hovers.delete(host); + }, + render(ctx) { + const clock = dayClock(ctx); + if (clock.live) tickNextMinute(ctx, clock.now); + const nav = periodNav( + { + period: clock.period, + range: { start: clock.bounds[0], end: clock.bounds[clock.bounds.length - 1] }, + back: clock.back, + zone: clock.zone, + } as HistoryFrame, + ctx, + clock.now, + ); + if (clock.status === 'loading') return message(nav, localize(ctx.hass, 'chart_loading')); + if (clock.status === 'error') { + return message(nav, localize(ctx.hass, 'chart_error', { error: clock.error ?? '' }), true); + } + if (clock.rings.every((r) => !r.hasData)) return message(nav, localize(ctx.hass, 'chart_no_data')); + return html`
+ ${nav} +
${dial(ctx, clock)} ${legend(ctx, clock)}
+
`; + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 90e9229..4f5d510 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -42,6 +42,7 @@ import { sankey } from './sankey'; import { timeline } from './timeline'; import { climate } from './climate'; import { windRoseStyle } from './wind-rose'; +import { day_clock } from './day-clock'; export interface RenderContext { show: ShowOptions; @@ -173,4 +174,5 @@ export const STYLES: Record = { timeline, climate, wind_rose: windRoseStyle, + day_clock, }; diff --git a/src/types.ts b/src/types.ts index 7eb8f68..a16de94 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose' | 'day_clock'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose', 'day_clock']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/day-clock.test.ts b/test/day-clock.test.ts new file mode 100644 index 0000000..8ff3b81 --- /dev/null +++ b/test/day-clock.test.ts @@ -0,0 +1,711 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + DEFAULT_ACTIVE, + activeSet, + averageDay, + barHeights, + bucketBounds, + clip, + clockDays, + foldSlots, + formatClockDuration, + isGap, + minuteAngle, + polar, + ringLayout, + sectorPath, + stateIntervals, + switchOns, + totalMs, + wallMinute, + wallSpans, +} from '../src/chart/day-clock'; +import { DAY_CLOCK_FIELDS, dayClockEntityFields } from '../src/editor/day-clock-editor'; +import { startOf } from '../src/history/timezone'; +import type { Point } from '../src/history/types'; +import { day_clock, dialLabels, fitText, minuteLabel, ringKind, validateDayClock } from '../src/styles/day-clock'; +import { STYLES } from '../src/styles/index'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import type { ResolvedEntity } from '../src/resolve'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const M = 60_000; +const H = 3_600_000; +const LONDON = 'Europe/London'; +const zone = { timeZone: LONDON, firstDay: 1 }; +const iso = (s: string) => Date.parse(s); +const txt = (...rows: [number, string | undefined][]): Point[] => rows.map(([t, s]) => ({ t, v: null, s })); + +describe('state intervals', () => { + // 29 Oct 2026 is GMT, so wall clock = UTC. + const midnight = iso('2026-10-29T00:00:00Z'); + const now = iso('2026-10-29T15:40:00Z'); + const on = new Set(['on']); + + it('clips an interval running over midnight from yesterday, and keeps one still running at now', () => { + // The store hands over the reading from before the range moved to its start, marked carried. + const points: Point[] = [ + { t: midnight, v: null, s: 'on', carried: true }, + ...txt([midnight + 1 * H, 'off'], [midnight + 6 * H, 'on'], [midnight + 7 * H, 'off'], [midnight + 15 * H, 'on']), + ]; + const spans = stateIntervals(points, { start: midnight, end: now }, on); + expect(spans.active).toEqual([ + { start: midnight, end: midnight + H, carried: true }, + { start: midnight + 6 * H, end: midnight + 7 * H }, + { start: midnight + 15 * H, end: now }, + ]); + expect(totalMs(spans.active)).toBe(2 * H + 40 * M); + // Already on at midnight is not a switch-on today. + expect(switchOns(spans.active)).toBe(2); + expect(spans.word).toBe('on'); + }); + + it('treats a point earlier than the range as carried, and skips gaps', () => { + const points = txt( + [midnight - 2 * H, 'on'], + [midnight + H, 'unavailable'], + [midnight + 2 * H, 'on'], + [midnight + 3 * H, 'unknown'], + ); + const spans = stateIntervals(points, { start: midnight, end: midnight + 4 * H }, on); + expect(spans.active).toEqual([ + { start: midnight, end: midnight + H, carried: true }, + { start: midnight + 2 * H, end: midnight + 3 * H }, + ]); + expect(spans.known).toEqual(spans.active); + expect(isGap(undefined)).toBe(true); + expect(isGap('')).toBe(true); + expect(isGap('off')).toBe(false); + }); + + it('joins repeats and honours a custom active set', () => { + const points = txt([0, 'heat'], [10, 'heat'], [20, 'idle'], [30, 'cool']); + expect(stateIntervals(points, { start: 0, end: 40 }, new Set(['heat', 'cool'])).active).toEqual([ + { start: 0, end: 20 }, + { start: 30, end: 40 }, + ]); + }); + + it('clips to a range, marking a cut start as carried', () => { + expect( + clip( + [ + { start: 0, end: 10 }, + { start: 20, end: 30 }, + { start: 40, end: 50 }, + ], + { start: 5, end: 25 }, + ), + ).toEqual([ + { start: 5, end: 10, carried: true }, + { start: 20, end: 25 }, + ]); + }); + + it('reads the active option as one state, a list, or the defaults', () => { + expect([...activeSet('heating')]).toEqual(['heating']); + expect([...activeSet([' on ', 'open', 3])]).toEqual(['on', 'open']); + expect([...activeSet([])]).toEqual(DEFAULT_ACTIVE); + expect(activeSet(undefined).has('home')).toBe(true); + }); +}); + +describe('the dial and the clock changes', () => { + it('maps a plain day minute by minute', () => { + const midnight = iso('2026-10-29T00:00:00Z'); + expect(wallMinute(midnight + 90 * M, midnight, LONDON)).toBe(90); + expect(wallSpans({ start: midnight + 6 * H, end: midnight + 8 * H }, midnight, LONDON)).toEqual([[360, 480]]); + expect(minuteAngle(360)).toBeCloseTo(Math.PI / 2); + }); + + it('on the 23-hour day, the skipped hour is left empty and the arc keeps its real length', () => { + const bounds = clockDays(iso('2026-03-29T12:00:00Z'), zone, 1, 0); + expect(bounds).toEqual([iso('2026-03-29T00:00:00Z'), iso('2026-03-29T23:00:00Z')]); + // 00:30 GMT to 03:30 BST: two hours of real time, drawn 00:30–01:00 and 02:00–03:30. + const spans = wallSpans( + { start: iso('2026-03-29T00:30:00Z'), end: iso('2026-03-29T02:30:00Z') }, + bounds[0], + LONDON, + ); + expect(spans).toEqual([ + [30, 60], + [120, 210], + ]); + // The day still ends at the top of the dial. + expect(wallSpans({ start: bounds[1] - H, end: bounds[1] }, bounds[0], LONDON)).toEqual([[1380, 1440]]); + }); + + it('on the 25-hour day, the repeated hour draws over itself', () => { + const bounds = clockDays(iso('2026-10-25T12:00:00Z'), zone, 1, 0); + expect(bounds[1] - bounds[0]).toBe(25 * H); + // 00:30 BST to 02:30 GMT: three hours, 01:00–02:00 twice. + const spans = wallSpans( + { start: iso('2026-10-24T23:30:00Z'), end: iso('2026-10-25T02:30:00Z') }, + bounds[0], + LONDON, + ); + expect(spans).toEqual([ + [30, 120], + [60, 150], + ]); + }); + + it('lists N whole days back from today, stepping back N days at a time', () => { + const now = iso('2026-10-29T15:40:00Z'); + const week = clockDays(now, zone, 7, 0); + expect(week).toHaveLength(8); + expect(week[6]).toBe(iso('2026-10-29T00:00:00Z')); + expect(week[7]).toBe(iso('2026-10-30T00:00:00Z')); + // Over the clock change: 25 Oct is 25 hours long. + expect(week[0]).toBe(iso('2026-10-22T23:00:00Z')); + expect(clockDays(now, zone, 7, 1)[7]).toBe(week[0]); + expect(clockDays(now, zone, 1, 1)).toEqual([iso('2026-10-28T00:00:00Z'), iso('2026-10-29T00:00:00Z')]); + }); + + it('draws sectors, whole rings and polar points', () => { + expect(polar(10, 0)).toEqual([0, -10]); + expect(polar(10, Math.PI / 2)).toEqual([10, 0]); + expect(sectorPath(5, 10, 0, Math.PI / 2)).toBe('M0 -10A10 10 0 0 1 10 0L5 0A5 5 0 0 0 0 -5Z'); + expect(sectorPath(0, 10, 0, 4)).toMatch(/L0 0Z$/); + expect(sectorPath(5, 10, 0, Math.PI * 2).match(/M/g)).toHaveLength(2); + expect(sectorPath(0, 10, 0, Math.PI * 2).match(/M/g)).toHaveLength(1); + expect(sectorPath(5, 10, 1, 1)).toBe(''); + }); +}); + +describe('the average day', () => { + const days = [0, 1, 2, 3].map((d) => iso('2026-10-26T00:00:00Z') + d * 24 * H); + + it('counts, for each minute, the days it was on out of the days it was known', () => { + const at = (d: number, h: number) => days[d] + h * H; + const active = [ + { start: at(0, 7), end: at(0, 8) }, + { start: at(1, 7), end: at(1, 7.5) }, + ]; + const known = [{ start: days[0], end: days[3] }]; + expect(averageDay(active, known, days, LONDON)).toEqual([ + { from: 420, to: 450, on: 2, of: 3 }, + { from: 450, to: 480, on: 1, of: 3 }, + ]); + }); + + it('leaves out days a minute was not known (unrecorded, or not yet happened)', () => { + const active = [ + { start: days[0] + 7 * H, end: days[0] + 8 * H }, + { start: days[2] + 7 * H, end: days[2] + 8 * H }, + ]; + // Day 1 unrecorded; day 2 only known until 07:30. + const known = [ + { start: days[0], end: days[1] }, + { start: days[2], end: days[2] + 7.5 * H }, + ]; + expect(averageDay(active, known, days, LONDON)).toEqual([ + { from: 420, to: 450, on: 2, of: 2 }, + { from: 450, to: 480, on: 1, of: 1 }, + ]); + }); +}); + +describe('bars by the hour', () => { + const midnight = iso('2026-10-29T00:00:00Z'); + const day = [midnight, midnight + 24 * H]; + const num = (...rows: [number, number | null][]): Point[] => rows.map(([t, v]) => ({ t, v })); + + it('buckets by real time from the day start', () => { + expect(bucketBounds({ start: 0, end: 2 * H }, 30)).toEqual([0, 30 * M, 60 * M, 90 * M, 2 * H]); + }); + + it('takes a time-weighted mean per hour, up to now', () => { + const points = num([midnight, 10], [midnight + 30 * M, 20], [midnight + 2 * H, 40]); + const slots = foldSlots(points, day, 60, 'mean', midnight + 3 * H, LONDON); + expect(slots).toEqual([ + { index: 0, v: 15, days: 1 }, + { index: 1, v: 20, days: 1 }, + { index: 2, v: 40, days: 1 }, + ]); + }); + + it('sums a meter’s change per half hour', () => { + const points = num([midnight, 100], [midnight + 10 * M, 101], [midnight + 40 * M, 103.5]); + const slots = foldSlots(points, day, 30, 'change', midnight + 60 * M, LONDON); + expect(slots.map((s) => [s.index, s.v])).toEqual([ + [0, 1], + [1, 2.5], + ]); + }); + + it('adds the two 01:00 hours of the 25-hour day together for a total, and averages them for a mean', () => { + const bounds = clockDays(iso('2026-10-25T12:00:00Z'), zone, 1, 0); + const first = iso('2026-10-25T00:00:00Z'); // 01:00 BST + const second = iso('2026-10-25T01:00:00Z'); // 01:00 GMT + const meter = num([first, 0], [first + 30 * M, 2], [second + 30 * M, 5]); + const change = foldSlots(meter, bounds, 60, 'change', bounds[1], LONDON); + expect(change.find((s) => s.index === 1)?.v).toBe(5); + const temp = num([first, 10], [second, 20], [second + H, 30]); + const mean = foldSlots(temp, bounds, 60, 'mean', second + H + M, LONDON); + expect(mean.find((s) => s.index === 1)?.v).toBe(15); + }); + + it('averages each hour across days', () => { + const two = [midnight - 24 * H, midnight, midnight + 24 * H]; + const points = num([two[0], 10], [two[0] + H, null], [midnight, 30], [midnight + H, null]); + const slots = foldSlots(points, two, 60, 'mean', midnight + 2 * H, LONDON); + expect(slots).toEqual([{ index: 0, v: 20, days: 2 }]); + expect(foldSlots(points, two, 60, 'max', midnight + 2 * H, LONDON)[0].v).toBe(20); + }); + + it('scales totals from zero and levels from lowest to highest', () => { + const slots = [5, 10, 0].map((v, index) => ({ index, v, days: 1 })); + expect(barHeights(slots, true)).toEqual([0.5, 1, 0]); + expect(barHeights(slots, false)).toEqual([0.15 + 0.85 * 0.5, 1, 0.15]); + expect(barHeights([{ index: 0, v: 3, days: 1 }], false)).toEqual([1]); + expect(barHeights([], true)).toEqual([]); + expect(barHeights([{ index: 0, v: 0, days: 1 }], true)).toEqual([0]); + }); +}); + +describe('layout and text', () => { + it('stacks rings from the outside in and keeps a hole for the centre', () => { + const one = ringLayout(['state']); + expect(one.rings).toEqual([{ r0: 77, r1: 92 }]); + const many = ringLayout(['state', 'numeric', 'state', 'numeric', 'state', 'state']); + expect(many.hole).toBeCloseTo(44); + expect(many.rings[0].r1).toBe(92); + expect(ringLayout([]).hole).toBe(92); + }); + + it('formats durations as the dial shows them', () => { + expect(formatClockDuration(4 * H + 10 * M)).toBe('4 h 10 m'); + expect(formatClockDuration(2 * H)).toBe('2 h'); + expect(formatClockDuration(25 * M)).toBe('25 m'); + expect(formatClockDuration(-5)).toBe('0 m'); + }); + + it('fits centre text, cutting it short only when it would get too small', () => { + expect(fitText('4 h 10 m', 80, 17)).toEqual({ size: 17, text: '4 h 10 m' }); + const cut = fitText('A very long name for a radiator valve on', 60, 11); + expect(cut.size).toBeCloseTo(11 * 0.62); + expect(cut.text.endsWith('…')).toBe(true); + }); + + it('labels the dial on a 24- or 12-hour clock', () => { + const h24 = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '24' } }); + const h12 = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '12' } }); + expect(dialLabels(h24)).toEqual(['00', '06', '12', '18']); + expect(dialLabels(h12)).toEqual(['12 AM', '6 AM', '12 PM', '6 PM']); + expect(dialLabels(makeHass({}, { locale: { language: 'en-GB', number_format: 'language' } }))).toEqual([ + '00', + '06', + '12', + '18', + ]); + expect(minuteLabel(420, h24)).toBe('07:00'); + expect(minuteLabel(1440, h24)).toBe('00:00'); + expect(minuteLabel(810, h12)).toBe('1:30 PM'); + }); + + it('decides arcs or bars from the domain, the active option, the unit and the value', () => { + const e = (id: string, extra: Partial = {}, attrs: Record = {}) => + ({ + entityId: id, + config: { entity: id }, + stateObj: { entity_id: id, state: '1', attributes: attrs }, + available: true, + ...extra, + }) as unknown as ResolvedEntity; + expect(ringKind(e('binary_sensor.door'))).toBe('state'); + expect(ringKind(e('sensor.power', {}, { unit_of_measurement: 'W' }))).toBe('numeric'); + expect(ringKind(e('sensor.mode', { available: false }))).toBe('state'); + expect(ringKind(e('sensor.count'))).toBe('numeric'); + expect(ringKind(e('sensor.flag', { config: { entity: 'sensor.flag', active: '1' } } as never))).toBe('state'); + expect(ringKind(e('sensor.flag', { config: { entity: 'sensor.flag', active: [] } } as never))).toBe('numeric'); + }); +}); + +describe('validate', () => { + const base = { type: 'x', style: 'day_clock' } as CardConfig; + it('accepts its options and rejects bad ones readably', () => { + expect(() => + validateDayClock({ ...base, days: 7, center: 'now', bucket: 30, aggregate: 'max', period: { offset: 1 } }), + ).not.toThrow(); + expect(() => validateDayClock({ ...base, bucket: '15' })).not.toThrow(); + expect(() => validateDayClock({ ...base, days: 0 })).toThrow('"days" must be a whole number from 1 to 31'); + expect(() => validateDayClock({ ...base, days: 1.5 })).toThrow('"days"'); + expect(() => validateDayClock({ ...base, center: 'middle' })).toThrow('"center" must be one of: total, now, none'); + expect(() => validateDayClock({ ...base, bucket: 20 })).toThrow('"bucket" must be one of: 15, 30, 60'); + expect(() => validateDayClock({ ...base, aggregate: 'median' })).toThrow('"aggregate" must be one of'); + expect(() => validateDayClock({ ...base, tooltip: 'yes' })).toThrow('"tooltip" must be true or false'); + expect(() => + validateDayClock({ ...base, entities: ['switch.a', { entity: 'switch.b', active: ['on'] }] }), + ).not.toThrow(); + expect(() => validateDayClock({ ...base, entities: [{ entity: 'switch.b', active: 1 }] })).toThrow( + '"active" must be a state or a list of states', + ); + }); +}); + +/* ------------------------------------------------------------------ the card */ + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const NOW = iso('2026-10-29T15:40:00Z'); +const MIDNIGHT = iso('2026-10-29T00:00:00Z'); +const s = (t: number) => t / 1000; + +function hassFor( + history: Record, + extra: Record]> = {}, +) { + const hass = makeHass( + { + 'climate.house': ['heat', { friendly_name: 'Heating', hvac_action: 'heating' }], + 'switch.water': ['off', { friendly_name: 'Hot water' }], + 'sensor.motion': [ + '3', + { friendly_name: 'Motion', unit_of_measurement: 'events', state_class: 'total_increasing' }, + ], + ...extra, + }, + { + locale: { language: 'en', number_format: 'language', time_zone: 'server', time_format: '24' }, + config: { time_zone: LONDON }, + } as Partial, + ); + hass.formatEntityState = (o, st) => st ?? o.state; + hass.formatEntityAttributeValue = (_o, _a, v) => String(v); + hass.callWS = vi.fn(async (msg: { entity_ids: string[] }) => { + const out: Record = {}; + for (const id of msg.entity_ids) if (history[id]) out[id] = history[id]; + return out; + }) as unknown as HomeAssistant['callWS']; + return hass; +} + +const HISTORY = { + 'climate.house': [ + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT - H) }, + { s: 'heat', a: { hvac_action: 'idle' }, lu: s(MIDNIGHT + H) }, + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT + 6 * H) }, + { s: 'heat', a: { hvac_action: 'idle' }, lu: s(MIDNIGHT + 8 * H) }, + { s: 'heat', a: { hvac_action: 'heating' }, lu: s(MIDNIGHT + 15 * H) }, + ], + 'switch.water': [ + { s: 'off', lu: s(MIDNIGHT - H) }, + { s: 'on', lu: s(MIDNIGHT + 6 * H) }, + { s: 'unavailable', lu: s(MIDNIGHT + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT + 8 * H) }, + ], + 'sensor.motion': [ + { s: '0', lu: s(MIDNIGHT - H) }, + { s: '2', lu: s(MIDNIGHT + 7 * H + 10 * M) }, + { s: '3', lu: s(MIDNIGHT + 9 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassFor(HISTORY)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'day_clock', + entities: ['climate.house', 'switch.water', 'sensor.motion'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el?.textContent?.replace(/\s+/g, ' ').trim(); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + document.body.innerHTML = ''; + vi.useRealTimers(); +}); + +describe('24-hour clock card', () => { + it('is registered as a history chart named "24-hour clock"', () => { + expect(STYLES.day_clock).toBe(day_clock); + expect(day_clock.group).toBe('history'); + expect(day_clock.thumbnail()).toBeTruthy(); + }); + + it('draws one ring per entity: arcs for states, bars for numbers, with a hand and the dimmed future', async () => { + const card = await mount({}); + const rings = qa(card, '.dc-ring'); + expect(rings.map((r) => r.getAttribute('data-index'))).toEqual(['0', '1', '2']); + expect(rings.map((r) => r.classList.contains('numeric'))).toEqual([false, false, true]); + // Heating: carried in from yesterday, 06–08, and running since 15:00. + expect(rings[0].querySelectorAll('.dc-mark')).toHaveLength(3); + // Hot water: on 06–07, then unavailable (a gap). + expect(rings[1].querySelectorAll('.dc-mark')).toHaveLength(1); + // Motion: a meter, so bars of its change in the hours it went up (07:00 and 08:00). + expect(rings[2].querySelectorAll('.dc-mark').length).toBeGreaterThan(0); + expect(q(card, '.dc-hand')).not.toBeNull(); + expect(q(card, '.dc-future')).not.toBeNull(); + expect(text(q(card, '.dc-centre-value'))).toBe('3 h 40 m'); + expect(text(q(card, '.dc-centre-label'))).toBe('Heating on'); + expect(qa(card, '.dc-label').map((l) => l.textContent)).toEqual(['00', '06', '12', '18']); + expect(q(card, 'svg.dc-svg')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.dc-svg')!.getAttribute('aria-label')).toBe('24-hour clock chart of Heating, Hot water, Motion'); + const legend = qa(card, '.dc-item').map(text); + expect(legend[0]).toBe('Heating 3 h 40 m · 2 times'); + expect(legend[1]).toBe('Hot water 1 h · once'); + expect(legend[2]).toBe('Motion 3.0 events · busiest 07:00'); + expect(text(q(card, '.dc-now'))).toBe('15:40 · Heating on'); + // Climate rings follow what it is doing, not its mode. + const call = (card.hass!.callWS as ReturnType).mock.calls.find((c) => c[0].no_attributes === false); + expect(call?.[0].entity_ids).toEqual(['climate.house']); + }); + + it('shows a tooltip for an arc on hover or focus, and for a bar', async () => { + const card = await mount({}); + const arcs = qa(card, '.dc-ring')[0].querySelectorAll('.dc-mark'); + arcs[1].dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('06:00 – 08:00'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('2 h'); + arcs[2].dispatchEvent(new FocusEvent('focus')); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('15:00 – now'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('40 m so far'); + arcs[2].dispatchEvent(new FocusEvent('blur')); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + const bar = qa(card, '.dc-ring')[2].querySelector('.dc-mark')!; + bar.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-time'))).toBe('07:00 – 08:00'); + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('2.0 events'); + bar.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + }); + + it('keeps a touch tooltip up for a moment', async () => { + const card = await mount({}); + const arc = qa(card, '.dc-ring')[1].querySelector('.dc-mark')!; + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(NOW); + arc.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch' })); + arc.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'touch' })); + await card.updateComplete; + expect(q(card, '.dc-tip')).not.toBeNull(); + vi.advanceTimersByTime(3100); + await card.updateComplete; + expect(q(card, '.dc-tip')).toBeNull(); + }); + + it('honours per-entity colour and active states, and centre options', async () => { + const card = await mount({ + center: 'now', + entities: [ + { entity: 'switch.water', color: 'red', active: 'off' }, + { entity: 'climate.house', name: 'House' }, + ], + } as Partial); + const first = qa(card, '.dc-ring')[0]; + expect(first.querySelector('.dc-mark')!.getAttribute('fill')).toContain('--error-color'); + // Off from before midnight to 06:00, and from 08:00 on. + expect(first.querySelectorAll('.dc-mark')).toHaveLength(2); + expect(text(q(card, '.dc-centre-value'))).toBe('15:40'); + expect(text(q(card, '.dc-centre-label'))).toBe('Hot water off'); + const none = await mount({ center: 'none' }); + expect(none.shadowRoot!.querySelector('.dc-centre')).toBeNull(); + }); + + it('shows a missing entity as an empty ring, and only a message when there is nothing at all', async () => { + const card = await mount({ entities: ['switch.water', 'light.gone'] }); + const rings = qa(card, '.dc-ring'); + expect(rings).toHaveLength(2); + expect(rings[1].querySelectorAll('.dc-mark')).toHaveLength(0); + expect(text(qa(card, '.dc-item')[1])).toBe('light.gone Entity not found'); + const empty = await mount({ entities: ['light.gone'] }); + expect(text(empty.shadowRoot!.querySelector('.chart-message'))).toBe('No readings in this period.'); + expect(empty.shadowRoot!.querySelector('.dc-dial')).toBeNull(); + }); + + it('says so while loading and when loading fails', async () => { + const hass = hassFor(HISTORY); + hass.callWS = vi.fn(() => new Promise(() => undefined)) as unknown as HomeAssistant['callWS']; + const loading = await mount({}, hass); + expect(text(loading.shadowRoot!.querySelector('.chart-message'))).toBe('Loading history…'); + const failing = hassFor(HISTORY); + failing.callWS = vi.fn(async () => { + throw new Error('boom'); + }) as unknown as HomeAssistant['callWS']; + const failed = await mount({}, failing); + expect(text(failed.shadowRoot!.querySelector('.chart-message.error'))).toBe("Couldn't load history: boom"); + }); + + it('shows the average day over several days, with no hand', async () => { + const day = 24 * H; + const history = { + 'switch.water': [ + { s: 'off', lu: s(MIDNIGHT - 3 * day) }, + { s: 'on', lu: s(MIDNIGHT - 2 * day + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT - 2 * day + 8 * H) }, + { s: 'on', lu: s(MIDNIGHT - day + 7 * H) }, + { s: 'off', lu: s(MIDNIGHT - day + 7.5 * H) }, + ], + 'sensor.motion': [ + { s: '0', lu: s(MIDNIGHT - 3 * day) }, + { s: '4', lu: s(MIDNIGHT - day + 7 * H + M) }, + ], + }; + const card = await mount({ days: 3, entities: ['switch.water', 'sensor.motion'] }, hassFor(history)); + expect(q(card, '.dc-hand')).toBeNull(); + const arcs = [...qa(card, '.dc-ring')[0].querySelectorAll('.dc-mark')]; + expect(arcs.map((a) => a.getAttribute('fill-opacity'))).toEqual(['0.6666666666666666', '0.3333333333333333']); + // Arcs over several days are not each a tab stop. + expect(arcs[0].getAttribute('tabindex')).toBeNull(); + arcs[0].dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip'))).toBe('07:00 – 07:30 Hot water on 2 of 3 days'); + expect(text(qa(card, '.dc-item')[0])).toBe('Hot water 30 m a day · 0.7 times a day'); + expect(text(qa(card, '.dc-item')[1])).toBe('Motion 1.3 events a day · busiest 07:00'); + expect(text(q(card, '.dc-centre-label'))).toBe('Hot water on per day'); + expect(text(q(card, '.dc-foot'))).toBe('Average day · last 3 days'); + const bar = qa(card, '.dc-ring')[1].querySelector('.dc-mark')!; + bar.dispatchEvent(new PointerEvent('pointerenter', { pointerType: 'mouse' })); + await card.updateComplete; + expect(text(q(card, '.dc-tip .tooltip-value'))).toBe('1.3 events on average'); + }); + + it('steps back a day with the buttons, drawing the whole of yesterday', async () => { + const card = await mount({ period: { navigate: true } } as Partial); + expect(text(q(card, '.period-label'))).toBe('Today'); + (q(card, '.period-prev') as HTMLButtonElement).click(); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + expect(text(q(card, '.period-label'))).toBe('Yesterday'); + expect(q(card, '.dc-hand')).toBeNull(); + expect(q(card, '.dc-now')).toBeNull(); + }); + + it('labels numbers that are levels by their peak, and a numeric-only centre by its value', async () => { + const hass = hassFor( + { + 'sensor.temp': [ + { s: '18', lu: s(MIDNIGHT - H) }, + { s: '21', lu: s(MIDNIGHT + 7 * H) }, + { s: '19', lu: s(MIDNIGHT + 9 * H) }, + ], + }, + { 'sensor.temp': ['19', { friendly_name: 'Lounge', unit_of_measurement: '°C' }] }, + ); + const card = await mount({ entities: ['sensor.temp'], bucket: 30 } as Partial, hass); + expect(text(q(card, '.dc-item'))).toBe('Lounge peak 21.0 °C at 07:00'); + expect(qa(card, '.dc-mark')).toHaveLength(32); + expect(text(q(card, '.dc-centre-value'))).toBe('19.0 °C'); + expect(text(q(card, '.dc-centre-label'))).toBe('Lounge'); + }); + + it('moves the hand every minute and stops when the card leaves', async () => { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(NOW); + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'day_clock', entities: ['switch.water'] } as CardConfig); + card.hass = hassFor(HISTORY); + document.body.appendChild(card); + await vi.advanceTimersByTimeAsync(10); + await card.updateComplete; + const before = q(card, '.dc-hand')!.getAttribute('x2'); + await vi.advanceTimersByTimeAsync(20 * 60_000); + await card.updateComplete; + expect(q(card, '.dc-hand')!.getAttribute('x2')).not.toBe(before); + card.remove(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe('24-hour clock editor', () => { + it('shows the Chart section and writes days, centre, bucket and aggregate', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'day_clock', entities: ['switch.a'], bucket: 30 } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(form.data).toEqual({ + days: 1, + period_offset: 0, + period_navigate: false, + center: 'total', + bucket: '30', + aggregate: 'auto', + tooltip: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), days: 7, center: 'none', bucket: '15', aggregate: 'max' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, days: 7, center: 'none', bucket: 15, aggregate: 'max' }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), bucket: '60', period_navigate: true } }, + }), + ); + const last = events.at(-1)!; + expect(last.bucket).toBeUndefined(); + expect(last.period).toEqual({ navigate: true }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields.map((f) => f.path.join('.'))).toEqual(['color', 'active']); + expect(dayClockEntityFields().schema).toHaveLength(1); + expect(DAY_CLOCK_FIELDS.map((f) => f.name)).toContain('bucket'); + }); +}); + +// The interval maths runs on any start of day the zone gives. +it('finds the start of a day in the zone', () => { + expect(startOf('day', NOW, zone)).toBe(MIDNIGHT); +}); + +describe('text history (shared loader, as on the state timeline branch)', () => { + it('keeps the words of states, keyed apart from the numbers', async () => { + const { key, parseHistory, toPoint, toText } = await import('../src/history/fetch'); + expect(parseHistory([{ s: 'on', lu: 1 }], undefined, true)).toEqual([{ t: 1000, v: null, s: 'on' }]); + expect(toPoint(5, 3, true)).toEqual({ t: 5, v: null, s: '3' }); + expect(toPoint(5, '3')).toEqual({ t: 5, v: 3 }); + expect(toText(true)).toBe('true'); + expect(toText(null)).toBeUndefined(); + expect(key({ entity: 'a', text: true })).toBe('a|text'); + expect(key({ entity: 'a', attribute: 'b', text: true })).toBe('a|b|text'); + }); +}); From 5e5bf86786666cc91383bca797531b47e407f77e Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 23:57:23 +0000 Subject: [PATCH 26/41] feat: trend card, a mini-graph-card style tile with the value over its history A new history style, `style: trend`, after mini-graph-card: the name and icon, the current value in large type, and a graph of the last 24 hours filling the bottom of the card edge to edge. It sizes like a tile (half a sections row) and the graph keeps its shape as the card resizes. Everything mini-graph-card does, under Chartdeck's names where one exists: - Graph: `graph: line | bar | none`, `fill: fade | solid | none` (true and false also work; fade is the default), `curve: smooth | straight | step`, `line_width`, `height` (per 500 px of width, as mini-graph's), `bar_spacing`. - Detail: `points_per_hour` (0.5 by default; 0 draws every reading) with `aggregate`, card-wide or per entity. `period`, `source: statistics`, `group_by` and previous/next buttons come from the shared history options. - Colour: the colour bands colour the line and fill by value, blended or changing at each band (`color_transition: smooth | hard`); an entity's own `color` wins. `color_icon`, `color_name` and `color_value` take the current colour. - Dots and labels: `points` and `labels` (the highest and lowest reading, as pills over the corners) are `hover`, `always` or `never`. - Range: `y_min` / `y_max` take a number or a soft `~N` that gives way to readings past it; `min_range` stops small wobbles filling the graph. Each has a `_secondary` twin for entities on the right-hand axis. - `extrema` and `average` add min / avg / max (with times) under the value. - Layout: `align_header`, `align_icon` (left, right, or beside the value), `align_state`, `font_size`, `font_size_header`, `icon_image`, and `group: true` to drop the card's background inside another card. - `value_factor` scales readings and the value by a power of ten. - Per entity: `y_axis: secondary`, `aggregate`, `fixed_value` (the current value as a flat line), `show_state` (a small value beside the main one), `show_indicator`, `show_graph`, `show_line`, `show_fill`, `show_points`, `show_legend` and `show_legend_state`. With two or more entities a legend sits above the graph; tapping an entry opens it. It is drawn like the other history charts rather than like mini-graph-card: the area chart's fill fading from 42 % to 5 %, the 2.5 px line, the column chart's rounded columns growing in, the guide line and ringed dot on hover, the series header's swatches for the legend, and high / low labels and Low / Average / High chips tinted with the series colour. Improvements over mini-graph-card: hovering or dragging along the graph puts the value at that moment (and its time) in place of the current one; the smoothing never overshoots a reading; unavailable stretches stay gaps; a bar takes its own value's band colour; bars stand on zero; long periods can read long-term statistics; and every option is in the visual editor. Not carried over: `state_map` (needs text states in history), `logarithmic` (on the log-scale branch), `hour24` and `cache` (Home Assistant's locale and Chartdeck's history store cover them), `show.state: last`. Editor: the Chart section has every card option; each entity's options have the per-entity ones. A chart style can now set its own sections size (`gridOptions`), used here for the half-width tile. --- src/chart/trend-data.ts | 214 ++++++++ src/chartdeck-card.ts | 3 +- src/editor/trend-editor.ts | 238 ++++++++ src/styles/index.ts | 2 + src/styles/trend.ts | 1048 ++++++++++++++++++++++++++++++++++++ src/types.ts | 4 +- test/trend-data.test.ts | 132 +++++ test/trend.test.ts | 300 +++++++++++ 8 files changed, 1938 insertions(+), 3 deletions(-) create mode 100644 src/chart/trend-data.ts create mode 100644 src/editor/trend-editor.ts create mode 100644 src/styles/trend.ts create mode 100644 test/trend-data.test.ts create mode 100644 test/trend.test.ts diff --git a/src/chart/trend-data.ts b/src/chart/trend-data.ts new file mode 100644 index 0000000..27b1f73 --- /dev/null +++ b/src/chart/trend-data.ts @@ -0,0 +1,214 @@ +import { baseColor } from '../bands'; +import { AGGREGATES, groupHistory, groupStatistics } from '../history/group'; +import type { Aggregate } from '../history/group'; +import type { Point, Range, Source } from '../history/types'; +import type { ResolvedBand } from '../types'; +import { padDomain } from './scale'; + +/** + * The data side of the trend style: readings combined into evenly spaced buckets, the value + * range the graph spans (with soft and hard bounds and a minimum range), the colour stops for + * a graph coloured by bands, and the lowest, highest and average reading. + */ + +/** Buckets per hour when `points_per_hour` isn't set: one per two hours, as mini-graph-card. */ +export const DEFAULT_POINTS_PER_HOUR = 0.5; +/** Most buckets a graph draws; past this the buckets widen. */ +export const MAX_BUCKETS = 500; +/** Most bars a bar graph draws; past this the bars widen. */ +export const MAX_BARS = 96; + +/** A value-axis end: `hard` always applies; a soft one (`~N` in YAML) moves out to fit readings past it. */ +export interface Bound { + value: number; + soft: boolean; +} + +/** `5` or `"5"` is a hard bound, `"~5"` a soft one; anything else is an error. */ +export function parseBound(raw: unknown, name: string): Bound | undefined { + if (raw === undefined || raw === null || raw === '') return undefined; + if (typeof raw === 'number' && Number.isFinite(raw)) return { value: raw, soft: false }; + if (typeof raw === 'string') { + const text = raw.trim(); + const soft = text.startsWith('~'); + const n = Number(soft ? text.slice(1) : text); + if (text !== '' && text !== '~' && Number.isFinite(n)) return { value: n, soft }; + } + throw new Error(`"${name}" must be a number, or ~ and a number for a soft limit (e.g. "~0")`); +} + +export interface DomainOptions { + min?: Bound; + max?: Bound; + /** The range is never narrower than this, so small wobbles don't fill the graph. */ + minRange?: number; + /** Bars: readings all on one side of zero stand on zero. */ + fromZero?: boolean; +} + +/** + * The values the graph spans, bottom to top. Readings are padded a little so the line's stroke + * isn't cut by the card's edge; a hard bound is never padded past, and a soft bound only moves + * when a reading goes beyond it. + */ +export function trendDomain(data: [number, number] | undefined, options: DomainOptions = {}): [number, number] { + let [lo, hi] = data ?? [options.min?.value ?? 0, options.max?.value ?? 1]; + if (options.fromZero) { + if (lo > 0) lo = 0; + if (hi < 0) hi = 0; + } + if (options.minRange !== undefined && hi - lo < options.minRange) { + const middle = (lo + hi) / 2; + lo = middle - options.minRange / 2; + hi = middle + options.minRange / 2; + } + let [padLo, padHi] = padDomain([lo, hi], 0.1); + if (options.fromZero && lo === 0) padLo = 0; + if (options.fromZero && hi === 0) padHi = 0; + const { min, max } = options; + if (min) padLo = min.soft && lo < min.value ? padLo : min.value; + if (max) padHi = max.soft && hi > max.value ? padHi : max.value; + if (padHi <= padLo) padHi = padLo + 1; + return [padLo, padHi]; +} + +/** Bucket boundaries across `range`: each start, then the last end. Never more than `max` buckets. */ +export function bucketBounds(range: Range, pointsPerHour: number, max = MAX_BUCKETS): number[] { + const span = range.end - range.start; + if (span <= 0 || pointsPerHour <= 0) return []; + const count = Math.max(1, Math.min(max, Math.round((span / 3_600_000) * pointsPerHour))); + const width = span / count; + const bounds: number[] = []; + for (let i = 0; i <= count; i++) bounds.push(i === count ? range.end : Math.round(range.start + i * width)); + return bounds; +} + +/** + * One value per bucket, combined with `aggregate` (time-weighted for the average). History is + * readings that hold until the next one; statistics are rows. A bucket after `until` (now) is a + * gap. The point the frame adds at `until` to carry the last reading on is dropped first: it + * isn't a reading, and a sum would count it twice. + */ +export function bucketSeries( + points: Point[], + bounds: number[], + aggregate: Aggregate, + source: Source, + until: number, +): Point[] { + if (bounds.length < 2) return points; + let readings = points; + const last = points[points.length - 1]; + const before = points[points.length - 2]; + if (source === 'history' && last && before && last.t === until && last.v === before.v) readings = points.slice(0, -1); + return source === 'statistics' + ? groupStatistics(readings, bounds, aggregate, until) + : groupHistory(readings, bounds, aggregate, until); +} + +/** + * Where each bucket is drawn: at the middle of the part of it that has happened, so the last, + * still-filling bucket sits between its start and now. The first and last values are carried + * out to the graph's edges (marked `carried`, so hovering and dots skip them), as mini-graph-card + * draws edge to edge. + */ +export function placeBuckets(buckets: Point[], range: Range, until: number): TrendPoint[] { + const placed: TrendPoint[] = []; + for (const b of buckets) { + const stop = Math.min(b.end ?? b.t, until); + if (b.t >= until) break; + placed.push({ ...b, start: b.t, end: stop, t: Math.round((b.t + stop) / 2) }); + } + const real = placed.filter((p) => p.v !== null); + if (real.length === 0) return placed; + const first = placed[0]; + const last = placed[placed.length - 1]; + const out = [...placed]; + if (first.v !== null && first.t > range.start) out.unshift({ t: range.start, v: first.v, carried: true }); + const edge = Math.min(range.end, until); + if (last.v !== null && last.t < edge) out.push({ t: edge, v: last.v, carried: true }); + return out; +} + +/** A drawn point. A bucket keeps the span it covers (`start` to `end`) for hovering. */ +export interface TrendPoint extends Point { + start?: number; +} + +/** A gradient stop: `offset` 0 at the bottom of the graph, 1 at the top. */ +export interface ColorStop { + offset: number; + color: string; +} + +/** + * Stops for a vertical gradient that colours the graph by bands. `hard` changes colour exactly at + * each band's start; `smooth` blends from one band's colour to the next across the band, so the + * colour reaches each band's own at its start. + */ +export function bandStops(bands: ResolvedBand[], domain: [number, number], transition: 'smooth' | 'hard'): ColorStop[] { + const [lo, hi] = domain; + const at = (v: number) => Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + const stops: ColorStop[] = []; + bands.forEach((band, i) => { + const color = baseColor(band.color); + const from = Number.isFinite(band.from) ? at(band.from) : 0; + const to = i + 1 < bands.length ? at(bands[i + 1].from) : 1; + if (transition === 'hard') { + stops.push({ offset: from, color }, { offset: to, color }); + } else if (i === 0 || i === bands.length - 1 || from < to) { + stops.push({ offset: from, color }); + if (i === bands.length - 1) stops.push({ offset: 1, color }); + } + }); + return stops; +} + +export interface Extreme { + t: number; + v: number; +} + +export interface Extrema { + min: Extreme; + max: Extreme; + /** Time-weighted when the readings hold until the next (history), else a plain mean. */ + mean: number; +} + +/** + * The lowest and highest reading and the average, over the readings drawn. Carried edge points + * aren't readings and are skipped (except to weight the last reading's time). + */ +export function extrema(points: Point[], weighted: boolean): Extrema | undefined { + let min: Extreme | undefined; + let max: Extreme | undefined; + let sum = 0; + let weight = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null) continue; + if (!p.carried) { + if (!min || p.v < min.v) min = { t: p.t, v: p.v }; + if (!max || p.v > max.v) max = { t: p.t, v: p.v }; + } + if (weighted) { + const next = points[i + 1]; + const w = next ? next.t - p.t : 0; + sum += p.v * w; + weight += w; + } else if (!p.carried) { + sum += p.v; + weight += 1; + } + } + if (!min || !max) return undefined; + return { min, max, mean: weight > 0 ? sum / weight : (min.v + max.v) / 2 }; +} + +/** Throw a readable error for a bad per-entity aggregate. */ +export function checkAggregate(raw: unknown, where: string): void { + if (raw !== undefined && !AGGREGATES.includes(raw as Aggregate)) { + throw new Error(`${where} must be one of: ${AGGREGATES.join(', ')}`); + } +} diff --git a/src/chartdeck-card.ts b/src/chartdeck-card.ts index aa0128e..46e4fd3 100644 --- a/src/chartdeck-card.ts +++ b/src/chartdeck-card.ts @@ -162,7 +162,8 @@ export class ChartdeckCard extends LitElement { getGridOptions(): { columns: number; rows: 'auto'; min_columns: number; min_rows: number } { const n = this._count(); // Charts need the room: a time axis squeezed into a quarter section is unreadable. - if (isChart(this._style)) return { columns: 12, rows: 'auto', min_columns: 6, min_rows: 2 }; + if (isChart(this._style)) + return { columns: 12, min_columns: 6, min_rows: 2, ...this._style.gridOptions, rows: 'auto' }; const rows = this._style.layout === 'rows'; const columns = rows || n <= 1 ? 6 : 12; // Row styles need half a section: at 3 columns the icon, cells and value can't fit. diff --git a/src/editor/trend-editor.ts b/src/editor/trend-editor.ts new file mode 100644 index 0000000..d428150 --- /dev/null +++ b/src/editor/trend-editor.ts @@ -0,0 +1,238 @@ +import type { TemplateResult } from 'lit'; +import { CURVES } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; +import { AGGREGATES } from '../history/group'; +import { registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { HISTORY_FIELDS, cachedSchema, chartForm, choice, historySchema, historyVariant } from './chart-editors'; +import { LINE_WIDTH_SELECTOR } from './schemas'; +import type { FieldBinding } from './schemas'; + +/** The trend style's Chart section and per-entity options. Defaults match the style's (see styles/trend.ts). */ + +registerStrings('en', { + editor_points_per_hour: 'Points per hour (0 draws every reading)', + editor_graph: 'Graph', + editor_graph_line: 'Line', + editor_graph_bar: 'Bars', + editor_graph_none: 'None (value only)', + editor_fill: 'Fill under the line', + editor_fill_solid: 'Solid', + editor_fill_fade: 'Fade', + editor_fill_none: 'None', + editor_points: 'Dots on the line', + editor_points_hover: 'On hover', + editor_points_always: 'Always', + editor_points_never: 'Never', + editor_labels: 'Highest and lowest labels', + editor_labels_hover: 'On hover', + editor_labels_always: 'Always', + editor_labels_never: 'Never', + editor_height: 'Graph height (per 500 px of width)', + editor_bar_spacing: 'Space between bars (px)', + editor_color_transition: 'Band colours', + editor_color_transition_smooth: 'Blend', + editor_color_transition_hard: 'Change at each band', + editor_extrema: 'Show lowest and highest', + editor_average: 'Show average', + editor_legend: 'Legend (two or more entities)', + editor_trend_y_min: 'Lowest value (~ for soft)', + editor_trend_y_max: 'Highest value (~ for soft)', + editor_min_range: 'Smallest range shown', + editor_y_min_secondary: 'Right axis lowest (~ for soft)', + editor_y_max_secondary: 'Right axis highest (~ for soft)', + editor_min_range_secondary: 'Right axis smallest range', + editor_value_factor: 'Scale by 10 to the power of', + editor_align_header: 'Name and icon', + editor_align_header_default: 'Spread out', + editor_align_header_left: 'Left', + editor_align_header_right: 'Right', + editor_align_header_center: 'Centre', + editor_align_icon: 'Icon', + editor_align_icon_right: 'Right of the name', + editor_align_icon_left: 'Left of the name', + editor_align_icon_state: 'Beside the value', + editor_align_state: 'Value', + editor_align_state_left: 'Left', + editor_align_state_right: 'Right', + editor_align_state_center: 'Centre', + editor_font_size: 'Value size (%)', + editor_font_size_header: 'Name size (px)', + editor_color_name: 'Colour the name', + editor_color_value: 'Colour the value', + editor_trend_color_icon: 'Colour the icon', + editor_group: 'No background (inside another card)', + editor_icon_image: 'Image instead of the icon (URL)', + editor_trend_tooltip: 'Show the value under the pointer', + editor_y_axis_side: 'Axis', + editor_y_axis_side_primary: 'Left', + editor_y_axis_side_secondary: 'Right (its own scale)', + editor_entity_aggregate: 'Combine with', + editor_fixed_value: 'Draw the current value as a flat line', + editor_show_state: 'Show its value beside the main one', + editor_show_indicator: 'Colour dot by the value', + editor_show_graph: 'On the graph', + editor_show_line: 'Line', + editor_show_fill: 'Fill', + editor_show_points: 'Dots', + editor_show_legend_entity: 'In the legend', + editor_show_legend_state: 'Value in the legend', +}); + +const TREND_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'points_per_hour', path: ['points_per_hour'], default: 0.5 }, + { name: 'graph', path: ['graph'], default: 'line' }, + { name: 'fill', path: ['fill'], default: 'fade' }, + { name: 'curve', path: ['curve'], default: 'smooth' }, + { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, + { name: 'points', path: ['points'], default: 'hover' }, + { name: 'labels', path: ['labels'], default: 'hover' }, + { name: 'height', path: ['height'], default: 150 }, + { name: 'bar_spacing', path: ['bar_spacing'], default: 4 }, + { name: 'color_transition', path: ['color_transition'], default: 'smooth' }, + { name: 'extrema', path: ['extrema'], default: false }, + { name: 'average', path: ['average'], default: false }, + { name: 'legend', path: ['legend'], default: true }, + { name: 'trend_y_min', path: ['y_min'] }, + { name: 'trend_y_max', path: ['y_max'] }, + { name: 'min_range', path: ['min_range'] }, + { name: 'y_min_secondary', path: ['y_min_secondary'] }, + { name: 'y_max_secondary', path: ['y_max_secondary'] }, + { name: 'min_range_secondary', path: ['min_range_secondary'] }, + { name: 'value_factor', path: ['value_factor'], default: 0 }, + { name: 'align_header', path: ['align_header'], default: 'default' }, + { name: 'align_icon', path: ['align_icon'], default: 'right' }, + { name: 'align_state', path: ['align_state'], default: 'left' }, + { name: 'font_size', path: ['font_size'], default: 100 }, + { name: 'font_size_header', path: ['font_size_header'] }, + { name: 'color_name', path: ['color_name'], default: false }, + { name: 'color_value', path: ['color_value'], default: false }, + { name: 'trend_color_icon', path: ['color_icon'], default: false }, + { name: 'group', path: ['group'], default: false }, + { name: 'icon_image', path: ['icon_image'] }, + { name: 'trend_tooltip', path: ['tooltip'], default: true }, +]; + +const grid = (...schema: unknown[]) => ({ type: 'grid', name: '', schema }); +const bool = (name: string) => ({ name, selector: { boolean: {} } }); +const box = (name: string, min?: number, max?: number, step: number | 'any' = 'any') => ({ + name, + selector: { + number: { mode: 'box', step, ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) }, + }, +}); +/** A bound takes a number or `~` and a number, so it is a text field; the card turns digits into a number. */ +const bound = (name: string) => ({ name, selector: { text: {} } }); + +function trendSchema(hass: HomeAssistant | undefined, config: CardConfig) { + return [ + ...historySchema(hass, config), + box('points_per_hour', 0, 60), + grid( + { name: 'graph', selector: choice(hass, 'graph', ['line', 'bar', 'none']) }, + { name: 'fill', selector: choice(hass, 'fill', ['fade', 'solid', 'none']) }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'points', selector: choice(hass, 'points', ['hover', 'always', 'never']) }, + { name: 'labels', selector: choice(hass, 'labels', ['hover', 'always', 'never']) }, + box('height', 20, 1000, 1), + box('bar_spacing', 0, 50, 1), + ), + { name: 'color_transition', selector: choice(hass, 'color_transition', ['smooth', 'hard']) }, + grid(bool('extrema'), bool('average'), bool('legend'), bool('trend_tooltip')), + grid(bound('trend_y_min'), bound('trend_y_max'), box('min_range', 0)), + grid(bound('y_min_secondary'), bound('y_max_secondary'), box('min_range_secondary', 0)), + grid( + { name: 'align_header', selector: choice(hass, 'align_header', ['default', 'left', 'right', 'center']) }, + { name: 'align_icon', selector: choice(hass, 'align_icon', ['right', 'left', 'state']) }, + { name: 'align_state', selector: choice(hass, 'align_state', ['left', 'right', 'center']) }, + box('font_size', 25, 400, 1), + box('font_size_header', 6, 60, 1), + box('value_factor', -9, 9, 1), + ), + grid(bool('color_name'), bool('trend_color_icon'), bool('color_value'), bool('group')), + { name: 'icon_image', selector: { text: {} } }, + ]; +} + +/** Bounds typed as digits become numbers, so `y_min: 0` stays a number in YAML. */ +function numericBounds(config: CardConfig): CardConfig { + let next = config; + for (const k of ['y_min', 'y_max', 'y_min_secondary', 'y_max_secondary']) { + const v = next[k]; + if (typeof v === 'string' && v.trim() !== '' && !v.trim().startsWith('~') && Number.isFinite(Number(v))) { + next = { ...next, [k]: Number(v) }; + } else if (v === '') { + next = { ...next }; + delete next[k]; + } + } + return next; +} + +export function trendEditor(ctx: ChartEditorContext): TemplateResult { + const schema = cachedSchema(`trend|${historyVariant(ctx.config)}`, ctx.hass, () => trendSchema(ctx.hass, ctx.config)); + const form = { + ...ctx, + // ha-form's text field hands back strings; keep bounds as numbers when they are. + config: { ...ctx.config, ...stringBounds(ctx.config) }, + commit: (config: CardConfig) => ctx.commit(numericBounds(config)), + }; + return chartForm(form, 'chart', schema, TREND_FIELDS); +} + +/** Numbers shown in the bound text fields as text. */ +function stringBounds(config: CardConfig): Partial { + const out: Partial = {}; + for (const k of ['y_min', 'y_max', 'y_min_secondary', 'y_max_secondary']) { + if (typeof config[k] === 'number') out[k] = String(config[k]); + } + return out; +} + +const ENTITY_FIELDS: FieldBinding[] = [ + { name: 'series_color', path: ['color'] }, + { name: 'curve', path: ['curve'] }, + { name: 'line_width', path: ['line_width'] }, + { name: 'entity_aggregate', path: ['aggregate'] }, + { name: 'y_axis_side', path: ['y_axis'], default: 'primary' }, + { name: 'fixed_value', path: ['fixed_value'], default: false }, + { name: 'show_graph', path: ['show_graph'], default: true }, + { name: 'show_line', path: ['show_line'], default: true }, + { name: 'show_fill', path: ['show_fill'], default: true }, + { name: 'show_points', path: ['show_points'] }, + { name: 'show_legend_entity', path: ['show_legend'], default: true }, + { name: 'show_legend_state', path: ['show_legend_state'], default: false }, + { name: 'show_indicator', path: ['show_indicator'], default: false }, + { name: 'show_state', path: ['show_state'], default: false }, +]; + +function entitySchema(hass: HomeAssistant | undefined) { + return [ + grid( + { name: 'series_color', selector: { text: {} } }, + { name: 'curve', selector: choice(hass, 'curve', CURVES) }, + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'entity_aggregate', selector: choice(hass, 'aggregate', AGGREGATES) }, + { name: 'y_axis_side', selector: choice(hass, 'y_axis_side', ['primary', 'secondary']) }, + ), + grid( + bool('show_state'), + bool('show_indicator'), + bool('show_graph'), + bool('show_line'), + bool('show_fill'), + bool('show_points'), + bool('show_legend_entity'), + bool('show_legend_state'), + bool('fixed_value'), + ), + ]; +} + +/** Colour, shape, axis and what shows for one entity. */ +export function trendEntityFields(hass: HomeAssistant | undefined) { + return { schema: cachedSchema('trend-entity', hass, () => entitySchema(hass)), fields: ENTITY_FIELDS }; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 4f5d510..9630936 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -43,6 +43,7 @@ import { timeline } from './timeline'; import { climate } from './climate'; import { windRoseStyle } from './wind-rose'; import { day_clock } from './day-clock'; +import { trend } from './trend'; export interface RenderContext { show: ShowOptions; @@ -175,4 +176,5 @@ export const STYLES: Record = { climate, wind_rose: windRoseStyle, day_clock, + trend, }; diff --git a/src/styles/trend.ts b/src/styles/trend.ts new file mode 100644 index 0000000..c417ac7 --- /dev/null +++ b/src/styles/trend.ts @@ -0,0 +1,1048 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { bandFor, baseColor } from '../bands'; +import { DEFAULT_BANDS } from '../config'; +import { formatTime } from '../chart/axes'; +import { + VIEW_H, + VIEW_W, + disconnectHistory, + frameStyles, + historyFrame, + periodNav, + plot, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { stopChartWidth } from '../chart/grid'; +import { linear } from '../chart/scale'; +import { runPath, runs } from '../chart/paths'; +import type { Curve } from '../chart/paths'; +import { DEFAULT_LINE_WIDTH, MAX_LINE_WIDTH, MIN_LINE_WIDTH, dotSize, dotsPath } from '../chart/series-style'; +import { + hoverHandlers, + hoverTime, + nearestIndex, + stopHover, + tooltipEnabled, + tooltipStyles, + validateTooltipOptions, +} from '../chart/tooltip'; +import { + DEFAULT_POINTS_PER_HOUR, + MAX_BARS, + MAX_BUCKETS, + bandStops, + bucketBounds, + bucketSeries, + checkAggregate, + extrema, + parseBound, + placeBuckets, + trendDomain, +} from '../chart/trend-data'; +import type { Bound, TrendPoint } from '../chart/trend-data'; +import { trendEditor, trendEntityFields } from '../editor/trend-editor'; +import { formatNumber, withUnit } from '../format'; +import type { Aggregate } from '../history/group'; +import { localize, registerStrings } from '../localize'; +import { iconOverride } from '../resolve'; +import type { ResolvedEntity } from '../resolve'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartStyleModule } from './index'; + +/** + * A compact card for one or a few sensors, after mini-graph-card: the name and icon, the current + * value in large type, and a graph of its history filling the bottom of the card edge to edge. + * Hovering or dragging along the graph shows the value at that moment in place of the current one. + */ + +registerStrings('en', { + editor_style_trend: 'Trend', + trend_min: 'Low', + trend_avg: 'Average', + trend_max: 'High', +}); + +export const GRAPH_KINDS = ['line', 'bar', 'none'] as const; +export const FILL_KINDS = ['solid', 'fade', 'none'] as const; +export const SHOW_MODES = ['hover', 'always', 'never'] as const; +export const HEADER_ALIGN = ['default', 'left', 'right', 'center'] as const; +export const ICON_ALIGN = ['right', 'left', 'state'] as const; +export const STATE_ALIGN = ['left', 'right', 'center'] as const; +export const TRANSITIONS = ['smooth', 'hard'] as const; + +type GraphKind = (typeof GRAPH_KINDS)[number]; +type FillKind = (typeof FILL_KINDS)[number]; +type ShowMode = (typeof SHOW_MODES)[number]; + +/** Graph height per 500 px of width, as mini-graph-card's `height`: the graph keeps its shape as the card resizes. */ +export const DEFAULT_HEIGHT = 150; +export const DEFAULT_TREND_LINE_WIDTH = DEFAULT_LINE_WIDTH; +export const DEFAULT_BAR_SPACING = 4; + +/** The trend options from YAML, with defaults filled in. */ +export interface TrendOptions { + graph: GraphKind; + fill: FillKind; + points: ShowMode; + labels: ShowMode; + pointsPerHour: number; + height: number; + lineWidth: number; + barSpacing: number; + transition: 'smooth' | 'hard'; + extrema: boolean; + average: boolean; + legend: boolean; + factor: number; + alignHeader: (typeof HEADER_ALIGN)[number]; + alignIcon: (typeof ICON_ALIGN)[number]; + alignState: (typeof STATE_ALIGN)[number]; + fontSize: number; + fontSizeHeader?: number; + colorName: boolean; + colorValue: boolean; + colorIcon: boolean; + group: boolean; + iconImage?: string; + primary: { min?: Bound; max?: Bound; minRange?: number }; + secondary: { min?: Bound; max?: Bound; minRange?: number }; +} + +/** Per-entity trend settings (entity configs keep keys the card doesn't know). */ +interface TrendEntity { + color?: string; + line_width?: number; + aggregate?: Aggregate; + y_axis?: 'primary' | 'secondary'; + fixed_value?: boolean; + show_state?: boolean; + show_indicator?: boolean; + show_graph?: boolean; + show_line?: boolean; + show_fill?: boolean; + show_points?: boolean; + show_legend?: boolean; + show_legend_state?: boolean; +} + +/** mini-graph-card's true / false / hover, or always / never / hover. */ +function showMode(raw: unknown, fallback: ShowMode): ShowMode { + if (raw === undefined) return fallback; + if (raw === true) return 'always'; + if (raw === false) return 'never'; + return raw as ShowMode; +} + +/** `fill: true` (mini-graph-card's solid fill) is the faded one here, as on the area chart. */ +function fillKind(raw: unknown): FillKind { + if (raw === undefined || raw === true) return 'fade'; + if (raw === false) return 'none'; + return raw as FillKind; +} + +const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined); + +export function trendOptions(raw: CardConfig): TrendOptions { + return { + graph: (raw.graph as GraphKind | undefined) ?? 'line', + fill: fillKind(raw.fill), + points: showMode(raw.points, 'hover'), + labels: showMode(raw.labels, 'hover'), + pointsPerHour: num(raw.points_per_hour) ?? DEFAULT_POINTS_PER_HOUR, + height: num(raw.height) ?? DEFAULT_HEIGHT, + lineWidth: num(raw.line_width) ?? DEFAULT_TREND_LINE_WIDTH, + barSpacing: num(raw.bar_spacing) ?? DEFAULT_BAR_SPACING, + transition: (raw.color_transition as 'smooth' | 'hard' | undefined) ?? 'smooth', + extrema: raw.extrema === true, + average: raw.average === true, + legend: raw.legend !== false, + factor: Math.pow(10, num(raw.value_factor) ?? 0), + alignHeader: (raw.align_header as TrendOptions['alignHeader'] | undefined) ?? 'default', + alignIcon: (raw.align_icon as TrendOptions['alignIcon'] | undefined) ?? 'right', + alignState: (raw.align_state as TrendOptions['alignState'] | undefined) ?? 'left', + fontSize: num(raw.font_size) ?? 100, + fontSizeHeader: num(raw.font_size_header), + colorName: raw.color_name === true, + colorValue: raw.color_value === true, + colorIcon: raw.color_icon === true, + group: raw.group === true, + iconImage: typeof raw.icon_image === 'string' && raw.icon_image !== '' ? raw.icon_image : undefined, + primary: { + min: parseBound(raw.y_min, 'y_min'), + max: parseBound(raw.y_max, 'y_max'), + minRange: num(raw.min_range), + }, + secondary: { + min: parseBound(raw.y_min_secondary, 'y_min_secondary'), + max: parseBound(raw.y_max_secondary, 'y_max_secondary'), + minRange: num(raw.min_range_secondary), + }, + }; +} + +function oneOf(raw: unknown, values: readonly unknown[], name: string): void { + if (raw !== undefined && !values.includes(raw)) { + throw new Error(`"${name}" must be one of: ${values.join(', ')}`); + } +} + +function checkNumber(raw: unknown, name: string, min: number, max = Infinity, integer = false): void { + if (raw === undefined) return; + if ( + typeof raw !== 'number' || + !Number.isFinite(raw) || + raw < min || + raw > max || + (integer && !Number.isInteger(raw)) + ) { + const range = max === Infinity ? `${min} or more` : `from ${min} to ${max}`; + throw new Error(`"${name}" must be ${integer ? 'a whole number' : 'a number'} ${range}`); + } +} + +function checkBool(raw: unknown, name: string): void { + if (raw !== undefined && typeof raw !== 'boolean') throw new Error(`"${name}" must be true or false`); +} + +/** Throw a readable error for any bad trend option, on the card or an entity. */ +export function validateTrend(raw: CardConfig): void { + validateHistoryOptions(raw); + validateTooltipOptions(raw); + oneOf(raw.graph, GRAPH_KINDS, 'graph'); + oneOf(raw.fill, [...FILL_KINDS, true, false], 'fill'); + oneOf(raw.points, [...SHOW_MODES, true, false], 'points'); + oneOf(raw.labels, [...SHOW_MODES, true, false], 'labels'); + oneOf(raw.color_transition, TRANSITIONS, 'color_transition'); + oneOf(raw.align_header, HEADER_ALIGN, 'align_header'); + oneOf(raw.align_icon, ICON_ALIGN, 'align_icon'); + oneOf(raw.align_state, STATE_ALIGN, 'align_state'); + checkNumber(raw.points_per_hour, 'points_per_hour', 0, 60); + checkNumber(raw.height, 'height', 20, 1000); + checkNumber(raw.line_width, 'line_width', MIN_LINE_WIDTH, MAX_LINE_WIDTH); + checkNumber(raw.bar_spacing, 'bar_spacing', 0, 50); + checkNumber(raw.value_factor, 'value_factor', -9, 9, true); + checkNumber(raw.font_size, 'font_size', 25, 400); + checkNumber(raw.font_size_header, 'font_size_header', 6, 60); + checkNumber(raw.min_range, 'min_range', 0); + checkNumber(raw.min_range_secondary, 'min_range_secondary', 0); + for (const k of ['extrema', 'average', 'legend', 'color_name', 'color_value', 'group']) checkBool(raw[k], k); + if (raw.icon_image !== undefined && typeof raw.icon_image !== 'string') throw new Error('"icon_image" must be a URL'); + trendOptions(raw); + for (const e of raw.entities ?? []) { + if (typeof e !== 'object') continue; + const own = e as TrendEntity & { entity: string }; + const where = (k: string) => `"${k}" of ${own.entity}`; + checkAggregate(own.aggregate, where('aggregate')); + oneOf(own.y_axis, ['primary', 'secondary'], `y_axis of ${own.entity}`); + checkNumber(own.line_width, `line_width of ${own.entity}`, MIN_LINE_WIDTH, MAX_LINE_WIDTH); + for (const k of [ + 'fixed_value', + 'show_state', + 'show_indicator', + 'show_graph', + 'show_line', + 'show_fill', + 'show_points', + 'show_legend', + 'show_legend_state', + ] as const) { + if (own[k] !== undefined && typeof own[k] !== 'boolean') throw new Error(`${where(k)} must be true or false`); + } + } +} + +/** One series ready to draw: its points in buckets, its colour and where it shows. */ +interface TrendSeries { + index: number; + entity: ResolvedEntity; + own: TrendEntity; + points: TrendPoint[]; + /** The series' own colour, or undefined when it is coloured by bands. */ + color?: string; + /** Its colour now: its own, the current value's band, or its palette colour. */ + current: string; + curve: Curve; + secondary: boolean; + graphed: boolean; +} + +/** Bands someone set (on the card or the entity), rather than the single default one. */ +const hasBands = (e: ResolvedEntity) => e.config.severity !== DEFAULT_BANDS; + +function trendSeries(frame: HistoryFrame, ctx: ChartContext, options: TrendOptions): TrendSeries[] { + const until = Math.min(frame.range.end, Date.now()); + const bars = options.graph === 'bar'; + // Bars always need buckets; a line with points_per_hour 0 draws every reading. + const perHour = bars && options.pointsPerHour === 0 ? DEFAULT_POINTS_PER_HOUR : options.pointsPerHour; + const bounds = frame.group ? [] : bucketBounds(frame.range, perHour, bars ? MAX_BARS : MAX_BUCKETS); + const cardAggregate = (ctx.raw.aggregate as Aggregate | undefined) ?? 'mean'; + return frame.series.map((s: ChartSeries) => { + const own = s.entity.config as unknown as TrendEntity; + let points: TrendPoint[] = s.points; + if (own.fixed_value && s.entity.value !== null) { + points = [ + { t: frame.range.start, v: s.entity.value }, + { t: until, v: s.entity.value }, + ]; + } else if (frame.group) { + // Calendar groups (`group_by`): drawn at their middles like buckets. + points = placeBuckets(s.points, frame.range, until); + } else if (bounds.length) { + points = placeBuckets( + bucketSeries(s.points, bounds, own.aggregate ?? cardAggregate, frame.source, until), + frame.range, + until, + ); + } + if (options.factor !== 1) points = points.map((p) => (p.v === null ? p : { ...p, v: p.v * options.factor })); + const ownColor = own.color !== undefined ? baseColor(own.color) : undefined; + const banded = !ownColor && hasBands(s.entity); + const value = s.entity.value === null ? null : s.entity.value * options.factor; + const current = ownColor ?? (banded && value !== null ? baseColor(bandFor(value, s.entity.bands).color) : s.color); + return { + index: s.index, + entity: s.entity, + own, + points, + color: banded ? undefined : (ownColor ?? s.color), + current, + curve: s.curve, + secondary: own.y_axis === 'secondary', + graphed: own.show_graph !== false, + }; + }); +} + +/** Lowest and highest reading across these series. */ +function dataExtent(series: TrendSeries[]): [number, number] | undefined { + let lo = Infinity; + let hi = -Infinity; + for (const s of series) { + for (const p of s.points) { + if (p.v === null) continue; + if (p.v < lo) lo = p.v; + if (p.v > hi) hi = p.v; + } + } + return lo === Infinity ? undefined : [lo, hi]; +} + +/** The value as text, scaled by `value_factor`, with the entity's unit kept apart for styling. */ +function valueParts(v: number | null, e: ResolvedEntity, factor: number, hass: ChartContext['hass']) { + if (v === null) return { number: e.display, unit: '' }; + return { number: formatNumber(v * factor, e.decimals, hass), unit: e.unit }; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +interface Scaled { + series: TrendSeries; + y: (v: number) => number; +} + +function gradientDefs(scaled: Scaled[], domains: Map, options: TrendOptions) { + return scaled + .filter(({ series: s }) => s.color === undefined) + .map(({ series: s }) => { + const stops = bandStops(s.entity.bands, domains.get(s.secondary)!, options.transition); + return svg` + ${stops.map((stop) => svg``)} + `; + }); +} + +const paint = (s: TrendSeries) => s.color ?? `url(#cd-trend-${s.index})`; + +/** A filled line: the fill closes down to the bottom of the graph, under each unbroken run. */ +function lineSeries({ series: s, y }: Scaled, x: (t: number) => number, options: TrendOptions): SVGTemplateResult { + const parts = runs(s.points).map((run) => { + const pts = run.map((p): [number, number] => [x(p.t), y(p.v as number)]); + return { pts, d: runPath(pts, s.curve) }; + }); + const line = s.own.show_line !== false; + const fill = options.fill !== 'none' && s.own.show_fill !== false; + const width = s.own.line_width ?? options.lineWidth; + const dots = s.own.show_points ?? options.points === 'always'; + const real = s.points.filter((p) => p.v !== null && !p.carried); + return svg` + ${ + fill + ? svg` `${d}L${pts[pts.length - 1][0]},${VIEW_H}L${pts[0][0]},${VIEW_H}Z`).join('')} + fill=${paint(s)} + mask=${options.fill === 'fade' ? 'url(#cd-trend-fade)' : nothing} + >` + : nothing + } + ${ + line + ? svg` p.d).join('')} fill="none" stroke=${paint(s)} + style="stroke-width:${width}px">` + : nothing + } + ${ + dots && real.length <= 200 + ? svg` [x(p.t), y(p.v as number)]))} fill="none" + stroke=${paint(s)} style="stroke-width:${dotSize(width)}px">` + : nothing + } + `; +} + +/** + * Columns, one per bucket, side by side when there are several series, drawn in HTML as on the + * column chart so their rounded ends stay round. A column coloured by bands takes its own value's + * band. `spacing` is the gap between buckets, in px. + */ +function columns(scaled: Scaled[], x: (t: number) => number, spacing: number) { + const n = scaled.length; + const cols = scaled.flatMap(({ series: s, y }, k) => { + const zero = Math.min(VIEW_H, Math.max(0, y(0))); + return s.points + .filter((p) => p.v !== null && !p.carried && p.start !== undefined && p.end !== undefined) + .map((p) => { + const left = x(p.start!) / VIEW_W; + const slot = (x(p.end!) - x(p.start!)) / VIEW_W; + const top = Math.min(VIEW_H, Math.max(0, y(p.v as number))); + const down = top > zero; + const color = s.color ?? baseColor(bandFor(p.v as number, s.entity.bands).color); + const width = `(${pct(slot)} - ${spacing}px) / ${n}`; + return html`
`; + }); + }); + return html`
${cols}
`; +} + +/** + * A series' name in the legend. The card's `name` is the header's title, and Chartdeck hands it + * down to every entity, so a series that only has the card's name uses its friendly name instead. + */ +function seriesName(s: TrendSeries, ctx: ChartContext): string { + const cardName = ctx.raw.name; + if (cardName === undefined || s.entity.name !== cardName) return s.entity.name; + return (s.entity.stateObj?.attributes.friendly_name as string | undefined) ?? s.entity.entityId; +} + +/** The value at the hovered moment for each series, or nothing when not hovering. */ +function hovered(series: TrendSeries[], t: number | undefined) { + if (t === undefined) return undefined; + return series.map((s) => { + const real = s.points.filter((p) => !p.carried); + const i = nearestIndex(real, t); + return i < 0 ? undefined : real[i]; + }); +} + +/** "14:00 – 16:00" for a bucket, the time alone for a reading. */ +function whenText(p: TrendPoint, ctx: ChartContext): string { + if (p.start !== undefined && p.end !== undefined && p.end > p.start) { + return `${formatTime(p.start, ctx.hass)} – ${formatTime(p.end, ctx.hass)}`; + } + return formatTime(p.t, ctx.hass); +} + +function icon(e: ResolvedEntity, ctx: ChartContext, options: TrendOptions, color: string) { + const style = options.colorIcon ? `color:${color}` : ''; + if (options.iconImage) { + return html``; + } + return html` + + `; +} + +/** Low, average and high as chips tinted with the series colour, as on the line chart's highs and lows. */ +function infoRow(s: TrendSeries, options: TrendOptions, ctx: ChartContext, weighted: boolean) { + if (!options.extrema && !options.average) return nothing; + const x = extrema(s.points, weighted); + if (!x) return nothing; + const text = (v: number) => withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const chip = (key: string, v: number, t?: number) => + html`${localize(ctx.hass, key)} ${text(v)}${ + t !== undefined ? html` · ${formatTime(t, ctx.hass)}` : nothing + }`; + return html`
+ ${options.extrema ? chip('trend_min', x.min.v, x.min.t) : nothing} + ${options.average ? chip('trend_avg', x.mean) : nothing} + ${options.extrema ? chip('trend_max', x.max.v, x.max.t) : nothing} +
`; +} + +function render(ctx: ChartContext): TemplateResult { + const options = trendOptions(ctx.raw); + const { show } = ctx.config; + const frame = historyFrame(ctx); + const series = trendSeries(frame, ctx, options); + const primary = series[0]; + const e = primary.entity; + const graphedSeries = series.filter((s) => s.graphed); + const t = tooltipEnabled(ctx.raw) && frame.status === 'ready' ? hoverTime(ctx.host) : undefined; + const at = hovered(series, t); + + // Header: name and icon. + const nameStyle = options.colorName ? `color:${primary.current}` : ''; + const headerIcon = show.icon && options.alignIcon !== 'state' ? icon(e, ctx, options, primary.current) : nothing; + const header = + show.name || headerIcon !== nothing + ? html`
+ ${show.name ? html`${e.name}` : nothing} ${headerIcon} +
` + : nothing; + + // States: the primary value large, any others set to show small beside it. + const stateOf = (s: TrendSeries, i: number) => { + const p = at?.[i]; + const v = p ? p.v : s.entity.value; + const parts = p + ? { number: p.v === null ? '—' : formatNumber(p.v, s.entity.decimals, ctx.hass), unit: s.entity.unit } + : valueParts(v, s.entity, options.factor, ctx.hass); + // The colour follows the value shown: the hovered reading's band while hovering. + const color = + p && p.v !== null && s.color === undefined ? baseColor(bandFor(p.v, s.entity.bands).color) : s.current; + return { parts, color, when: p ? whenText(p, ctx) : undefined }; + }; + const big = stateOf(primary, 0); + const showPrimary = show.value; + const secondaries = series.slice(1).filter((s) => s.own.show_state === true); + const states = + showPrimary || secondaries.length || options.alignIcon === 'state' + ? html`
+ ${show.icon && options.alignIcon === 'state' ? icon(e, ctx, options, primary.current) : nothing} + ${ + showPrimary + ? html`
+ ${primary.own.show_indicator ? html`` : nothing} + ${big.parts.number}${big.parts.unit ? html`${big.parts.unit}` : nothing} + ${big.when ? html`${big.when}` : nothing} +
` + : nothing + } + ${ + secondaries.length + ? html`
+ ${secondaries.map((s) => { + const st = stateOf(s, s.index); + return html`
+ ${s.own.show_indicator !== false ? html`` : nothing} + ${st.parts.number}${st.parts.unit ? html`${st.parts.unit}` : nothing} +
`; + })} +
` + : nothing + } +
` + : nothing; + + const legendItems = graphedSeries.filter((s) => s.own.show_legend !== false); + // Two or more lines: the row of swatches and names every history chart has. + const legend = + options.legend && graphedSeries.length > 1 && legendItems.length + ? html`
+ ${legendItems.map( + (s) => + html`
+ + ${seriesName(s, ctx)} + ${s.own.show_legend_state ? html`${s.entity.display}` : nothing} +
`, + )} +
` + : nothing; + + const nav = periodNav(frame, ctx); + const graph = options.graph === 'none' ? nothing : graphArea(ctx, frame, graphedSeries, options, at); + return html`
+
+ ${header} ${nav === nothing ? nothing : html`
${nav}
`} ${states} + ${infoRow(primary, options, ctx, frame.source === 'history' && !frame.group && options.pointsPerHour === 0)} + ${legend} +
+ ${graph} +
`; +} + +function graphArea( + ctx: ChartContext, + frame: HistoryFrame, + series: TrendSeries[], + options: TrendOptions, + at: (TrendPoint | undefined)[] | undefined, +) { + const ratio = `aspect-ratio:500 / ${options.height}`; + if (frame.status !== 'ready' || series.every((s) => s.points.every((p) => p.v === null))) { + const key = + frame.status === 'loading' ? 'chart_loading' : frame.status === 'error' ? 'chart_error' : 'chart_no_data'; + return html`
+ ${localize(ctx.hass, key, { error: frame.error ?? '' })} +
`; + } + const bars = options.graph === 'bar'; + const domains = new Map(); + for (const secondary of [false, true]) { + const group = series.filter((s) => s.secondary === secondary); + if (!group.length) continue; + domains.set( + secondary, + trendDomain(dataExtent(group), { ...(secondary ? options.secondary : options.primary), fromZero: bars }), + ); + } + const x = linear([frame.range.start, frame.range.end], [0, VIEW_W]); + const scaled: Scaled[] = series.map((s) => ({ series: s, y: linear(domains.get(s.secondary)!, [VIEW_H, 0]) })); + const content = bars ? svg`` : svg`${scaled.map((sc) => lineSeries(sc, x, options))}`; + const defs = svg`${gradientDefs(scaled, domains, options)} + + + + + + + `; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_trend'), + names: series.map((s) => s.entity.name).join(', '), + }); + + // Value labels: the top and bottom of each axis, as pills over the graph's corners. + const bound = (secondary: boolean) => { + const group = series.filter((s) => s.secondary === secondary); + const data = dataExtent(group); + if (!data) return nothing; + // The readings' own lowest and highest, not the padded ends; a hard bound shows itself. + const set = secondary ? options.secondary : options.primary; + const lo = set.min && !set.min.soft ? set.min.value : data[0]; + const hi = set.max && !set.max.soft ? set.max.value : data[1]; + const e = group[0].entity; + const f = (v: number) => withUnit(formatNumber(v, e.decimals, ctx.hass), e.unit); + return html`
+ ${f(hi)}${f(lo)} +
`; + }; + const labels = + options.labels === 'never' + ? nothing + : html`
${bound(false)}${bound(true)}
`; + + // Hover: the guide line and a dot on each line at the hovered reading, as on the line chart + // (bars light up their slot instead, as on the column chart). + const guide = + at && !bars && at[series[0].index] + ? html`
` + : nothing; + const dots = + at && !bars + ? scaled.map(({ series: s, y }) => { + const p = at[s.index]; + if (!p || p.v === null || s.own.show_points === false) return nothing; + const top = y(p.v) / VIEW_H; + if (!(top >= 0 && top <= 1)) return nothing; + const color = s.color ?? baseColor(bandFor(p.v, s.entity.bands).color); + return html``; + }) + : nothing; + const marker = + at && bars && at[series[0].index] + ? (() => { + const p = at[series[0].index]!; + if (p.start === undefined || p.end === undefined) return nothing; + return html`
`; + })() + : nothing; + + const hover = tooltipEnabled(ctx.raw) + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points.filter((p) => !p.carried)), + ) + : undefined; + return html`
+ ${plot(content, label, defs)} ${bars ? columns(scaled, x, options.barSpacing) : nothing} ${labels} ${marker} + ${guide} ${options.points === 'never' ? nothing : dots} +
`; +} + +export const trend: ChartStyleModule = { + layout: 'chart', + group: 'history', + height: 170, + gridOptions: { columns: 6, min_columns: 3, min_rows: 2 }, + styles: css` + ${frameStyles} + ${tooltipStyles} + .content.style-trend { + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + } + .content.style-trend > .chart { + flex: 1; + display: flex; + flex-direction: column; + } + /* The graph sits on the card's bottom edge, its corners cut by the card's rounding. */ + ha-card:has(> .content.style-trend) { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ha-card:has(> .content.style-trend .trend.group) { + box-shadow: none; + border: none; + background: none; + } + .trend { + flex: 1; + display: flex; + flex-direction: column; + } + .trend-top { + padding: 16px 16px 0; + } + .trend.group .trend-top { + padding: 0 0 0; + } + .trend.no-graph .trend-top { + padding-bottom: 16px; + } + .trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + } + .trend-header.icon-left { + flex-direction: row-reverse; + justify-content: flex-end; + } + .trend-header.align-left { + justify-content: flex-start; + } + .trend-header.align-right { + justify-content: flex-end; + } + .trend-header.align-center { + justify-content: center; + } + .trend-name { + font-size: 1.15em; + font-weight: 500; + line-height: 1.4em; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .trend-icon { + flex: none; + display: inline-flex; + color: var(--state-icon-color, #44739e); + } + .trend-icon ha-state-icon { + --mdc-icon-size: 1.7em; + width: 1.7em; + height: 1.7em; + } + .trend-icon img { + width: 1.7em; + height: 1.7em; + object-fit: contain; + } + .trend-states { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0 16px; + min-width: 0; + padding-bottom: 4px; + } + .trend-states.align-right { + flex-direction: row-reverse; + } + .trend-states.align-center { + justify-content: space-evenly; + } + .trend-states .trend-icon { + align-self: center; + font-size: 1.15em; + } + .trend-state { + position: relative; + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + } + .trend-state:not(.small) { + margin-top: 4px; + } + .trend-value { + font-size: 2.25em; + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.01em; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .trend-unit { + font-size: 1.15em; + font-weight: 400; + color: var(--secondary-text-color); + white-space: nowrap; + } + /* While hovering, the reading's time sits after the value. */ + .trend-when { + align-self: center; + margin-left: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .trend-secondary { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + min-width: 0; + padding-top: 8px; + } + .trend-states.align-right .trend-secondary { + align-items: flex-start; + } + .trend-state.small { + align-items: center; + gap: 6px; + } + .trend-state.small .trend-value { + font-size: 1em; + font-weight: 500; + letter-spacing: 0; + } + .trend-state.small .trend-unit { + font-size: 0.875em; + } + .trend-state .swatch { + align-self: center; + } + /* Low, average and high, as the line chart's highs and lows. */ + .trend-info { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0 8px; + } + .trend-chip { + height: 20px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 10px; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-chip.trend_avg { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--primary-text-color) 6%, var(--card-background-color, #fff)); + } + .trend-chip-type { + font-weight: 500; + } + .trend-chip-time { + color: var(--secondary-text-color); + } + .trend-legend { + padding: 0 0 6px; + } + .trend .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .trend-graph { + position: relative; + margin-top: auto; + width: 100%; + max-height: 400px; + } + .trend-graph.hoverable { + touch-action: pan-y; + cursor: crosshair; + } + .trend-graph.message { + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + font-size: 0.875em; + min-height: 48px; + } + .trend-graph.message.error { + color: var(--error-color, #db4437); + } + .trend-graph .plot { + overflow: hidden; + } + .style-trend .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-trend .dots { + stroke-linecap: round; + } + .style-trend .fill.solid { + fill-opacity: 0.2; + } + /* Columns as on the column chart: rounded at the outer end, growing in from the base. */ + .trend-cols { + position: absolute; + inset: 0; + pointer-events: none; + } + .trend-col { + position: absolute; + box-sizing: border-box; + border-radius: 4px 4px 0 0; + transform-origin: 50% 100%; + animation: cd-trend-grow var(--cd-duration) var(--cd-easing); + } + .trend-col.down { + border-radius: 0 0 4px 4px; + transform-origin: 50% 0; + } + :host([static]) .trend-col { + animation: none; + } + @keyframes cd-trend-grow { + from { + transform: scaleY(0); + } + } + .trend-labels-wrap { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1; + } + .trend-labels-wrap.labels-hover { + opacity: 0; + } + .trend-graph:hover .trend-labels-wrap.labels-hover, + .trend-graph:active .trend-labels-wrap.labels-hover { + opacity: 1; + } + .trend-labels { + position: absolute; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + padding: 6px 8px; + box-sizing: border-box; + } + .trend-labels.primary { + left: 0; + } + .trend-labels.secondary { + right: 0; + align-items: flex-end; + } + /* Tinted with the series colour, as the line chart's high and low labels. */ + .trend-labels span { + height: 18px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-graph .crosshair { + z-index: 1; + pointer-events: none; + } + .trend-graph .hover-dot { + z-index: 2; + pointer-events: none; + } + .bar-hover { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + pointer-events: none; + } + `, + thumbnail: () => svg` + + + + + `, + validate: validateTrend, + editor: trendEditor, + entityFields: trendEntityFields, + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + }, + render, +}; diff --git a/src/types.ts b/src/types.ts index a16de94..786417d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose' | 'day_clock'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose', 'day_clock']; +export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose' | 'day_clock' | 'trend'; +export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose', 'day_clock', 'trend']; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; diff --git a/test/trend-data.test.ts b/test/trend-data.test.ts new file mode 100644 index 0000000..4db0716 --- /dev/null +++ b/test/trend-data.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { + bandStops, + bucketBounds, + bucketSeries, + extrema, + parseBound, + placeBuckets, + trendDomain, +} from '../src/chart/trend-data'; + +const H = 3_600_000; + +describe('trend bounds', () => { + it('reads hard and soft bounds, and rejects anything else', () => { + expect(parseBound(undefined, 'y_min')).toBeUndefined(); + expect(parseBound('', 'y_min')).toBeUndefined(); + expect(parseBound(5, 'y_min')).toEqual({ value: 5, soft: false }); + expect(parseBound('5', 'y_min')).toEqual({ value: 5, soft: false }); + expect(parseBound('~-2.5', 'y_min')).toEqual({ value: -2.5, soft: true }); + expect(() => parseBound('~', 'y_min')).toThrow(/"y_min" must be a number/); + expect(() => parseBound('low', 'y_max')).toThrow(/"y_max"/); + expect(() => parseBound(true, 'y_max')).toThrow(/"y_max"/); + }); + + it('pads the readings, and keeps hard bounds exactly', () => { + expect(trendDomain([10, 20])).toEqual([9, 21]); + expect(trendDomain([10, 20], { min: { value: 0, soft: false } })).toEqual([0, 21]); + expect(trendDomain([10, 20], { max: { value: 15, soft: false } })).toEqual([9, 15]); + }); + + it('moves a soft bound only when a reading goes past it', () => { + // ~0 holds: every reading is above it. + expect(trendDomain([10, 20], { min: { value: 0, soft: true } })).toEqual([0, 21]); + // ~15 gives way to the 10 reading below it. + expect(trendDomain([10, 20], { min: { value: 15, soft: true } })).toEqual([9, 21]); + expect(trendDomain([10, 20], { max: { value: 30, soft: true } })).toEqual([9, 30]); + expect(trendDomain([10, 20], { max: { value: 18, soft: true } })).toEqual([9, 21]); + }); + + it('widens a narrow range to min_range about its middle', () => { + const [lo, hi] = trendDomain([18.6, 18.8], { minRange: 4 }); + expect(lo).toBeCloseTo(16.3); + expect(hi).toBeCloseTo(21.1); + }); + + it('stands bars on zero, and never returns an empty range', () => { + expect(trendDomain([10, 20], { fromZero: true })).toEqual([0, 22]); + expect(trendDomain([-20, -10], { fromZero: true })).toEqual([-22, 0]); + expect(trendDomain(undefined)).toEqual([-0.1, 1.1]); + expect(trendDomain([5, 5], { min: { value: 5, soft: false }, max: { value: 5, soft: false } })).toEqual([5, 6]); + }); +}); + +describe('trend buckets', () => { + const range = { start: 0, end: 24 * H }; + + it('splits the range into points_per_hour buckets, capped', () => { + const bounds = bucketBounds(range, 0.5); + expect(bounds).toHaveLength(13); + expect(bounds[1]).toBe(2 * H); + expect(bounds.at(-1)).toBe(24 * H); + expect(bucketBounds(range, 60, 96)).toHaveLength(97); + expect(bucketBounds(range, 0)).toEqual([]); + expect(bucketBounds({ start: 5, end: 5 }, 1)).toEqual([]); + }); + + it('averages each bucket by time, and drops the carried point before a sum', () => { + const points = [ + { t: 0, v: 10 }, + { t: 1.5 * H, v: 20 }, + { t: 4 * H, v: 20 }, + ]; + const bounds = [0, 2 * H, 4 * H]; + const mean = bucketSeries(points, bounds, 'mean', 'history', 4 * H); + expect(mean.map((p) => p.v)).toEqual([12.5, 20]); + const sum = bucketSeries(points, bounds, 'sum', 'history', 4 * H); + expect(sum.map((p) => p.v)).toEqual([30, null]); + expect(bucketSeries(points, [], 'mean', 'history', 4 * H)).toBe(points); + }); + + it('places each bucket at the middle of what has happened, carried out to both edges', () => { + const placed = placeBuckets( + [ + { t: 0, end: 2 * H, v: 1 }, + { t: 2 * H, end: 4 * H, v: 3 }, + { t: 4 * H, end: 6 * H, v: null }, + ], + { start: 0, end: 6 * H }, + 3 * H, + ); + expect(placed).toEqual([ + { t: 0, v: 1, carried: true }, + { t: H, start: 0, end: 2 * H, v: 1 }, + { t: 2.5 * H, start: 2 * H, end: 3 * H, v: 3 }, + { t: 3 * H, v: 3, carried: true }, + ]); + expect(placeBuckets([{ t: 0, end: H, v: null }], { start: 0, end: H }, H)).toEqual([ + { t: H / 2, start: 0, end: H, v: null }, + ]); + }); +}); + +describe('trend colours and extrema', () => { + const bands = [ + { from: -Infinity, color: 'blue' }, + { from: 20, color: 'red' }, + ]; + + it('changes colour at a band start, or blends towards it', () => { + const hard = bandStops(bands, [10, 30], 'hard'); + expect(hard.map((s) => s.offset)).toEqual([0, 0.5, 0.5, 1]); + expect(hard[1].color).toContain('--info-color'); + expect(hard[2].color).toContain('--error-color'); + const smooth = bandStops(bands, [10, 30], 'smooth'); + expect(smooth.map((s) => s.offset)).toEqual([0, 0.5, 1]); + }); + + it('finds the lowest, highest and average, skipping carried edges', () => { + const points = [ + { t: 0, v: 50, carried: true }, + { t: 1, v: 10 }, + { t: 2, v: null }, + { t: 3, v: 30 }, + { t: 5, v: 30, carried: true }, + ]; + expect(extrema(points, false)).toEqual({ min: { t: 1, v: 10 }, max: { t: 3, v: 30 }, mean: 20 }); + // Weighted: 50 held for 1, 10 for 1, 30 for 2; the unavailable stretch doesn't count → 120 / 4. + expect(extrema(points, true)!.mean).toBe(30); + expect(extrema([{ t: 0, v: null }], false)).toBeUndefined(); + }); +}); diff --git a/test/trend.test.ts b/test/trend.test.ts new file mode 100644 index 0000000..387d48e --- /dev/null +++ b/test/trend.test.ts @@ -0,0 +1,300 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +type Card = HTMLElement & { + setConfig(c: CardConfig): void; + hass?: HomeAssistant; + updateComplete: Promise; + getGridOptions(): Record; +}; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +function hassWith(history: Record, fail = false) { + const hass = makeHass({ + 'sensor.in': ['18.8', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => { + if (fail) throw new Error('boom'); + return history; + }) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.in': [ + { s: '17', lu: sec(23 * H) }, + { s: '19', lu: sec(16 * H) }, + { s: '22', lu: sec(8 * H) }, + { s: '18.8', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(23 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +async function mount(config: Partial, hass = hassWith(history)) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'trend', + entities: ['sensor.in'], + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const text = (el: Element | null) => el!.textContent!.replace(/\s+/g, ' ').trim(); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('trend card', () => { + it('shows the name, icon and large value over a filled line, as a half-width tile', async () => { + const card = await mount({ name: 'Temperature' }); + expect(text(q(card, '.trend-name'))).toBe('Temperature'); + expect(q(card, '.trend-header ha-state-icon')).not.toBeNull(); + expect(text(q(card, '.trend-value'))).toBe('18.8'); + expect(text(q(card, '.trend-unit'))).toBe('°C'); + expect(qa(card, 'path.line')).toHaveLength(1); + expect(q(card, 'path.fill.fade')!.getAttribute('mask')).toBe('url(#cd-trend-fade)'); + // 24 hours at 0.5 points per hour is 12 buckets, drawn as one smooth run edge to edge. + const d = q(card, 'path.line')!.getAttribute('d')!; + expect(d.startsWith('M0,')).toBe(true); + expect(d.match(/C/g)!.length).toBeGreaterThanOrEqual(12); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Trend chart of Temperature'); + expect(q(card, '.trend-graph')!.getAttribute('style')).toContain('aspect-ratio:500 / 150'); + expect(card.getGridOptions()).toMatchObject({ columns: 6, min_columns: 3, rows: 'auto' }); + }); + + it('draws every reading with points_per_hour 0, and fades or drops the fill', async () => { + const raw = await mount({ points_per_hour: 0, fill: 'solid', curve: 'straight' } as Partial); + expect(q(raw, 'path.line')!.getAttribute('d')!.match(/L/g)).toHaveLength(4); + expect(q(raw, 'path.fill.solid')!.hasAttribute('mask')).toBe(false); + const none = await mount({ fill: false } as Partial); + expect(q(none, 'path.fill')).toBeNull(); + }); + + it('colours the line by bands, and the icon, name and value by the current band', async () => { + const card = await mount({ + severity: [ + { from: 0, color: 'blue' }, + { from: 18, color: 'red' }, + ], + color_icon: true, + color_name: true, + color_value: true, + color_transition: 'hard', + } as Partial); + expect(q(card, 'path.line')!.getAttribute('stroke')).toBe('url(#cd-trend-0)'); + expect(qa(card, '#cd-trend-0 stop').length).toBe(4); + expect(q(card, '.trend-value')!.getAttribute('style')).toContain('--error-color'); + expect(q(card, '.trend-name')!.getAttribute('style')).toContain('--error-color'); + expect(q(card, '.trend-icon')!.getAttribute('style')).toContain('--error-color'); + // An entity's own colour wins over bands. + const own = await mount({ + entities: [{ entity: 'sensor.in', color: 'green' }], + severity: [{ from: 0, color: 'blue' }], + } as Partial); + expect(q(own, 'path.line')!.getAttribute('stroke')).toContain('--success-color'); + }); + + it('shows other entities small, in a legend, and on a right-hand axis', async () => { + const card = await mount({ + name: 'Temperatures', + entities: ['sensor.in', { entity: 'sensor.out', show_state: true, y_axis: 'secondary', show_legend_state: true }], + labels: true, + } as Partial); + expect(qa(card, 'path.line')).toHaveLength(2); + expect(text(q(card, '.trend-state.small'))).toBe('9.0°C'); + // The card's name is the header's; the legend uses each entity's friendly name. + expect(qa(card, '.trend-legend .series-item').map(text)).toEqual(['Inside', 'Outside 9.0 °C']); + expect(text(q(card, '.trend-labels.primary'))).toMatch(/°C/); + expect(text(q(card, '.trend-labels.secondary'))).toBe('9.0 °C5.0 °C'); + expect(q(card, '.trend-labels-wrap.labels-always')).not.toBeNull(); + }); + + it('leaves out an entity that is off the graph, and draws a fixed value flat', async () => { + const card = await mount({ + entities: [ + { entity: 'sensor.in', fixed_value: true }, + { entity: 'sensor.out', show_graph: false }, + ], + points_per_hour: 0, + curve: 'straight', + } as Partial); + const lines = qa(card, 'path.line'); + expect(lines).toHaveLength(1); + const ys = lines[0].getAttribute('d')!.match(/,([\d.]+)/g)!; + expect(new Set(ys).size).toBe(1); + expect(q(card, '.trend-legend')).toBeNull(); + }); + + it('draws bars from zero, coloured by band, one per day', async () => { + const card = await mount({ + graph: 'bar', + period: { last: 3, unit: 'day' }, + group_by: 'day', + severity: [ + { from: 0, color: 'blue' }, + { from: 18.5, color: 'red' }, + ], + } as Partial); + const bars = qa(card, '.trend-col'); + expect(bars.length).toBeGreaterThanOrEqual(1); + expect(bars.length).toBeLessThanOrEqual(4); + expect(qa(card, 'path.line')).toHaveLength(0); + }); + + it('shows min, average and max, and scales values by value_factor', async () => { + const card = await mount({ + extrema: true, + average: true, + value_factor: -1, + points_per_hour: 0, + } as Partial); + const items = qa(card, '.trend-chip').map(text); + expect(items[0]).toMatch(/^Low 1\.7 °C · /); + expect(items[1]).toMatch(/^Average 1\.\d °C$/); + expect(items[2]).toMatch(/^High 2\.2 °C · /); + expect(text(q(card, '.trend-value'))).toBe('1.9'); + }); + + it('shows the value under the pointer in place of the current one', async () => { + const card = await mount({ name: 'Temperature' }); + const graph = q(card, '.trend-graph') as HTMLElement; + graph.getBoundingClientRect = () => ({ left: 0, width: 100, top: 0, height: 30 }) as DOMRect; + graph.dispatchEvent(new PointerEvent('pointermove', { clientX: 50, pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.trend-when')).not.toBeNull(); + expect(text(q(card, '.trend-value'))).toMatch(/^(19|22)\.\d$/); + expect(q(card, '.hover-dot')).not.toBeNull(); + expect(q(card, '.trend-graph .crosshair')).not.toBeNull(); + graph.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.trend-when')).toBeNull(); + expect(text(q(card, '.trend-value'))).toBe('18.8'); + }); + + it('lays out the header and value as asked, and hides the graph', async () => { + const card = await mount({ + align_icon: 'state', + align_state: 'center', + align_header: 'center', + graph: 'none', + font_size: 80, + font_size_header: 18, + icon_image: '/local/me.png', + group: true, + } as Partial); + expect(q(card, '.trend-header .trend-icon')).toBeNull(); + expect(q(card, '.trend-states .trend-icon img')!.getAttribute('src')).toBe('/local/me.png'); + expect(q(card, '.trend-states.align-center')!.getAttribute('style')).toContain('font-size:80%'); + expect(q(card, '.trend-header.align-center')!.getAttribute('style')).toContain('font-size:18px'); + expect(q(card, '.trend-graph')).toBeNull(); + expect(q(card, '.trend.group.no-graph')).not.toBeNull(); + }); + + it('says when history is loading, failed or empty, under the value', async () => { + const failed = await mount({}, hassWith(history, true)); + expect(text(q(failed, '.trend-graph.message.error'))).toBe("Couldn't load history: boom"); + expect(text(q(failed, '.trend-value'))).toBe('18.8'); + const empty = await mount({}, hassWith({})); + expect(text(q(empty, '.trend-graph.message'))).toBe('No readings in this period.'); + }); + + it('opens the entity from its name, value and legend entry', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out'] }); + expect(qa(card, '.entity').map((e) => (e as HTMLElement).dataset.index)).toEqual(['0', '0', '0', '1']); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'trend', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ graph: 'pie' })).toThrow(/"graph" must be one of: line, bar, none/); + expect(set({ fill: 'striped' })).toThrow(/"fill"/); + expect(set({ points: 'sometimes' })).toThrow(/"points"/); + expect(set({ points_per_hour: -1 })).toThrow(/"points_per_hour"/); + expect(set({ value_factor: 1.5 })).toThrow(/whole number/); + expect(set({ y_min: 'low' })).toThrow(/"y_min"/); + expect(set({ extrema: 'yes' })).toThrow(/"extrema"/); + expect(set({ icon_image: 5 })).toThrow(/"icon_image"/); + expect(set({ entities: [{ entity: 'sensor.in', y_axis: 'left' }] })).toThrow(/y_axis/); + expect(set({ entities: [{ entity: 'sensor.in', aggregate: 'avg' }] })).toThrow(/aggregate/); + expect(set({ entities: [{ entity: 'sensor.in', show_fill: 'no' }] })).toThrow(/show_fill/); + expect(set({ fill: true, points: false, labels: 'hover', y_min: '~0', y_max: 30 })).not.toThrow(); + }); +}); + +describe('trend card editor', () => { + it('shows the Chart section, writes options, and keeps number bounds as numbers', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'trend', entities: ['sensor.in'], y_max: 30 } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const data = form.data as Record; + expect(data).toMatchObject({ + points_per_hour: 0.5, + graph: 'line', + fill: 'fade', + points: 'hover', + labels: 'hover', + height: 150, + line_width: 2.5, + legend: true, + trend_y_max: '30', + trend_tooltip: true, + }); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...data, graph: 'bar', trend_y_min: '0', trend_y_max: '~40', extrema: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, graph: 'bar', y_min: 0, y_max: '~40', extrema: true }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string }[] }).fields.map((f) => f.name); + expect(fields).toContain('y_axis_side'); + expect(fields).toContain('show_state'); + expect(fields).toContain('fixed_value'); + }); +}); From 440f631360725170a7db7c03b2b7f62c0cd11f79 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 14:59:30 +0000 Subject: [PATCH 27/41] fix: the trend card's grid size reaches the sections layout Restores ChartStyleModule.gridOptions, lost when the style registry was merged. --- src/styles/index.ts | 2 + src/types.ts | 93 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index 9630936..eb7ee5f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -103,6 +103,8 @@ export interface ChartStyleModule { render(ctx: ChartContext): TemplateResult; /** Estimated height in px, for masonry sizing. */ height: number; + /** Sections grid size, when not the full-width default (a small tile-like chart). */ + gridOptions?: { columns: number; min_columns: number; min_rows: number }; /** A 64 × 40 picture for the style picker, in theme colours. */ thumbnail(): SVGTemplateResult; /** Throw a readable Error for an invalid option this style reads. */ diff --git a/src/types.ts b/src/types.ts index 786417d..6b41d11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,96 @@ import type { GaugeOptions } from './gauge'; import type { Rule } from './fill'; -export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box' | 'funnel' | 'radar' | 'polar_area' | 'treemap' | 'sunburst' | 'icicle' | 'grouped_bars' | 'bullet' | 'lollipop' | 'waterfall' | 'slope' | 'dumbbell' | 'range_area' | 'sparkline' | 'streamgraph' | 'candlestick' | 'violin' | 'beeswarm' | 'sankey' | 'timeline' | 'climate' | 'wind_rose' | 'day_clock' | 'trend'; -export const STYLE_NAMES: StyleName[] = ['bar', 'thin', 'segmented', 'tank', 'ring', 'needle', 'arc', 'line', 'column', 'area', 'combined', 'pie', 'rings', 'pictograph', 'waffle', 'histogram', 'scatter', 'bubble', 'heatmap', 'box', 'funnel', 'radar', 'polar_area', 'treemap', 'sunburst', 'icicle', 'grouped_bars', 'bullet', 'lollipop', 'waterfall', 'slope', 'dumbbell', 'range_area', 'sparkline', 'streamgraph', 'candlestick', 'violin', 'beeswarm', 'sankey', 'timeline', 'climate', 'wind_rose', 'day_clock', 'trend']; +export type StyleName = + | 'bar' + | 'thin' + | 'segmented' + | 'tank' + | 'ring' + | 'needle' + | 'arc' + | 'line' + | 'column' + | 'area' + | 'combined' + | 'pie' + | 'rings' + | 'pictograph' + | 'waffle' + | 'histogram' + | 'scatter' + | 'bubble' + | 'heatmap' + | 'box' + | 'funnel' + | 'radar' + | 'polar_area' + | 'treemap' + | 'sunburst' + | 'icicle' + | 'grouped_bars' + | 'bullet' + | 'lollipop' + | 'waterfall' + | 'slope' + | 'dumbbell' + | 'range_area' + | 'sparkline' + | 'streamgraph' + | 'candlestick' + | 'violin' + | 'beeswarm' + | 'sankey' + | 'timeline' + | 'climate' + | 'wind_rose' + | 'day_clock' + | 'trend'; +export const STYLE_NAMES: StyleName[] = [ + 'bar', + 'thin', + 'segmented', + 'tank', + 'ring', + 'needle', + 'arc', + 'line', + 'column', + 'area', + 'combined', + 'pie', + 'rings', + 'pictograph', + 'waffle', + 'histogram', + 'scatter', + 'bubble', + 'heatmap', + 'box', + 'funnel', + 'radar', + 'polar_area', + 'treemap', + 'sunburst', + 'icicle', + 'grouped_bars', + 'bullet', + 'lollipop', + 'waterfall', + 'slope', + 'dumbbell', + 'range_area', + 'sparkline', + 'streamgraph', + 'candlestick', + 'violin', + 'beeswarm', + 'sankey', + 'timeline', + 'climate', + 'wind_rose', + 'day_clock', + 'trend', +]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ export type Limit = number | string; From 54d9cffa74feb8b5ce9d827de559e9e91b09278f Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:11:51 +0000 Subject: [PATCH 28/41] feat: zoom into a history chart, pan around it, and drag a window on an overview strip Two new switches in the Chart section of the editor, after the tooltip one: - "Zoom and pan" (`zoom: true`). With a mouse, drag across the chart to zoom into that stretch; once zoomed, drag to pan. Ctrl (or Cmd) + scroll zooms about the pointer, and a trackpad pinch does the same because browsers send it as Ctrl + scroll. A plain scroll is left to the page, so a dashboard still scrolls over the chart; sideways or Shift + scroll pans once zoomed. On a phone or tablet two fingers pinch and pan, keeping the moment under each finger under it; one finger still reads values with the tooltip, and an up-and-down swipe still scrolls. - "Overview strip below the chart" (`overview: true`). The whole period drawn small under the chart with the zoomed window over it: drag the window to move it, its edges to resize it, across the strip for a new one, or tap beside it to centre it there. It works on its own too, for zooming only through the strip. The window can be focused and moved with the arrow keys, zoomed with up/down or + and -, and reset with Escape; screen readers hear the times it shows. A "Reset zoom" button appears in the chart's top corner while zoomed (an icon only on a compact card); a double click or double tap does the same. Zooming fits the value axis and the time labels to what is on show, and draws the readings in the window at full detail rather than the thinned whole period. The zoom is kept on the card, not in its config. It is held as a share of the period, so a live chart keeps the same window as time moves on, and it starts again from the whole period when the previous/next buttons step to another one or the period, grouping or source changes. The narrowest zoom is two groups for grouped readings, else five minutes. The tooltip steps aside during a drag or pinch and comes back on the next move. The gestures, maths, overview strip and their styles live in src/chart/zoom.ts, and chartArea takes the gestures, extra plot classes and a row under the plot, so the area and column charts can adopt it with a few lines (see zoomView, zoomGestures, overviewStrip). The editor fields are ZOOM_FIELDS / ZOOM_SCHEMA in chart-editors.ts. Closes #40 --- src/chart/grid.ts | 40 ++- src/chart/zoom.ts | 698 ++++++++++++++++++++++++++++++++++++ src/editor/chart-editors.ts | 19 + src/styles/line.ts | 69 +++- test/line.test.ts | 2 + test/zoom.test.ts | 380 ++++++++++++++++++++ 6 files changed, 1183 insertions(+), 25 deletions(-) create mode 100644 src/chart/zoom.ts create mode 100644 test/zoom.test.ts diff --git a/src/chart/grid.ts b/src/chart/grid.ts index dc55737..5a35ad7 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -166,6 +166,23 @@ export interface ChartAreaOptions { /** Text for each label along the bottom or up the side, when they aren't times and plain values. */ xLabel?: (v: number) => string; yLabel?: (v: number) => string; + /** Fuller pointer handling (zoom and pan), wrapping the tooltip's; used instead of `hover`. */ + gestures?: Gestures; + /** Extra classes on the plot area, for a feature's own look (`zoomable`, `zoomed`). */ + classes?: string; + /** Under the plot and its time labels, lined up with the plot (an overview strip). */ + below?: TemplateResult | typeof nothing; +} + +/** Every pointer handler the plot area can take. */ +export interface Gestures { + down(ev: PointerEvent): void; + move(ev: PointerEvent): void; + up(ev: PointerEvent): void; + cancel(ev: PointerEvent): void; + leave(ev: PointerEvent): void; + wheel(ev: WheelEvent): void; + dblclick(ev: MouseEvent): void; } /** @@ -184,6 +201,9 @@ export function chartArea({ right, xLabel, yLabel, + gestures, + classes = '', + below = nothing, }: ChartAreaOptions) { const { options } = axes; const outside = options.labels === 'outside'; @@ -210,21 +230,23 @@ export function chartArea({ const rLabels = r ? html`
${outside ? rSizer : nothing}${rightLabels(r, ctx)}
` : nothing; - return html`
+ const wrap = `plot-wrap axes-${options.labels} ${y ? 'has-y' : ''} ${x ? 'has-x' : ''} ${r ? 'has-right' : ''} ${below === nothing ? '' : 'has-overview'}`; + return html`
${outside && y ? html`
${sizer}${yTicks}
` : nothing}
${plot(content, label, defs, gridLines(axes))} ${outside ? nothing : html`${yLabels}${rLabels}${xLabels}`} ${overlay}
- ${outside ? html`${rLabels}${xLabels}` : nothing} + ${outside ? html`${rLabels}${xLabels}` : nothing} ${below}
`; } diff --git a/src/chart/zoom.ts b/src/chart/zoom.ts new file mode 100644 index 0000000..2e0ebb6 --- /dev/null +++ b/src/chart/zoom.ts @@ -0,0 +1,698 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { localize, registerStrings } from '../localize'; +import type { Point, Range } from '../history/types'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { formatDateTime } from './axes'; +import { VIEW_W } from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import type { Gestures } from './grid'; +import { runs } from './paths'; +import { extent, linear, padDomain } from './scale'; +import { downsample } from './downsample'; +import type { Hover } from './tooltip'; + +registerStrings('en', { + zoom_reset: 'Reset zoom', + zoom_window: 'Shown: {from} to {to}', + zoom_window_label: 'Part of the chart shown', + editor_zoom: 'Zoom and pan (pinch, Ctrl + scroll or drag)', + editor_overview: 'Overview strip below the chart', +}); + +/** The part of a chart's span on show, as fractions of it: 0–1 is all of it. */ +export interface ZoomWindow { + from: number; + to: number; +} + +export const FULL: ZoomWindow = { from: 0, to: 1 }; + +/** Drawing height of the overview strip's coordinate box. */ +export const OVERVIEW_H = 40; +/** Points per series in the overview: it is small, so a rough line is enough. */ +const OVERVIEW_POINTS = 200; +/** A mouse press moving less than this (px) is a click, not a drag. */ +const DRAG_PX = 4; +/** Two taps closer than this (ms) are a double tap. */ +const DOUBLE_TAP_MS = 300; +/** Grab distance either side of a brush edge, in px. */ +const HANDLE_PX = 10; + +export interface ZoomOptions { + /** Pinch, Ctrl + scroll and drag on the plot. */ + zoom: boolean; + /** The overview strip under the chart, with a draggable window. */ + overview: boolean; +} + +export function validateZoomOptions(raw: CardConfig): void { + for (const k of ['zoom', 'overview']) { + if (raw[k] !== undefined && typeof raw[k] !== 'boolean') throw new Error(`"${k}" must be true or false`); + } +} + +export function zoomOptions(raw: CardConfig): ZoomOptions { + return { zoom: raw.zoom === true, overview: raw.overview === true }; +} + +// ---------------------------------------------------------------- maths + +const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); + +/** Keep a window inside the span and no narrower than `minSpan` (a fraction). */ +export function clampWindow(w: ZoomWindow, minSpan: number): ZoomWindow { + const min = clamp(minSpan, 0, 1); + let span = clamp(w.to - w.from, min, 1); + if (!Number.isFinite(span)) span = 1; + const from = clamp(Number.isFinite(w.from) ? w.from : 0, 0, 1 - span); + return { from, to: from + span }; +} + +/** Whether a window shows less than the whole span. */ +export function isZoomed(w: ZoomWindow): boolean { + return w.from > 1e-9 || w.to < 1 - 1e-9; +} + +/** + * Zoom by `factor` (over 1 zooms in) about the point `at` of the way across the visible window, + * so the time under the pointer stays under it. + */ +export function zoomAt(w: ZoomWindow, at: number, factor: number, minSpan: number): ZoomWindow { + const span = w.to - w.from; + const pivot = w.from + clamp(at, 0, 1) * span; + const next = clamp(span / factor, minSpan, 1); + const from = pivot - clamp(at, 0, 1) * next; + return clampWindow({ from, to: from + next }, minSpan); +} + +/** Move a window by `delta` (a fraction of the whole span), stopping at either end. */ +export function panBy(w: ZoomWindow, delta: number): ZoomWindow { + const span = w.to - w.from; + const from = clamp(w.from + delta, 0, 1 - span); + return { from, to: from + span }; +} + +/** + * Two fingers: the moments under each finger when they went down stay under them as they move. + * `start` and `now` are the fingers' positions as fractions of the plot's width. + */ +export function pinchWindow( + w0: ZoomWindow, + start: [number, number], + now: [number, number], + minSpan: number, +): ZoomWindow { + const span0 = w0.to - w0.from; + const ta = w0.from + start[0] * span0; + const tb = w0.from + start[1] * span0; + const gap = now[1] - now[0]; + // Fingers crossed or together: keep the zoom, follow their middle. + if (Math.abs(gap) < 0.02 || Math.abs(start[1] - start[0]) < 0.02) { + return panBy(w0, -(((now[0] + now[1]) / 2 - (start[0] + start[1]) / 2) * span0)); + } + const span = clamp((tb - ta) / gap, minSpan, 1); + const from = ta - now[0] * span; + return clampWindow({ from, to: from + span }, minSpan); +} + +/** The window made by dragging from `a` to `b` (fractions of the visible window). */ +export function selectWindow(w: ZoomWindow, a: number, b: number, minSpan: number): ZoomWindow { + const span = w.to - w.from; + const lo = w.from + clamp(Math.min(a, b), 0, 1) * span; + const hi = w.from + clamp(Math.max(a, b), 0, 1) * span; + if (hi - lo >= minSpan) return { from: lo, to: hi }; + const mid = (lo + hi) / 2; + return clampWindow({ from: mid - minSpan / 2, to: mid + minSpan / 2 }, minSpan); +} + +/** The times a window covers. */ +export function windowRange(range: Range, w: ZoomWindow): Range { + const span = range.end - range.start; + return { start: range.start + w.from * span, end: range.start + w.to * span }; +} + +/** + * The points a zoomed chart needs: those inside `view`, and one either side so its lines run on + * to the edges. Points are sorted by time. + */ +export function visiblePoints(points: Point[], view: Range): Point[] { + if (points.length === 0) return points; + let lo = 0; + let hi = points.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + // A grouped point covers `t` to `end`: it is visible while any of it is. + if ((points[mid].end ?? points[mid].t) < view.start) lo = mid + 1; + else hi = mid; + } + const first = Math.max(0, lo - 1); + let last = lo; + while (last < points.length && points[last].t <= view.end) last++; + const out = points.slice(first, Math.min(points.length, last + 1)); + return out.length === points.length ? points : out; +} + +// ---------------------------------------------------------------- state + +type Gesture = + | { kind: 'pan' | 'select'; x0: number; w0: ZoomWindow; moved: boolean; px: number } + | { kind: 'pinch'; w0: ZoomWindow; start: [number, number] } + | { kind: 'left' | 'right' | 'move' | 'new'; f0: number; w0: ZoomWindow; moved: boolean; px: number }; + +interface ZoomState { + key: string; + window: ZoomWindow; + /** Pointers down on the plot: id → x as a fraction of the plot's width. */ + pointers: Map; + gesture?: Gesture; + /** A drag-to-zoom in progress, as fractions of the plot. */ + select?: [number, number]; + lastTap?: { at: number; x: number }; +} + +const states = new WeakMap(); + +function stateOf(host: HTMLElement, key: string): ZoomState { + let s = states.get(host); + if (!s || s.key !== key) { + // A different span (stepped back, or the period changed): start from all of it. + s = { key, window: FULL, pointers: new Map() }; + states.set(host, s); + } + return s; +} + +/** Forget a card's zoom (it leaves the page). */ +export function stopZoom(host: HTMLElement): void { + states.delete(host); +} + +/** Whether a pan, pinch or drag is under way: the tooltip steps aside meanwhile. */ +export function zoomBusy(host: HTMLElement): boolean { + const g = states.get(host)?.gesture; + return !!g && (g.kind === 'pinch' || ('moved' in g && g.moved)); +} + +/** What a chart shows once zoomed: the window, the times it covers, and how to change it. */ +export interface ZoomView { + options: ZoomOptions; + window: ZoomWindow; + zoomed: boolean; + /** The times on show (all of `frame.range` when not zoomed). */ + range: Range; + /** Smallest window, as a fraction of the span. */ + minSpan: number; + /** The window now, which may have moved since this view was made (events between draws). */ + current(): ZoomWindow; + set(w: ZoomWindow): void; + reset(): void; +} + +/** Shortest span a chart can zoom to, in ms: two groups when grouped, else a few minutes. */ +export function minZoomMs(frame: Pick): number { + const unit = { hour: 3_600_000, day: 86_400_000, week: 604_800_000, month: 2_592_000_000 }; + return frame.group ? 2 * unit[frame.group.unit] : 5 * 60_000; +} + +/** + * The card's zoom over `frame`. It lives on the card, not in its config, and starts again from + * the whole span when the span itself changes (previous / next, or a new period). + */ +export function zoomView(ctx: ChartContext, frame: HistoryFrame): ZoomView { + const options = zoomOptions(ctx.raw); + const total = frame.range.end - frame.range.start; + const minSpan = total > 0 ? Math.min(1, minZoomMs(frame) / total) : 1; + const key = `${JSON.stringify(frame.period)}|${frame.back}|${frame.group?.unit ?? ''}|${frame.source}`; + const state = stateOf(ctx.host, key); + if (!options.zoom && !options.overview) state.window = FULL; + const window = clampWindow(state.window, minSpan); + const set = (w: ZoomWindow) => { + const next = clampWindow(w, minSpan); + if (next.from === state.window.from && next.to === state.window.to) return; + state.window = next; + ctx.requestUpdate(); + }; + return { + options, + window, + zoomed: isZoomed(window), + range: windowRange(frame.range, window), + minSpan, + current: () => clampWindow(state.window, minSpan), + set, + reset: () => set(FULL), + }; +} + +// ---------------------------------------------------------------- the plot's gestures + +const fracOf = (ev: PointerEvent | WheelEvent | MouseEvent) => { + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + return { + f: rect.width > 0 ? clamp((ev.clientX - rect.left) / rect.width, 0, 1) : 0, + width: rect.width, + }; +}; + +/** Keep getting a pointer's moves once it leaves the element (not for synthetic pointers). */ +function capture(ev: PointerEvent): void { + try { + (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId); + } catch { + // No such active pointer: nothing to capture. + } +} + +/** A hover `leave` that hides the tooltip at once (a mouse leaving), whatever the pointer was. */ +const hideHover = (hover: Hover | undefined) => hover?.leave({ pointerType: 'mouse' } as PointerEvent); + +/** + * Pointer, wheel and double-click handlers for the plot, wrapping the tooltip's: a mouse drag + * selects a span to zoom into (or pans once zoomed), two fingers pinch and pan, Ctrl + scroll + * (or a trackpad pinch) zooms about the pointer, sideways scroll pans, and a double click or + * double tap goes back to the whole span. One finger still scrubs the tooltip, and up and down + * still scroll the page. + */ +export function zoomGestures(ctx: ChartContext, view: ZoomView, hover?: Hover): Gestures { + const state = states.get(ctx.host)!; + const end = () => { + state.gesture = undefined; + if (state.select) { + state.select = undefined; + ctx.requestUpdate(); + } + }; + return { + down(ev) { + const { f, width } = fracOf(ev); + state.pointers.set(ev.pointerId, f); + if (state.pointers.size === 2) { + const [a, b] = [...state.pointers.values()]; + state.gesture = { kind: 'pinch', w0: view.current(), start: a <= b ? [a, b] : [b, a] }; + state.select = undefined; + hideHover(hover); + return; + } + if (state.pointers.size > 2) return; + if (ev.pointerType === 'mouse' && ev.button === 0) { + const w0 = view.current(); + state.gesture = { kind: isZoomed(w0) ? 'pan' : 'select', x0: f, w0, moved: false, px: width }; + capture(ev); + } + hover?.move(ev); + }, + move(ev) { + if (!state.pointers.has(ev.pointerId)) { + hover?.move(ev); + return; + } + const { f } = fracOf(ev); + state.pointers.set(ev.pointerId, f); + const g = state.gesture; + if (g?.kind === 'pinch') { + if (state.pointers.size < 2) return; + const [a, b] = [...state.pointers.values()]; + view.set(pinchWindow(g.w0, g.start, a <= b ? [a, b] : [b, a], view.minSpan)); + return; + } + if (g && (g.kind === 'pan' || g.kind === 'select')) { + if (!g.moved && Math.abs(f - g.x0) * g.px < DRAG_PX) { + hover?.move(ev); + return; + } + if (!g.moved) hideHover(hover); + g.moved = true; + if (g.kind === 'pan') { + view.set(panBy(g.w0, -(f - g.x0) * (g.w0.to - g.w0.from))); + } else { + state.select = [g.x0, f]; + ctx.requestUpdate(); + } + return; + } + hover?.move(ev); + }, + up(ev) { + const { f, width } = fracOf(ev); + const g = state.gesture; + state.pointers.delete(ev.pointerId); + if (g?.kind === 'pinch') { + // The second finger lifting ends the pinch; the first doesn't then start scrubbing. + if (state.pointers.size === 0) state.gesture = undefined; + return; + } + if (g?.kind === 'select' && g.moved) view.set(selectWindow(g.w0, g.x0, f, view.minSpan)); + const tapped = !g || !('moved' in g) || !g.moved; + end(); + if (ev.pointerType !== 'mouse' && tapped) { + const now = ev.timeStamp || Date.now(); + const last = state.lastTap; + if (last && now - last.at < DOUBLE_TAP_MS && Math.abs(f - last.x) * width < 30) { + state.lastTap = undefined; + view.reset(); + } else { + state.lastTap = { at: now, x: f }; + } + } + }, + cancel(ev) { + state.pointers.delete(ev.pointerId); + // A stray third finger going doesn't end a pinch. + if (state.gesture?.kind === 'pinch' && state.pointers.size >= 2) return; + end(); + hover?.leave(ev); + }, + leave(ev) { + // Mouse capture keeps a drag going outside the plot; it ends on release. + if (state.gesture && ev.pointerType === 'mouse') return; + state.pointers.delete(ev.pointerId); + hover?.leave(ev); + }, + wheel(ev) { + const { f } = fracOf(ev); + if (ev.ctrlKey || ev.metaKey) { + // Ctrl + scroll, and a trackpad pinch (which browsers send as Ctrl + scroll). + ev.preventDefault(); + const step = ev.deltaMode === 1 ? ev.deltaY * 16 : ev.deltaY; + view.set(zoomAt(view.current(), f, Math.exp(-clamp(step, -100, 100) / 200), view.minSpan)); + return; + } + const sideways = ev.shiftKey && ev.deltaX === 0 ? ev.deltaY : ev.deltaX; + const w = view.current(); + if (isZoomed(w) && Math.abs(sideways) > Math.abs(ev.shiftKey ? 0 : ev.deltaY)) { + ev.preventDefault(); + const { width } = fracOf(ev); + view.set(panBy(w, (sideways / Math.max(width, 1)) * (w.to - w.from))); + } + }, + dblclick() { + view.reset(); + }, + }; +} + +/** The drag-to-zoom band and the reset button, over the plot. */ +export function zoomLayer(ctx: ChartContext, view: ZoomView): TemplateResult | typeof nothing { + const state = states.get(ctx.host); + const sel = state?.select; + const band = sel + ? html`
` + : nothing; + const reset = view.zoomed + ? html`` + : nothing; + if (band === nothing && reset === nothing) return nothing; + return html`${band}${reset}`; +} + +const ZOOM_OUT = + 'M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z'; + +// ---------------------------------------------------------------- overview strip + +/** Each series as a thin line across the whole span, for the overview strip. */ +export function overviewLines(series: readonly ChartSeries[], range: Range): SVGTemplateResult { + const span = extent(series.map((s) => s.points)); + if (!span) return svg``; + const [lo, hi] = padDomain(span); + const x = linear([range.start, range.end], [0, VIEW_W]); + const y = linear([lo, hi], [OVERVIEW_H - 2, 2]); + return svg`${series.map((s) => { + const d = runs(downsample(s.points, OVERVIEW_POINTS)) + .map((run) => + run.length === 1 + ? `M${x(run[0].t).toFixed(1)},${y(run[0].v as number).toFixed(1)}h0.5` + : `M${run.map((p) => `${x(p.t).toFixed(1)},${y(p.v as number).toFixed(1)}`).join('L')}`, + ) + .join(''); + return svg``; + })}`; +} + +/** + * The overview: the whole span drawn small, with the zoomed window over it. Drag the window to + * move it, its edges to resize it, or across the strip to choose a new one; tap the strip to + * centre the window there; double-tap to show everything again. Arrow keys move the window, + * up and down (or + and −) zoom, and Escape resets. + */ +export function overviewStrip( + ctx: ChartContext, + view: ZoomView, + range: Range, + content: SVGTemplateResult, +): TemplateResult { + const state = states.get(ctx.host)!; + const w = view.window; + const pos = (ev: PointerEvent) => fracOf(ev); + const down = (ev: PointerEvent) => { + if (ev.button > 0) return; + ev.stopPropagation(); + const { f, width } = pos(ev); + const w = view.current(); + const tol = HANDLE_PX / Math.max(width, 1); + let kind: 'left' | 'right' | 'move' | 'new' = 'new'; + if (isZoomed(w)) { + if (Math.abs(f - w.from) <= tol) kind = 'left'; + else if (Math.abs(f - w.to) <= tol) kind = 'right'; + else if (f > w.from && f < w.to) kind = 'move'; + } + state.gesture = { kind, f0: f, w0: w, moved: false, px: width }; + capture(ev); + }; + const move = (ev: PointerEvent) => { + const g = state.gesture; + if (!g || !('f0' in g)) return; + const { f } = pos(ev); + if (!g.moved && Math.abs(f - g.f0) * g.px < DRAG_PX) return; + g.moved = true; + const { w0 } = g; + if (g.kind === 'move') view.set(panBy(w0, f - g.f0)); + else if (g.kind === 'left') view.set({ from: Math.min(f, w0.to - view.minSpan), to: w0.to }); + else if (g.kind === 'right') view.set({ from: w0.from, to: Math.max(f, w0.from + view.minSpan) }); + else view.set(selectWindow(FULL, g.f0, f, view.minSpan)); + }; + const up = () => { + const g = state.gesture; + if (!g || !('f0' in g)) return; + state.gesture = undefined; + if (!g.moved && g.kind === 'new' && isZoomed(g.w0)) { + // A tap beside the window centres it there. + const span = g.w0.to - g.w0.from; + view.set({ from: g.f0 - span / 2, to: g.f0 + span / 2 }); + } + }; + const key = (ev: KeyboardEvent) => { + const w = view.current(); + const span = w.to - w.from; + const centre = 0.5; + const actions: Record ZoomWindow> = { + ArrowLeft: () => panBy(w, -span / 4), + ArrowRight: () => panBy(w, span / 4), + ArrowUp: () => zoomAt(w, centre, 1.5, view.minSpan), + ArrowDown: () => zoomAt(w, centre, 1 / 1.5, view.minSpan), + '+': () => zoomAt(w, centre, 1.5, view.minSpan), + '=': () => zoomAt(w, centre, 1.5, view.minSpan), + '-': () => zoomAt(w, centre, 1 / 1.5, view.minSpan), + Home: () => panBy(w, -1), + End: () => panBy(w, 1), + Escape: () => FULL, + }; + const act = actions[ev.key]; + if (!act) return; + ev.preventDefault(); + ev.stopPropagation(); + view.set(act()); + }; + const shown = windowRange(range, w); + const valuetext = localize(ctx.hass, 'zoom_window', { + from: formatDateTime(shown.start, ctx.hass), + to: formatDateTime(shown.end, ctx.hass), + }); + const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + return html`
(state.gesture = undefined)} + @dblclick=${(ev: Event) => { + ev.stopPropagation(); + view.reset(); + }} + > + +
+
+
+ +
+
`; +} + +export const zoomStyles = css` + .plot-area.zoomable { + touch-action: pan-y; + } + .plot-area.zoomable.zoomed { + cursor: grab; + } + .plot-area.zoomable.zoomed:active { + cursor: grabbing; + } + .zoom-select { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + border-left: 1px solid var(--primary-color); + border-right: 1px solid var(--primary-color); + pointer-events: none; + z-index: 1; + } + .zoom-reset { + position: absolute; + top: 2px; + right: 2px; + /* Under the tooltip: the box only covers it while the pointer is away from the corner. */ + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 6px; + font: inherit; + font-size: 11px; + line-height: 18px; + color: var(--primary-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); + border-radius: 12px; + cursor: pointer; + } + .zoom-reset svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .zoom-reset:hover { + background: color-mix(in srgb, var(--primary-color) 12%, var(--card-background-color, #fff)); + } + .zoom-reset:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .frame.compact .zoom-reset span { + display: none; + } + .frame.compact .zoom-reset { + padding: 2px; + } + .overview { + position: relative; + grid-column: 2; + grid-row: 3; + height: 32px; + margin-top: 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--primary-text-color) 4%, transparent); + touch-action: none; + cursor: crosshair; + user-select: none; + -webkit-user-select: none; + } + .frame.compact .overview { + height: 22px; + margin-top: 4px; + } + .overview-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .overview-lines path { + stroke-width: 1; + vector-effect: non-scaling-stroke; + opacity: 0.8; + } + .overview-shade { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--card-background-color, #fff) 60%, transparent); + pointer-events: none; + } + .overview-window { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--primary-color) 70%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + cursor: grab; + } + .overview:not(.zoomed) .overview-window { + border-color: transparent; + background: none; + cursor: crosshair; + } + .overview-window:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .overview-handle { + position: absolute; + top: 50%; + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--primary-color); + transform: translate(-50%, -50%); + cursor: ew-resize; + } + .overview-handle.left { + left: 0; + } + .overview-handle.right { + left: 100%; + } + .overview:not(.zoomed) .overview-handle { + display: none; + } + .plot-wrap.has-overview { + grid-template-rows: minmax(0, 1fr) auto auto; + height: 220px; + } + .frame.compact .plot-wrap.has-overview { + height: 140px; + } +`; diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index cb522cc..1f2a3c8 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -203,6 +203,23 @@ export function axesSchema(hass?: HomeAssistant) { ]; } +/** Zoom gestures and the overview strip, shared by every history chart. */ +export const ZOOM_FIELDS: FieldBinding[] = [ + { name: 'zoom', path: ['zoom'], default: false }, + { name: 'overview', path: ['overview'], default: false }, +]; + +export const ZOOM_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'zoom', selector: { boolean: {} } }, + { name: 'overview', selector: { boolean: {} } }, + ], + }, +]; + const cache = new Map(); /** Build a schema once per language (and per style), so ha-form isn't handed a new one each render. */ @@ -245,6 +262,7 @@ const LINE_FIELDS: FieldBinding[] = [ { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, { name: 'dots', path: ['dots'], default: false }, ...AXES_FIELDS, + ...ZOOM_FIELDS, ...LAYOUT_FIELDS, ]; @@ -269,6 +287,7 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { }, { name: 'labels', selector: { boolean: {} } }, ...axesSchema(ctx.hass), + ...ZOOM_SCHEMA, ...layoutSchema(ctx.hass), ]); return chartForm(ctx, 'chart', schema, LINE_FIELDS); diff --git a/src/styles/line.ts b/src/styles/line.ts index 33cfcbe..6d14629 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -30,6 +30,18 @@ import { validateTooltipOptions, } from '../chart/tooltip'; import { extent } from '../chart/scale'; +import { + overviewLines, + overviewStrip, + stopZoom, + validateZoomOptions, + visiblePoints, + zoomBusy, + zoomGestures, + zoomLayer, + zoomStyles, + zoomView, +} from '../chart/zoom'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; import { formatNumber } from '../format'; @@ -108,12 +120,22 @@ export function seriesPath(s: ChartSeries, scales: PlotScales, stroke: string, r `; } -function valueLabels(frame: HistoryFrame, scales: PlotScales, ctx: Parameters[0]) { +function valueLabels( + frame: HistoryFrame, + series: ChartSeries[], + view: Range, + scales: PlotScales, + ctx: Parameters[0], +) { return html`
- ${frame.series.flatMap((s) => { + ${series.flatMap((s) => { // The points at either edge are carried readings, not new ones: no label (groups are all real). const readings = s.points.filter( - (p) => p.v !== null && (frame.group !== undefined || (p.t !== frame.range.start && p.t !== frame.range.end)), + (p) => + p.v !== null && + p.t >= view.start && + p.t <= view.end && + (frame.group !== undefined || (p.t !== frame.range.start && p.t !== frame.range.end)), ); if (readings.length > MAX_LABELS) return []; return readings.map( @@ -137,6 +159,7 @@ export const line: ChartStyleModule = { ${gridStyles} ${tooltipStyles} ${layoutStyles} + ${zoomStyles} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -159,6 +182,7 @@ export const line: ChartStyleModule = { validateTooltipOptions(raw); validateSeriesStyle(raw); validateLayout(raw); + validateZoomOptions(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } @@ -172,6 +196,7 @@ export const line: ChartStyleModule = { disconnectHistory(host); stopChartWidth(host); stopHover(host); + stopZoom(host); }, render(ctx) { const frame = historyFrame(ctx); @@ -181,18 +206,31 @@ export const line: ChartStyleModule = { const message = frameMessage(frame, ctx); const layout = chartLayout(ctx.raw); if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); - const axes = plotAxes(ctx, frame.range, extent(frame.series.map((s) => s.points))); + // Zoomed, the chart draws the readings in its window (and one either side), fitted to them. + const zoom = zoomView(ctx, frame); + const view = zoom.range; + const shown = zoom.zoomed + ? frame.series.map((s) => ({ ...s, points: visiblePoints(s.points, view) })) + : frame.series; + const axes = plotAxes(ctx, view, extent(shown.map((s) => s.points))); const { scales } = axes; - const tooltip = tooltipEnabled(ctx.raw); + const tooltip = tooltipEnabled(ctx.raw) && !zoomBusy(ctx.host); const byBands = ctx.raw.color_by === 'bands'; const defs = byBands - ? svg`${frame.series.map((s) => bandGradient(`cd-bands-${s.index}`, s.entity.bands, scales))}` + ? svg`${shown.map((s) => bandGradient(`cd-bands-${s.index}`, s.entity.bands, scales))}` : nothing; // Listed order, last on top, with any series marked "on top" drawn after the rest. - const ordered = drawOrder(frame.series, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); + const ordered = drawOrder(shown, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); const lines = svg`${ordered.map((s) => seriesPath(s, scales, byBands ? `url(#cd-bands-${s.index})` : s.color, frame.range, ctx.raw), )}`; + const hover = tooltipEnabled(ctx.raw) + ? hoverHandlers( + ctx, + view, + shown.map((s) => s.points), + ) + : undefined; const label = localize(ctx.hass, 'chart_aria', { style: localize(ctx.hass, 'editor_style_line'), names: frame.series.map((s) => s.entity.name).join(', '), @@ -207,15 +245,14 @@ export const line: ChartStyleModule = { label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} - ${ctx.raw.labels === true ? valueLabels(frame, scales, ctx) : nothing} - ${tooltip ? tooltipLayer(ctx, frame.series, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, - hover: tooltip - ? hoverHandlers( - ctx, - frame.range, - frame.series.map((s) => s.points), - ) - : undefined, + ${ctx.raw.labels === true ? valueLabels(frame, shown, view, scales, ctx) : nothing} ${zoomLayer(ctx, zoom)} + ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, + hover, + gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, + classes: zoom.options.zoom ? `zoomable ${zoom.zoomed ? 'zoomed' : ''}` : '', + below: zoom.options.overview + ? overviewStrip(ctx, zoom, frame.range, overviewLines(frame.series, frame.range)) + : nothing, }), ); }, diff --git a/test/line.test.ts b/test/line.test.ts index 69a7e5b..bb83ae1 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -276,6 +276,8 @@ describe('line chart editor', () => { grid_horizontal: true, grid_vertical: false, tooltip: true, + zoom: false, + overview: false, header: 'above', compact: false, }); diff --git a/test/zoom.test.ts b/test/zoom.test.ts new file mode 100644 index 0000000..37ac388 --- /dev/null +++ b/test/zoom.test.ts @@ -0,0 +1,380 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + FULL, + clampWindow, + isZoomed, + minZoomMs, + panBy, + pinchWindow, + selectWindow, + visiblePoints, + windowRange, + zoomAt, +} from '../src/chart/zoom'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const close = (w: { from: number; to: number }, from: number, to: number) => { + expect(w.from).toBeCloseTo(from, 6); + expect(w.to).toBeCloseTo(to, 6); +}; + +describe('zoom maths', () => { + it('keeps a window inside the span and no narrower than the least', () => { + close(clampWindow({ from: -0.2, to: 0.3 }, 0.1), 0, 0.5); + close(clampWindow({ from: 0.9, to: 1.2 }, 0.1), 0.7, 1); + close(clampWindow({ from: 0.5, to: 0.5 }, 0.1), 0.5, 0.6); + close(clampWindow({ from: 0.2, to: 3 }, 0.1), 0, 1); + close(clampWindow({ from: NaN, to: NaN }, 0.1), 0, 1); + expect(isZoomed(FULL)).toBe(false); + expect(isZoomed({ from: 0, to: 0.99 })).toBe(true); + }); + + it('zooms about the pointer, so the time under it stays put', () => { + const w = zoomAt(FULL, 0.25, 2, 0.01); + close(w, 0.125, 0.625); + // The point a quarter across was at 0.25 of the span and still is. + expect(w.from + 0.25 * (w.to - w.from)).toBeCloseTo(0.25); + close(zoomAt(w, 0.5, 0.25, 0.01), 0, 1); + close(zoomAt(FULL, 0.5, 1000, 0.1), 0.45, 0.55); + }); + + it('pans a window, stopping at either end', () => { + close(panBy({ from: 0.2, to: 0.4 }, 0.1), 0.3, 0.5); + close(panBy({ from: 0.2, to: 0.4 }, -1), 0, 0.2); + close(panBy({ from: 0.2, to: 0.4 }, 5), 0.8, 1); + }); + + it('pinches: the moments under each finger stay under them', () => { + // Fingers at 0.4 and 0.6 of the whole span spread to 0.2 and 0.8: three times closer in. + const w = pinchWindow(FULL, [0.4, 0.6], [0.2, 0.8], 0.01); + close(w, 1 / 3, 2 / 3); + // Fingers moving together sideways pan. + close(pinchWindow({ from: 0.4, to: 0.6 }, [0.2, 0.8], [0.3, 0.9], 0.01), 0.38, 0.58); + // Fingers that meet keep the zoom and follow their middle. + close(pinchWindow({ from: 0.4, to: 0.6 }, [0.4, 0.6], [0.6, 0.605], 0.01), 0.3795, 0.5795); + close(pinchWindow(FULL, [0.5, 0.5], [0.1, 0.9], 0.01), 0, 1); + }); + + it('turns a drag into a window, widening one that is too narrow', () => { + close(selectWindow({ from: 0.5, to: 1 }, 0.8, 0.2, 0.01), 0.6, 0.9); + close(selectWindow(FULL, 0.5, 0.5, 0.1), 0.45, 0.55); + }); + + it('finds the times on show and the points they need', () => { + expect(windowRange({ start: 1000, end: 2000 }, { from: 0.25, to: 0.5 })).toEqual({ start: 1250, end: 1500 }); + const pts = [0, 10, 20, 30, 40].map((t) => ({ t, v: t })); + expect(visiblePoints(pts, { start: 12, end: 28 }).map((p) => p.t)).toEqual([10, 20, 30]); + expect(visiblePoints(pts, { start: 20, end: 20 }).map((p) => p.t)).toEqual([10, 20, 30]); + expect(visiblePoints(pts, { start: -5, end: 50 })).toBe(pts); + expect(visiblePoints([], { start: 0, end: 1 })).toEqual([]); + // A group from 10 to 19 is still in view at 15. + const groups = [0, 10, 20].map((t) => ({ t, v: 1, end: t + 10 })); + expect(visiblePoints(groups, { start: 15, end: 16 }).map((p) => p.t)).toEqual([0, 10, 20]); + expect(minZoomMs({ group: undefined })).toBe(300_000); + expect(minZoomMs({ group: { unit: 'day', aggregate: 'mean' } })).toBe(2 * 86_400_000); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + const rows = (base: number) => + Array.from({ length: 24 }, (_, i) => ({ s: String(base + (i % 5)), lu: sec((24 - i) * H - 60_000) })); + h.callWS = vi.fn(async () => ({ 'sensor.in': rows(18), 'sensor.out': rows(4) })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record = {}) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + zoom: true, + ...extra, + } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel) as HTMLElement; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; + +/** Make `el` 1000 px wide, so a fraction across is `frac * 1000` px. */ +function sized(el: Element) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + return el; +} + +function pointer(el: Element, type: string, frac: number, opts: { id?: number; kind?: string; button?: number } = {}) { + sized(el); + const ev = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: frac * 1000, button: opts.button ?? 0 }); + Object.defineProperty(ev, 'pointerType', { value: opts.kind ?? 'mouse' }); + Object.defineProperty(ev, 'pointerId', { value: opts.id ?? 1 }); + el.dispatchEvent(ev); + return ev; +} + +function wheel(el: Element, frac: number, init: WheelEventInit) { + sized(el); + const ev = new WheelEvent('wheel', { bubbles: true, cancelable: true, clientX: frac * 1000, ...init }); + el.dispatchEvent(ev); + return ev; +} + +const timeLabels = (card: Card) => qa(card, '.x-axis .axis-label').map((e) => e.textContent); +const windowStyle = (card: Card) => q(card, '.overview-window').getAttribute('style'); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('zoom on the line chart', () => { + it('zooms with Ctrl + scroll about the pointer, and resets with the button or a double click', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + expect(area.classList.contains('zoomable')).toBe(true); + expect(q(card, '.zoom-reset')).toBeNull(); + const before = timeLabels(card); + const line = q(card, 'path.line').getAttribute('d'); + // A plain scroll is the page's. + expect(wheel(area, 0.5, { deltaY: -100 }).defaultPrevented).toBe(false); + expect(wheel(area, 0.75, { deltaY: -200, ctrlKey: true }).defaultPrevented).toBe(true); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + expect(area.classList.contains('zoomed')).toBe(true); + expect(timeLabels(card)).not.toEqual(before); + expect(q(card, 'path.line').getAttribute('d')).not.toBe(line); + expect(windowStyle(card)).not.toMatch(/left:0%;width:100%/); + q(card, '.zoom-reset').click(); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + expect(windowStyle(card)).toMatch(/left:0%;width:100%/); + wheel(area, 0.5, { deltaY: -3, deltaMode: 1, metaKey: true }); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + area.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + }); + + it('drags to choose a span, then drags to pan it, and scrolls sideways', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + pointer(area, 'pointerdown', 0.25); + pointer(area, 'pointermove', 0.251); + await card.updateComplete; + expect(q(card, '.zoom-select')).toBeNull(); + expect(q(card, '.tooltip')).not.toBeNull(); + pointer(area, 'pointermove', 0.5); + // Leaving mid-drag doesn't end it. + pointer(area, 'pointerleave', 0.6); + await card.updateComplete; + expect(q(card, '.zoom-select')!.getAttribute('style')).toBe('left:25%;width:25%'); + expect(q(card, '.tooltip')).toBeNull(); + pointer(area, 'pointerup', 0.5); + await card.updateComplete; + expect(q(card, '.zoom-select')).toBeNull(); + expect(windowStyle(card)).toBe('left:25%;width:25%'); + // Zoomed, a drag pans: 10% of the plot to the left moves on by 10% of the window. + pointer(area, 'pointerdown', 0.5); + pointer(area, 'pointermove', 0.4); + pointer(area, 'pointerup', 0.4); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + // Sideways scroll (or Shift + scroll) pans too. + expect(wheel(area, 0.5, { deltaX: 400 }).defaultPrevented).toBe(true); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:37.5%;width:25%'); + wheel(area, 0.5, { deltaY: -400, shiftKey: true }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + // A right-button press, and a plain click, don't zoom. + pointer(area, 'pointerdown', 0.1, { button: 2 }); + pointer(area, 'pointerup', 0.1, { button: 2 }); + pointer(area, 'pointerdown', 0.1); + pointer(area, 'pointerup', 0.1); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:27.5%;width:25%'); + }); + + it('pinches with two fingers and double-taps back', async () => { + const card = await mount({ overview: true }); + const area = q(card, '.plot-area'); + pointer(area, 'pointerdown', 0.4, { id: 1, kind: 'touch' }); + await card.updateComplete; + expect(q(card, '.tooltip')).not.toBeNull(); + pointer(area, 'pointerdown', 0.6, { id: 2, kind: 'touch' }); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + // A third finger is ignored. + pointer(area, 'pointerdown', 0.9, { id: 3, kind: 'touch' }); + pointer(area, 'pointercancel', 0.9, { id: 3, kind: 'touch' }); + pointer(area, 'pointermove', 0.2, { id: 1, kind: 'touch' }); + pointer(area, 'pointermove', 0.8, { id: 2, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:33.33%;width:33.33%'); + pointer(area, 'pointerup', 0.8, { id: 2, kind: 'touch' }); + pointer(area, 'pointermove', 0.1, { id: 1, kind: 'touch' }); + pointer(area, 'pointerup', 0.1, { id: 1, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:33.33%;width:33.33%'); + pointer(area, 'pointerdown', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerup', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerleave', 0.5, { id: 4, kind: 'touch' }); + pointer(area, 'pointerdown', 0.51, { id: 5, kind: 'touch' }); + pointer(area, 'pointerup', 0.51, { id: 5, kind: 'touch' }); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + }); + + it('moves, resizes and redraws the window on the overview strip, and by keyboard', async () => { + const card = await mount({ zoom: false, overview: true }); + expect(q(card, '.plot-area').classList.contains('zoomable')).toBe(false); + const strip = q(card, '.overview'); + expect(qa(card, '.overview-lines path')).toHaveLength(2); + // Across the strip: a new window. + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointermove', 0.6); + pointer(strip, 'pointerup', 0.6); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:20%;width:40%'); + expect(q(card, '.overview').classList.contains('zoomed')).toBe(true); + // Its middle moves it; its edges resize it. + pointer(strip, 'pointerdown', 0.4); + pointer(strip, 'pointermove', 0.5); + pointer(strip, 'pointerup', 0.5); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:30%;width:40%'); + pointer(strip, 'pointerdown', 0.7); + pointer(strip, 'pointermove', 0.9); + pointer(strip, 'pointerup', 0.9); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:30%;width:60%'); + pointer(strip, 'pointerdown', 0.3); + pointer(strip, 'pointermove', 0.5); + pointer(strip, 'pointerup', 0.5); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:50%;width:40%'); + // A tap beside it centres it there; a moving pointer with nothing pressed does nothing. + pointer(strip, 'pointermove', 0.1); + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointerup', 0.2); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:40%'); + pointer(strip, 'pointerup', 0.2); + const win = q(card, '.overview-window'); + expect(win.getAttribute('aria-valuetext')).toMatch(/^Shown: .+ to .+/); + const key = (k: string) => { + const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }); + win.dispatchEvent(ev); + return ev.defaultPrevented; + }; + expect(key('ArrowRight')).toBe(true); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:10%;width:40%'); + key('ArrowUp'); + await card.updateComplete; + expect(windowStyle(card)).toMatch(/width:26.67%/); + key('-'); + key('End'); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:60%;width:40%'); + for (const k of ['ArrowLeft', 'ArrowDown', '+', '=', 'Home']) key(k); + expect(key('a')).toBe(false); + key('Escape'); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + // A right-button press on the strip is ignored; a double click resets. + pointer(strip, 'pointerdown', 0.2, { button: 2 }); + pointer(strip, 'pointermove', 0.6, { button: 2 }); + pointer(strip, 'pointerdown', 0.2); + pointer(strip, 'pointermove', 0.6); + pointer(strip, 'pointercancel', 0.6); + await card.updateComplete; + strip.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + await card.updateComplete; + expect(windowStyle(card)).toBe('left:0%;width:100%'); + }); + + it('starts again from the whole span after previous / next, and when switched off', async () => { + const card = await mount({ period: { last: 24, unit: 'hour', navigate: true }, overview: true }); + wheel(q(card, '.plot-area'), 0.5, { deltaY: -200, ctrlKey: true }); + await card.updateComplete; + expect(q(card, '.zoom-reset')).not.toBeNull(); + q(card, '.period-prev').click(); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + expect(q(card, '.zoom-reset')).toBeNull(); + const off = await mount({ zoom: false }); + expect(q(off, '.overview')).toBeNull(); + expect(q(off, '.plot-area').classList.contains('zoomable')).toBe(false); + }); + + it('checks its options, and forgets the zoom when it leaves the page', async () => { + const card = await mount(); + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.in'], zoom: 'yes' } as unknown as CardConfig), + ).toThrow('"zoom" must be true or false'); + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.in'], overview: 1 } as unknown as CardConfig), + ).toThrow('"overview" must be true or false'); + card.remove(); + }); +}); + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} + +describe('zoom in the editor', () => { + it('sets zoom and the overview strip from the Chart section', async () => { + await import('../src/editor/chartdeck-card-editor'); + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const names = JSON.stringify(form.schema); + expect(names).toContain('"zoom"'); + expect(names).toContain('"overview"'); + expect((form.data as Record).zoom).toBe(false); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), zoom: true, overview: true } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, zoom: true, overview: true }); + }); +}); From 16aeaf0e3baa549d504f977e838e74afc3a193ef Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:20:04 +0000 Subject: [PATCH 29/41] feat: mark thresholds, value bands, times, state changes and readings on a history chart A line chart can now carry annotations, set up in the editor's Chart section under "Annotations" (one form per annotation, a remove button on each, and buttons to add one of each kind) and written as an `annotations:` list: - `type: line` draws a horizontal line at a value: a number, or an entity whose state is the number (an input_number target, a setpoint sensor). Dashed unless `dash: false`. The value axis widens to include it, so a threshold off the readings' range is still on show. - `type: band` shades a band of values between `from` and `to` (numbers or entities); leave either out to run to the chart's edge. - `type: time` draws a vertical mark at set times: "07:00" marks every day at seven in Home Assistant's time zone, "2026-10-28 07:00" marks that moment once, and ISO times with an offset are taken as written. - `type: state` marks each time an entity changed to a state ("on"), or every change when no state is given, and with `shade: true` also shades the stretches it spent in that state - "heating on" across a temperature chart. Its states load with one history call of their own, are kept up to date from live states like the readings are, and are simply left off while they load or if loading fails. - `type: point` labels the highest, lowest, latest or first reading of a series (the first series unless `entity` names another), with a dot, the value and an optional label before it. Every annotation takes a `label` and a `color` (a palette name or any CSS colour; each kind has its own default). A new switch, "Shade the severity bands behind the chart" (`zones: true`), draws the card's severity bands as faint zones behind the readings. Labels of time and state marks from every annotation share two rows along the top of the chart; one that would overlap another is left out (its line stays, and each label's tooltip gives its time), and above 60 marks only the lines are drawn. An annotation whose entity is unavailable or missing is skipped without upsetting the chart, and a change to any entity an annotation reads redraws the card. Drawing, validation and the maths are in src/chart/annotations.ts (annotationLayers returns what goes behind the data, in front of it, and over it), and the editor list in src/editor/annotations-editor.ts, so the area and column charts can take them with a few lines. Closes #41 --- src/chart/annotations.ts | 634 +++++++++++++++++++++++++++++++ src/editor/annotations-editor.ts | 240 ++++++++++++ src/editor/chart-editors.ts | 8 +- src/styles/line.ts | 21 +- test/annotations.test.ts | 457 ++++++++++++++++++++++ test/line.test.ts | 1 + 6 files changed, 1357 insertions(+), 4 deletions(-) create mode 100644 src/chart/annotations.ts create mode 100644 src/editor/annotations-editor.ts create mode 100644 test/annotations.test.ts diff --git a/src/chart/annotations.ts b/src/chart/annotations.ts new file mode 100644 index 0000000..291981b --- /dev/null +++ b/src/chart/annotations.ts @@ -0,0 +1,634 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { formatNumber, withUnit } from '../format'; +import { toReading } from '../history/fetch'; +import { boundaries, wallTime, zonedTime } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Range } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant, ResolvedBand } from '../types'; +import { formatDateTime } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import type { PlotScales } from './grid'; + +registerStrings('en', { + annotation_at: '{label} at {time}', +}); + +/** What an annotation marks. */ +export const ANNOTATION_TYPES = ['line', 'band', 'time', 'state', 'point'] as const; +export type AnnotationType = (typeof ANNOTATION_TYPES)[number]; + +/** Which reading a point label goes on. */ +export const POINT_AT = ['max', 'min', 'last', 'first'] as const; +export type PointAt = (typeof POINT_AT)[number]; + +/** + * One annotation, as written in YAML. Values (`value`, `from`, `to`) are a number or an entity id + * whose state is the number. + */ +export interface Annotation { + type: AnnotationType; + label?: string; + color?: string; + /** line: where it is drawn. */ + value?: number | string; + /** line: dashed (the default) or solid. */ + dash?: boolean; + /** band: its bottom and top; either may be left out to run to the chart's edge. */ + from?: number | string; + to?: number | string; + /** time: "07:00" (every day) or "2026-10-28 07:00" (once), one or a list. */ + times?: string | string[]; + /** state: the entity whose changes are marked, and the state that counts (any change if absent). */ + entity?: string; + state?: string; + /** state: shade the stretches while the entity is in the state, as well as marking each start. */ + shade?: boolean; + /** point: which reading gets a label. */ + at?: PointAt; +} + +/** Default colours, picked to stand apart from the first series colours. */ +const DEFAULT_COLOR: Record = { + line: 'red', + band: 'green', + time: 'grey', + state: 'amber', + point: 'primary', +}; + +/** Above this many marks from one annotation, only the lines are drawn: labels would be a smear. */ +const MAX_MARK_LABELS = 60; +/** Rows of mark labels along the top of the plot. */ +const MARK_LABEL_ROWS = 2; +/** At most this many state changes are kept per entity. */ +const MAX_CHANGES = 2000; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); +const isEntityId = (v: unknown): v is string => typeof v === 'string' && /^[a-z_]+\.[a-z0-9_]+$/.test(v.trim()); + +/** A value that is a number or an entity id (numeric text counts as a number). */ +function checkValue(v: unknown, where: string): void { + if (v === undefined || isNumber(v) || isEntityId(v)) return; + if (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v))) return; + throw new Error(`"${where}" must be a number or an entity id`); +} + +const DAILY = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/; +const LOCAL = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/; + +/** Whether a time is one the card can place: "07:00", "2026-10-28 07:00", or a full ISO time. */ +export function isTimeText(text: string): boolean { + const s = text.trim(); + const daily = DAILY.exec(s); + if (daily) return Number(daily[1]) < 24 && Number(daily[2]) < 60; + return LOCAL.test(s) || (/\d{4}-\d{2}-\d{2}T.+(Z|[+-]\d{2}:?\d{2})$/.test(s) && Number.isFinite(Date.parse(s))); +} + +/** The list of times an annotation gives, from a list or comma-separated text. */ +export function timeList(times: Annotation['times']): string[] { + const list = Array.isArray(times) ? times : typeof times === 'string' ? times.split(',') : []; + return list.map((t) => String(t).trim()).filter((t) => t !== ''); +} + +/** Throw a readable error for a bad annotation. */ +export function validateAnnotations(raw: CardConfig): void { + const list = raw.annotations; + if (raw.zones !== undefined && typeof raw.zones !== 'boolean') throw new Error('"zones" must be true or false'); + if (list === undefined) return; + if (!Array.isArray(list)) throw new Error('"annotations" must be a list'); + list.forEach((item: unknown, i) => { + const where = (k: string) => `annotations[${i}].${k}`; + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + throw new Error(`"annotations[${i}]" must have a "type"`); + } + const a = item as Record; + if (!ANNOTATION_TYPES.includes(a.type as AnnotationType)) { + throw new Error(`"${where('type')}" must be one of: ${ANNOTATION_TYPES.join(', ')}`); + } + for (const k of ['label', 'color', 'state']) { + if (a[k] !== undefined && typeof a[k] !== 'string') throw new Error(`"${where(k)}" must be text`); + } + for (const k of ['dash', 'shade']) { + if (a[k] !== undefined && typeof a[k] !== 'boolean') throw new Error(`"${where(k)}" must be true or false`); + } + checkValue(a.value, where('value')); + checkValue(a.from, where('from')); + checkValue(a.to, where('to')); + if (a.entity !== undefined && !isEntityId(a.entity)) throw new Error(`"${where('entity')}" must be an entity id`); + if (a.type === 'line' && a.value === undefined) throw new Error(`"${where('value')}" is needed for a line`); + if (a.type === 'band' && a.from === undefined && a.to === undefined) { + throw new Error(`"${where('from')}" or "${where('to')}" is needed for a band`); + } + if (a.type === 'time') { + if (a.times !== undefined && typeof a.times !== 'string' && !Array.isArray(a.times)) { + throw new Error(`"${where('times')}" must be a time or a list of times`); + } + const bad = timeList(a.times as Annotation['times']).find((t) => !isTimeText(t)); + if (bad !== undefined) throw new Error(`"${where('times')}" has "${bad}": use 07:00 or 2026-10-28 07:00`); + } + if (a.type === 'state' && a.entity === undefined) + throw new Error(`"${where('entity')}" is needed for a state mark`); + if (a.at !== undefined && !POINT_AT.includes(a.at as PointAt)) { + throw new Error(`"${where('at')}" must be one of: ${POINT_AT.join(', ')}`); + } + }); +} + +/** The card's annotations; call after `validateAnnotations`. */ +export function annotationsOf(raw: CardConfig): Annotation[] { + return Array.isArray(raw.annotations) ? (raw.annotations as Annotation[]) : []; +} + +/** Entities annotations read, so their changes redraw the card. */ +export function annotationEntities(raw: CardConfig): string[] { + const ids = new Set(); + for (const a of annotationsOf(raw)) { + for (const v of [a.value, a.from, a.to, a.type === 'state' ? a.entity : undefined]) { + if (isEntityId(v)) ids.add(v.trim()); + } + } + return [...ids]; +} + +/** A number, or an entity's state as one; undefined while it has none (unavailable, missing). */ +export function resolveValue(v: number | string | undefined, hass: HomeAssistant): number | undefined { + if (v === undefined) return undefined; + if (isNumber(v)) return v; + const own = toReading(v); + if (own !== null) return own; + const reading = toReading(hass.states[v.trim()]?.state); + return reading ?? undefined; +} + +/** Values the value axis should reach, so a threshold line is always on show. */ +export function annotationValues(raw: CardConfig, hass: HomeAssistant): number[] { + return annotationsOf(raw) + .filter((a) => a.type === 'line') + .map((a) => resolveValue(a.value, hass)) + .filter(isNumber); +} + +/** + * The moments a list of times falls on within `range`: each day's "07:00" in the zone, or a + * single date and time (wall-clock in the zone, or with its own offset). + */ +export function markTimes(times: string[], range: Range, zone: Zone): number[] { + const out: number[] = []; + const days = boundaries('day', range.start, range.end, zone, 800); + for (const text of times) { + const daily = DAILY.exec(text); + if (daily) { + for (const day of days) { + const w = wallTime(day, zone.timeZone); + out.push(zonedTime(zone.timeZone, w.year, w.month, w.day, +daily[1], +daily[2], +(daily[3] ?? 0))); + } + continue; + } + const local = LOCAL.exec(text); + const t = local + ? zonedTime(zone.timeZone, +local[1], +local[2], +local[3], +local[4], +local[5], +(local[6] ?? 0)) + : Date.parse(text); + if (Number.isFinite(t)) out.push(t); + } + return [...new Set(out.filter((t) => t >= range.start && t <= range.end))].sort((a, b) => a - b); +} + +/** One entity's states over time, oldest first. */ +export interface StateChange { + t: number; + s: string; +} + +/** + * The stretches an entity spent in `state` (or each change, when no state is given), clipped to + * `range`. A stretch still going at the end runs to `until`. + */ +export function stateSpans( + changes: StateChange[], + state: string | undefined, + range: Range, + until: number, +): { start: number; end: number; s: string }[] { + const out: { start: number; end: number; s: string }[] = []; + const wanted = state?.trim().toLowerCase(); + for (let i = 0; i < changes.length; i++) { + const c = changes[i]; + if (i > 0 && changes[i - 1].s === c.s) continue; + if (wanted !== undefined && c.s.toLowerCase() !== wanted) continue; + let j = i + 1; + while (j < changes.length && changes[j].s === c.s) j++; + const end = j < changes.length ? changes[j].t : until; + const start = Math.max(c.t, range.start); + if (end <= range.start || start > range.end) continue; + out.push({ start, end: Math.min(end, range.end), s: c.s }); + } + return out; +} + +/** Rough width of a mark label in px (10 px text, with its padding). */ +export function labelWidth(text: string): number { + return Math.ceil(text.length * 5.8) + 10; +} + +/** + * Rows for mark labels so none overlap: each sits right of its line (left of it near the right + * edge), in the first of `rows` rows with room; a label with no room is left out (its line stays). + * `items` are in px; the result is a row per item, or undefined for none. + */ +export function placeMarkLabels( + items: { x: number; width: number }[], + plotWidth: number, + rows = MARK_LABEL_ROWS, +): (number | undefined)[] { + const order = items.map((_, i) => i).sort((a, b) => items[a].x - items[b].x); + const ends: number[] = Array.from({ length: rows }, () => -Infinity); + const out: (number | undefined)[] = items.map(() => undefined); + for (const i of order) { + const { x, width } = items[i]; + const left = x + 2 + width > plotWidth ? x - 2 - width : x + 2; + const row = ends.findIndex((end) => left >= end + 4); + if (row < 0) continue; + ends[row] = left + width; + out[i] = row; + } + return out; +} + +// ---------------------------------------------------------------- state history + +interface StateCache { + shape: string; + from: number; + to: number; + status: 'loading' | 'ready' | 'error'; + data: Map; + generation: number; +} + +const caches = new WeakMap(); + +interface CompressedState { + s?: string; + lu?: number; + lc?: number; +} + +/** Forget a card's state history (it leaves the page). */ +export function stopAnnotations(host: HTMLElement): void { + const c = caches.get(host); + if (c) c.generation++; + caches.delete(host); +} + +/** + * The states of the entities state marks follow, over `range`: loaded once, then kept up to date + * from `hass` while the range reaches the present. Empty while loading or after a failure: the + * chart draws without the marks rather than not at all. + */ +export function stateHistory(ctx: ChartContext, range: Range, live: boolean): Map { + const ids = [ + ...new Set( + annotationsOf(ctx.raw) + .filter((a) => a.type === 'state' && a.entity) + .map((a) => a.entity!.trim()), + ), + ]; + if (ids.length === 0) return new Map(); + const shape = ids.join(','); + let cache = caches.get(ctx.host); + const stale = + !cache || + cache.shape !== shape || + range.start < cache.from || + (!live && range.end > cache.to + 60_000) || + (live && cache.to !== Infinity); + if (stale) { + const generation = (cache?.generation ?? 0) + 1; + const next: StateCache = { + shape, + from: range.start, + to: live ? Infinity : range.end, + status: 'loading', + data: new Map(), + generation, + }; + cache = next; + caches.set(ctx.host, next); + const call = ctx.hass.callWS?.>({ + type: 'history/history_during_period', + start_time: new Date(range.start).toISOString(), + end_time: new Date(range.end).toISOString(), + entity_ids: ids, + minimal_response: true, + no_attributes: true, + significant_changes_only: false, + }); + if (call) { + call.then( + (result) => { + if (caches.get(ctx.host) !== next || next.generation !== generation) return; + for (const id of ids) { + const rows = (result?.[id] ?? []) + .map((r) => ({ t: Math.round((r.lu ?? r.lc ?? 0) * 1000), s: String(r.s ?? '') })) + .filter((r) => r.t > 0); + next.data.set(id, rows.slice(-MAX_CHANGES)); + } + next.status = 'ready'; + ctx.requestUpdate(); + }, + () => { + if (caches.get(ctx.host) !== next) return; + next.status = 'error'; + }, + ); + } + } + const c = cache!; + if (c.status === 'ready' && live) { + // Keep up with changes since loading, as the history store does for readings. + for (const id of ids) { + const state = ctx.hass.states[id]; + if (!state) continue; + const t = Date.parse(state.last_changed ?? state.last_updated ?? ''); + const rows = c.data.get(id) ?? []; + const last = rows[rows.length - 1]; + if (Number.isFinite(t) && (!last || (t > last.t && last.s !== state.state))) { + rows.push({ t, s: state.state }); + c.data.set(id, rows); + } + } + } + return c.data; +} + +// ---------------------------------------------------------------- drawing + +export interface AnnotationLayers { + /** Shaded bands and zones, drawn behind the data. */ + behind: SVGTemplateResult | typeof nothing; + /** Threshold and time lines, drawn over the data. */ + front: SVGTemplateResult | typeof nothing; + /** Labels and point marks, in HTML over the plot. */ + overlay: TemplateResult | typeof nothing; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +function seriesFor(a: Annotation, series: readonly ChartSeries[]): ChartSeries | undefined { + return (a.entity && series.find((s) => s.entity.entityId === a.entity!.trim())) || series[0]; +} + +/** The reading a point label sits on. */ +export function pointFor(series: ChartSeries, at: PointAt, range: Range, grouped: boolean) { + // Carried values at the edges aren't readings taken in the span (groups always are). + const pts = series.points.filter( + (p) => p.v !== null && (grouped || (!p.carried && (p.t !== range.end || at === 'last'))), + ); + if (pts.length === 0) return undefined; + if (at === 'first') return pts[0]; + if (at === 'last') return pts[pts.length - 1]; + return pts.reduce((best, p) => ((at === 'max' ? p.v! > best.v! : p.v! < best.v!) ? p : best)); +} + +/** Severity bands drawn as faint zones behind the chart, from the first series' bands. */ +function zoneRects(bands: ResolvedBand[], scales: PlotScales): SVGTemplateResult[] { + const [lo, hi] = scales.domain; + return bands.map((band, i) => { + const from = Math.max(lo, Number.isFinite(band.from) ? band.from : lo); + const to = Math.min(hi, i + 1 < bands.length ? bands[i + 1].from : hi); + if (!(to > from)) return svg``; + const y1 = scales.y(to); + const y2 = scales.y(from); + return svg``; + }); +} + +/** + * Everything the card's annotations draw on a history chart: zones and bands behind, threshold + * and time lines in front, and their labels and point marks over the plot. `plotWidth` is in px, + * for spacing time labels. + */ +export function annotationLayers( + ctx: ChartContext, + frame: HistoryFrame, + scales: PlotScales, + plotWidth: number, +): AnnotationLayers { + const list = annotationsOf(ctx.raw); + const zones = ctx.raw.zones === true && frame.series[0] ? zoneRects(frame.series[0].entity.bands, scales) : []; + if (list.length === 0 && zones.length === 0) return { behind: nothing, front: nothing, overlay: nothing }; + const behind: SVGTemplateResult[] = [...zones]; + const front: SVGTemplateResult[] = []; + const labels: TemplateResult[] = []; + const { range } = frame; + const x = (t: number) => scales.x(t); + const [lo, hi] = scales.domain; + const states = stateHistory(ctx, range, frame.live); + const until = Math.min(range.end, Date.now()); + + const pending: { t: number; label: string; color: string }[] = []; + const marks = (a: Annotation, color: string, times: number[], kind: string) => { + const shown = times.slice(0, 2000); + front.push( + svg`${shown.map((t) => svg``)}`, + ); + if (a.label && shown.length <= MAX_MARK_LABELS) { + for (const t of shown) pending.push({ t, label: a.label, color }); + } + }; + + for (const a of list) { + const color = baseColor(a.color ?? DEFAULT_COLOR[a.type]); + if (a.type === 'line') { + const v = resolveValue(a.value, ctx.hass); + if (v === undefined || v < lo || v > hi) continue; + const y = scales.y(v); + front.push( + svg``, + ); + if (a.label) { + const top = y / VIEW_H; + labels.push( + html`${a.label}`, + ); + } + } else if (a.type === 'band') { + const from = a.from === undefined ? lo : resolveValue(a.from, ctx.hass); + const to = a.to === undefined ? hi : resolveValue(a.to, ctx.hass); + if (from === undefined || to === undefined) continue; + const bottom = Math.max(lo, Math.min(from, to)); + const top = Math.min(hi, Math.max(from, to)); + if (!(top > bottom)) continue; + const y1 = scales.y(top); + behind.push( + svg``, + ); + if (a.label) { + labels.push( + html`${a.label}`, + ); + } + } else if (a.type === 'time') { + marks(a, color, markTimes(timeList(a.times), range, frame.zone), 'time'); + } else if (a.type === 'state') { + const spans = stateSpans(states.get(a.entity!.trim()) ?? [], a.state, range, until); + if (a.shade) { + behind.push( + svg`${spans.map((s) => svg``)}`, + ); + } + // A stretch already going when the chart starts has no start to mark. + marks( + a, + color, + spans.map((s) => s.start).filter((t) => t > range.start), + 'state', + ); + } else if (a.type === 'point') { + const s = seriesFor(a, frame.series); + const p = s && pointFor(s, a.at ?? 'max', range, frame.group !== undefined); + if (!s || !p) continue; + const left = x(p.t) / VIEW_W; + const top = scales.y(p.v as number) / VIEW_H; + if (!(left >= 0 && left <= 1 && top >= 0 && top <= 1)) continue; + const value = withUnit(formatNumber(p.v as number, s.entity.decimals, ctx.hass), s.entity.unit); + const edge = left < 0.1 ? 'start' : left > 0.9 ? 'end' : ''; + labels.push( + html` + ${a.label ? html`${a.label} ` : nothing}${value}`, + ); + } + } + // Mark labels from every annotation share the top rows, so none lands on another. + const rows = placeMarkLabels( + pending.map((m) => ({ x: (x(m.t) / VIEW_W) * plotWidth, width: labelWidth(m.label) })), + plotWidth, + ); + pending.forEach((m, i) => { + const row = rows[i]; + if (row === undefined) return; + const left = x(m.t) / VIEW_W; + const flip = left * plotWidth + 2 + labelWidth(m.label) > plotWidth; + labels.push( + html`${m.label}`, + ); + }); + return { + behind: behind.length ? svg`${behind}` : nothing, + front: front.length ? svg`${front}` : nothing, + overlay: labels.length ? html`
${labels}
` : nothing, + }; +} + +export const annotationStyles = css` + .plot .zone { + opacity: 0.1; + } + .plot .band { + opacity: 0.14; + } + .plot .state-shade { + opacity: 0.12; + } + .plot .threshold { + stroke-width: 1.5; + } + .plot .threshold.dashed { + stroke-dasharray: 6 4; + } + .plot .mark-line { + stroke-width: 1; + stroke-dasharray: 3 3; + opacity: 0.9; + } + .annotation-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + .annotation-label { + position: absolute; + font-size: 10px; + line-height: 1.2; + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + max-width: 45%; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--cd-annotation) 75%, var(--primary-text-color)); + background: color-mix(in srgb, var(--card-background-color, #fff) 82%, transparent); + } + .annotation-label.threshold { + right: 2px; + transform: translateY(calc(-100% - 1px)); + } + .annotation-label.threshold.below { + transform: translateY(1px); + } + .annotation-label.band { + left: 2px; + transform: translateY(1px); + } + .annotation-label.mark { + transform: translateX(2px); + max-width: none; + } + .annotation-label.mark.end { + transform: translateX(calc(-100% - 2px)); + } + .annotation-dot { + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + border: 2px solid var(--card-background-color, #fff); + transform: translate(-50%, -50%); + } + .annotation-label.point { + transform: translate(-50%, calc(-100% - 7px)); + font-weight: 500; + color: var(--primary-text-color); + border: 1px solid color-mix(in srgb, var(--cd-annotation) 60%, transparent); + background: color-mix(in srgb, var(--card-background-color, #fff) 90%, transparent); + } + .annotation-label.point .point-name { + color: var(--secondary-text-color); + font-weight: 400; + } + .annotation-label.point.below { + transform: translate(-50%, 7px); + } + .annotation-label.point.start { + transform: translate(-6px, calc(-100% - 7px)); + } + .annotation-label.point.end { + transform: translate(calc(-100% + 6px), calc(-100% - 7px)); + } + .annotation-label.point.start.below { + transform: translate(-6px, 7px); + } + .annotation-label.point.end.below { + transform: translate(calc(-100% + 6px), 7px); + } +`; diff --git a/src/editor/annotations-editor.ts b/src/editor/annotations-editor.ts new file mode 100644 index 0000000..a2d4a12 --- /dev/null +++ b/src/editor/annotations-editor.ts @@ -0,0 +1,240 @@ +import { html } from 'lit'; +import type { TemplateResult } from 'lit'; +import { ANNOTATION_TYPES, POINT_AT, timeList } from '../chart/annotations'; +import type { Annotation, AnnotationType } from '../chart/annotations'; +import { languageOf, localize, registerStrings } from '../localize'; +import type { ChartEditorContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; + +registerStrings('en', { + editor_annotations: 'Annotations', + editor_annotation_type: 'Kind', + editor_annotation_type_line: 'Line at a value', + editor_annotation_type_band: 'Shaded band of values', + editor_annotation_type_time: 'Marks at set times', + editor_annotation_type_state: 'Marks when an entity changes', + editor_annotation_type_point: 'Label on a reading', + editor_annotation_add_line: 'Line', + editor_annotation_add_band: 'Band', + editor_annotation_add_time: 'Times', + editor_annotation_add_state: 'State changes', + editor_annotation_add_point: 'Point label', + editor_annotation_add: 'Add an annotation:', + editor_annotation_value: 'Value (a number or an entity)', + editor_annotation_from: 'From (a number or an entity; blank for the bottom)', + editor_annotation_to: 'To (a number or an entity; blank for the top)', + editor_annotation_times: 'Times: 07:00 for every day, or 2026-10-28 07:00 (commas between)', + editor_annotation_entity: 'Entity', + editor_annotation_series: 'Series (blank for the first)', + editor_annotation_state: 'State (blank for every change)', + editor_annotation_shade: 'Shade while in this state', + editor_annotation_at: 'Reading', + editor_annotation_at_max: 'Highest', + editor_annotation_at_min: 'Lowest', + editor_annotation_at_last: 'Latest', + editor_annotation_at_first: 'First', + editor_annotation_label: 'Label', + editor_annotation_color: 'Colour', + editor_annotation_dash: 'Dashed', + editor_annotation_remove: 'Remove annotation', + editor_zones: 'Shade the severity bands behind the chart', +}); + +const DELETE_ICON = 'M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z'; + +/** The fields each kind of annotation has, after its kind; label and colour come last. */ +const FIELDS: Record = { + line: ['value', 'dash'], + band: ['from', 'to'], + time: ['times'], + state: ['entity', 'state', 'shade'], + point: ['at', 'series'], +}; + +/** A field's default: setting it back removes the key. */ +const DEFAULTS: Record = { dash: true, shade: false, at: 'max' }; + +function select(hass: HomeAssistant | undefined, prefix: string, values: readonly string[]) { + return { + select: { + mode: 'dropdown', + options: values.map((value) => ({ value, label: localize(hass, `editor_annotation_${prefix}_${value}`) })), + }, + }; +} + +const schemas = new Map(); + +/** One annotation's form: its kind, its own fields, then label and colour. */ +export function annotationSchema(type: AnnotationType, hass: HomeAssistant | undefined, entities: string[]) { + const k = `${type}|${languageOf(hass)}|${entities.join(',')}`; + let schema = schemas.get(k); + if (schema) return schema; + const selectors: Record = { + value: { text: {} }, + from: { text: {} }, + to: { text: {} }, + times: { text: {} }, + entity: { entity: {} }, + series: { entity: { include_entities: entities } }, + state: { text: {} }, + shade: { boolean: {} }, + dash: { boolean: {} }, + at: select(hass, 'at', POINT_AT), + }; + schema = [ + { name: 'type', selector: select(hass, 'type', ANNOTATION_TYPES) }, + ...FIELDS[type].map((name) => ({ name, selector: selectors[name] })), + { + type: 'grid', + name: '', + schema: [ + { name: 'label', selector: { text: {} } }, + { name: 'color', selector: { text: {} } }, + ], + }, + ]; + schemas.set(k, schema); + return schema; +} + +/** An annotation as form data: values as text, times joined, defaults filled in. */ +export function annotationData(a: Annotation): Record { + const data: Record = { type: a.type }; + for (const name of [...FIELDS[a.type], 'label', 'color']) { + const key = name === 'series' ? 'entity' : name; + let v = (a as unknown as Record)[key]; + if (name === 'times') v = timeList(a.times).join(', '); + else if ((name === 'value' || name === 'from' || name === 'to') && v !== undefined) v = String(v); + data[name] = v ?? DEFAULTS[name]; + } + return data; +} + +/** + * Form data back to an annotation. Blank fields and defaults are left out, numbers typed as text + * become numbers, and times become a list. A new kind keeps only the label and colour. + */ +export function annotationFromData(data: Record, before: Annotation): Annotation { + const type = (data.type as AnnotationType) ?? before.type; + const out: Record = { type }; + const names = type === before.type ? [...FIELDS[type], 'label', 'color'] : ['label', 'color']; + for (const name of names) { + let v = data[name]; + if (typeof v === 'string') v = v.trim(); + if (v === undefined || v === '' || v === DEFAULTS[name]) continue; + if ((name === 'value' || name === 'from' || name === 'to') && Number.isFinite(Number(v))) v = Number(v); + if (name === 'times') v = timeList(v as string); + out[name === 'series' ? 'entity' : name] = v; + } + return out as unknown as Annotation; +} + +/** The card config with its annotation list replaced (and the key gone once it is empty). */ +export function withAnnotations(config: CardConfig, list: Annotation[]): CardConfig { + const next = { ...config }; + if (list.length) next.annotations = list; + else delete next.annotations; + return next; +} + +/** Starting values for a new annotation of each kind. */ +function fresh(type: AnnotationType, config: CardConfig): Annotation { + if (type === 'line') return { type, value: 0 }; + if (type === 'band') return { type, from: 0, to: 1 }; + if (type === 'time') return { type, times: ['07:00'] }; + if (type === 'state') { + const first = (config.entities ?? [])[0]; + return { type, entity: typeof first === 'string' ? first : (first?.entity ?? 'sensor.example'), state: 'on' }; + } + return { type, at: 'max' }; +} + +/** + * The Annotations part of a history chart's editor: one form per annotation, each with a remove + * button, then a row of buttons to add one of each kind. + */ +export function annotationsEditor(ctx: ChartEditorContext): TemplateResult { + const list = Array.isArray(ctx.config.annotations) ? (ctx.config.annotations as Annotation[]) : []; + const entities = (ctx.config.entities ?? []).map((e) => (typeof e === 'string' ? e : e.entity)); + const t = (k: string) => localize(ctx.hass, k); + const replace = (i: number, a?: Annotation) => { + const next = [...list]; + if (a) next[i] = a; + else next.splice(i, 1); + ctx.commit(withAnnotations(ctx.config, next)); + }; + return html` +
+
${t('editor_annotations')}
+ ${list.map( + (a, i) => + html`
+ t(`editor_annotation_${s.name}`)} + @value-changed=${(ev: CustomEvent) => { + ev.stopPropagation(); + replace(i, annotationFromData((ev.detail?.value ?? {}) as Record, a)); + }} + > + replace(i)} + > +
`, + )} +
+ ${t('editor_annotation_add')} + ${ANNOTATION_TYPES.map( + (type) => + html``, + )} +
+
`; +} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 1f2a3c8..83ec28a 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -10,6 +10,7 @@ import { CALENDAR_SPANS, PERIOD_UNITS, PERIOD_UNTIL } from '../history/period'; import { languageOf, localize, registerStrings } from '../localize'; import type { ChartEditorContext } from '../styles/index'; import type { CardConfig, HomeAssistant } from '../types'; +import { annotationsEditor } from './annotations-editor'; import { LINE_WIDTH_SELECTOR, applyFormData, formData } from './schemas'; import type { FieldBinding } from './schemas'; @@ -219,6 +220,9 @@ export const ZOOM_SCHEMA = [ ], }, ]; +/** Shading the severity bands behind a history chart; the annotation list has its own editor. */ +export const ANNOTATION_FIELDS: FieldBinding[] = [{ name: 'zones', path: ['zones'], default: false }]; +export const ANNOTATION_SCHEMA = [{ name: 'zones', selector: { boolean: {} } }]; const cache = new Map(); @@ -263,6 +267,7 @@ const LINE_FIELDS: FieldBinding[] = [ { name: 'dots', path: ['dots'], default: false }, ...AXES_FIELDS, ...ZOOM_FIELDS, + ...ANNOTATION_FIELDS, ...LAYOUT_FIELDS, ]; @@ -288,7 +293,8 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { { name: 'labels', selector: { boolean: {} } }, ...axesSchema(ctx.hass), ...ZOOM_SCHEMA, + ...ANNOTATION_SCHEMA, ...layoutSchema(ctx.hass), ]); - return chartForm(ctx, 'chart', schema, LINE_FIELDS); + return html`${chartForm(ctx, 'chart', schema, LINE_FIELDS)}${annotationsEditor(ctx)}`; } diff --git a/src/styles/line.ts b/src/styles/line.ts index 6d14629..b03d9ce 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -42,6 +42,14 @@ import { zoomStyles, zoomView, } from '../chart/zoom'; +import { + annotationEntities, + annotationLayers, + annotationStyles, + annotationValues, + stopAnnotations, + validateAnnotations, +} from '../chart/annotations'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; import { formatNumber } from '../format'; @@ -160,6 +168,7 @@ export const line: ChartStyleModule = { ${tooltipStyles} ${layoutStyles} ${zoomStyles} + ${annotationStyles} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -183,6 +192,7 @@ export const line: ChartStyleModule = { validateSeriesStyle(raw); validateLayout(raw); validateZoomOptions(raw); + validateAnnotations(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } @@ -192,11 +202,13 @@ export const line: ChartStyleModule = { schema: entitySeriesSchema(hass), fields: entitySeriesFields(config.dots === true), }), + watch: annotationEntities, disconnect(host) { disconnectHistory(host); stopChartWidth(host); stopHover(host); stopZoom(host); + stopAnnotations(host); }, render(ctx) { const frame = historyFrame(ctx); @@ -212,7 +224,9 @@ export const line: ChartStyleModule = { const shown = zoom.zoomed ? frame.series.map((s) => ({ ...s, points: visiblePoints(s.points, view) })) : frame.series; - const axes = plotAxes(ctx, view, extent(shown.map((s) => s.points))); + // Threshold lines widen the value axis, so a target off the readings' range still shows. + const marks = annotationValues(ctx.raw, ctx.hass).map((v) => ({ t: view.start, v })); + const axes = plotAxes(ctx, view, extent([...shown.map((s) => s.points), marks])); const { scales } = axes; const tooltip = tooltipEnabled(ctx.raw) && !zoomBusy(ctx.host); const byBands = ctx.raw.color_by === 'bands'; @@ -231,6 +245,7 @@ export const line: ChartStyleModule = { shown.map((s) => s.points), ) : undefined; + const notes = annotationLayers(ctx, frame, scales, plotWidth(ctx)); const label = localize(ctx.hass, 'chart_aria', { style: localize(ctx.hass, 'editor_style_line'), names: frame.series.map((s) => s.entity.name).join(', '), @@ -241,12 +256,12 @@ export const line: ChartStyleModule = { chartArea({ axes, ctx, - content: lines, + content: svg`${notes.behind}${lines}${notes.front}`, label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} ${ctx.raw.labels === true ? valueLabels(frame, shown, view, scales, ctx) : nothing} ${zoomLayer(ctx, zoom)} - ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, + ${notes.overlay} ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, hover, gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, classes: zoom.options.zoom ? `zoomable ${zoom.zoomed ? 'zoomed' : ''}` : '', diff --git a/test/annotations.test.ts b/test/annotations.test.ts new file mode 100644 index 0000000..1b347bb --- /dev/null +++ b/test/annotations.test.ts @@ -0,0 +1,457 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + annotationEntities, + annotationValues, + isTimeText, + labelWidth, + markTimes, + placeMarkLabels, + pointFor, + resolveValue, + stateSpans, + timeList, + validateAnnotations, +} from '../src/chart/annotations'; +import type { ChartSeries } from '../src/chart/frame'; +import { + annotationData, + annotationFromData, + annotationSchema, + withAnnotations, +} from '../src/editor/annotations-editor'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const check = + (annotations: unknown, extra: Record = {}) => + () => + validateAnnotations({ type: 'x', annotations, ...extra } as CardConfig); + +describe('annotation options', () => { + it('accepts every kind, and explains a bad one', () => { + expect( + check([ + { type: 'line', value: 21, label: 'Target', color: 'red', dash: false }, + { type: 'line', value: 'input_number.target' }, + { type: 'line', value: '21.5' }, + { type: 'band', from: 18, to: 'sensor.high' }, + { type: 'band', to: 5 }, + { type: 'time', times: ['07:00', '2026-10-28 22:30', '2026-10-28T08:00:00Z'] }, + { type: 'time', times: '07:00, 19:00:30' }, + { type: 'state', entity: 'switch.heater', state: 'on', shade: true }, + { type: 'point', at: 'min', entity: 'sensor.in' }, + ]), + ).not.toThrow(); + expect(check(undefined)).not.toThrow(); + expect(check('x')).toThrow('"annotations" must be a list'); + expect(check([7])).toThrow('"annotations[0]" must have a "type"'); + expect(check([{ type: 'arrow' }])).toThrow('"annotations[0].type" must be one of: line, band, time, state, point'); + expect(check([{ type: 'line' }])).toThrow('"annotations[0].value" is needed for a line'); + expect(check([{ type: 'line', value: 'twenty' }])).toThrow( + '"annotations[0].value" must be a number or an entity id', + ); + expect(check([{ type: 'band' }])).toThrow('"annotations[0].from" or "annotations[0].to" is needed for a band'); + expect(check([{ type: 'line', value: 1, label: 2 }])).toThrow('"annotations[0].label" must be text'); + expect(check([{ type: 'line', value: 1, dash: 'no' }])).toThrow('"annotations[0].dash" must be true or false'); + expect(check([{ type: 'time', times: 7 }])).toThrow('"annotations[0].times" must be a time or a list of times'); + expect(check([{ type: 'time', times: ['25:00'] }])).toThrow('has "25:00": use 07:00 or 2026-10-28 07:00'); + expect(check([{ type: 'state' }])).toThrow('"annotations[0].entity" is needed for a state mark'); + expect(check([{ type: 'state', entity: 'Heater' }])).toThrow('"annotations[0].entity" must be an entity id'); + expect(check([{ type: 'point', at: 'middle' }])).toThrow( + '"annotations[0].at" must be one of: max, min, last, first', + ); + expect(check(undefined, { zones: 'yes' })).toThrow('"zones" must be true or false'); + }); + + it('reads times', () => { + expect(isTimeText('7:05')).toBe(true); + expect(isTimeText('24:00')).toBe(false); + expect(isTimeText('2026-10-28 07:00')).toBe(true); + expect(isTimeText('2026-10-28T07:00:00+01:00')).toBe(true); + expect(isTimeText('tomorrow')).toBe(false); + expect(timeList(' 07:00 ,, 08:00')).toEqual(['07:00', '08:00']); + expect(timeList(undefined)).toEqual([]); + }); + + it('resolves values from numbers and entities, and lists the entities to watch', () => { + const hass = makeHass({ 'input_number.t': '21.5', 'sensor.off': 'unavailable' }); + expect(resolveValue(3, hass)).toBe(3); + expect(resolveValue('4', hass)).toBe(4); + expect(resolveValue('input_number.t', hass)).toBe(21.5); + expect(resolveValue('sensor.off', hass)).toBeUndefined(); + expect(resolveValue('sensor.gone', hass)).toBeUndefined(); + expect(resolveValue(undefined, hass)).toBeUndefined(); + const raw = { + type: 'x', + annotations: [ + { type: 'line', value: 'input_number.t' }, + { type: 'line', value: 30 }, + { type: 'line', value: 'sensor.off' }, + { type: 'band', from: 'sensor.low', to: 9 }, + { type: 'state', entity: 'switch.heater' }, + { type: 'point', entity: 'sensor.in' }, + ], + } as CardConfig; + expect(annotationEntities(raw)).toEqual(['input_number.t', 'sensor.off', 'sensor.low', 'switch.heater']); + expect(annotationValues(raw, hass)).toEqual([21.5, 30]); + expect(annotationEntities({ type: 'x' } as CardConfig)).toEqual([]); + }); +}); + +describe('marks in time', () => { + const london = { timeZone: 'Europe/London', firstDay: 1 }; + + it('puts a daily time on every day in the range, in the zone, and single times once', () => { + const start = Date.UTC(2026, 9, 24, 12); + const end = Date.UTC(2026, 9, 26, 12); + // British Summer Time ends on 25 October: 07:00 is 06:00 UTC before and 07:00 UTC after. + expect(markTimes(['07:00'], { start, end }, london)).toEqual([Date.UTC(2026, 9, 25, 7), Date.UTC(2026, 9, 26, 7)]); + expect(markTimes(['2026-10-24 18:30', '2026-10-24T20:00:00Z', '2026-12-01 00:00'], { start, end }, london)).toEqual( + [Date.UTC(2026, 9, 24, 17, 30), Date.UTC(2026, 9, 24, 20)], + ); + }); + + it('finds the stretches an entity spent in a state', () => { + const changes = [ + { t: 0, s: 'off' }, + { t: 10, s: 'on' }, + { t: 15, s: 'on' }, + { t: 20, s: 'off' }, + { t: 30, s: 'ON' }, + ]; + expect(stateSpans(changes, 'on', { start: 5, end: 40 }, 35)).toEqual([ + { start: 10, end: 20, s: 'on' }, + { start: 30, end: 35, s: 'ON' }, + ]); + // No state: every change, the first clipped to the range. + expect(stateSpans(changes, undefined, { start: 5, end: 25 }, 25).map((s) => s.start)).toEqual([5, 10, 20]); + expect(stateSpans(changes, 'on', { start: 21, end: 25 }, 25)).toEqual([]); + }); + + it('keeps mark labels apart in two rows, flipping them at the right edge', () => { + expect(labelWidth('Alarm')).toBe(39); + const rows = placeMarkLabels( + [ + { x: 50, width: 50 }, + { x: 10, width: 50 }, + { x: 40, width: 50 }, + { x: 70, width: 50 }, + { x: 290, width: 50 }, + ], + 300, + ); + expect(rows).toEqual([undefined, 0, 1, 0, 0]); + }); + + it('picks the reading a point label sits on', () => { + const s = { + points: [ + { t: 0, v: 5, carried: true }, + { t: 1, v: 3 }, + { t: 2, v: null }, + { t: 3, v: 9 }, + { t: 10, v: 9 }, + ], + } as unknown as ChartSeries; + const range = { start: 0, end: 10 }; + expect(pointFor(s, 'max', range, false)!.t).toBe(3); + expect(pointFor(s, 'min', range, false)!.t).toBe(1); + expect(pointFor(s, 'first', range, false)!.t).toBe(1); + expect(pointFor(s, 'last', range, false)!.t).toBe(10); + expect(pointFor(s, 'first', range, true)!.t).toBe(0); + expect(pointFor({ points: [] } as unknown as ChartSeries, 'max', range, false)).toBeUndefined(); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass(failStates = false) { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'input_number.target': '30', + 'switch.heater': 'on', + }); + h.states['switch.heater'].last_changed = new Date(now - H).toISOString(); + h.callWS = vi.fn(async (msg: Record) => { + const ids = msg.entity_ids as string[]; + if (ids.includes('switch.heater')) { + if (failStates) throw new Error('nope'); + return { + 'switch.heater': [ + { s: 'off', lu: sec(30 * H) }, + { s: 'on', lu: sec(10 * H) }, + { s: 'off', lu: sec(8 * H) }, + { s: 'on', lu: sec(4 * H) }, + { s: 'off', lu: sec(3 * H) }, + ], + }; + } + return { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '23', lu: sec(12 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [{ s: '5', lu: sec(20 * H) }], + }; + }) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record, h = hass()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...extra, + } as CardConfig); + card.hass = h; + document.body.appendChild(card); + for (let i = 0; i < 3; i++) { + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + } + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const text = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('annotations on the line chart', () => { + it('draws threshold lines (from a number or an entity) and widens the axis to show them', async () => { + const card = await mount({ + annotations: [ + { type: 'line', value: 'input_number.target', label: 'Target' }, + { type: 'line', value: 2, color: 'blue', dash: false }, + { type: 'line', value: 'sensor.gone', label: 'Gone' }, + ], + }); + const lines = qa(card, 'line.threshold'); + expect(lines).toHaveLength(2); + expect(lines[0].classList.contains('dashed')).toBe(true); + expect(lines[0].getAttribute('stroke')).toContain('--error-color'); + expect(lines[1].classList.contains('dashed')).toBe(false); + expect(text(card, '.annotation-label')).toEqual(['Target']); + // The axis now reaches 30 and 2, past the readings (5 to 23). + const values = text(card, '.y-axis .axis-label').map(Number); + expect(Math.max(...values)).toBeGreaterThanOrEqual(30); + expect(Math.min(...values)).toBeLessThanOrEqual(2); + expect(card.hass!.callWS).toHaveBeenCalledTimes(1); + }); + + it('shades bands and the severity zones behind the lines, skipping what it cannot place', async () => { + const card = await mount({ + zones: true, + severity: [ + { from: 0, color: 'blue' }, + { from: 20, color: 'red' }, + { from: 500, color: 'green' }, + ], + annotations: [ + { type: 'band', from: 18, to: 21, label: 'Comfort' }, + { type: 'band', to: 10 }, + { type: 'band', from: 'sensor.gone' }, + { type: 'band', from: 100, to: 200 }, + ], + }); + expect(qa(card, 'rect.zone')).toHaveLength(2); + expect(qa(card, 'rect.band')).toHaveLength(2); + expect(text(card, '.annotation-label.band')).toEqual(['Comfort']); + // Behind: the shading comes before the data in the drawing. + const group = card.shadowRoot!.querySelector('.annotations-behind')!; + expect(group.compareDocumentPosition(card.shadowRoot!.querySelector('path.line')!)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it('marks set times and an entity turning on, with labels that do not overlap', async () => { + const card = await mount({ + annotations: [ + { type: 'time', times: [new Date(now - 6 * H).toISOString().replace(/\.\d+Z$/, 'Z')], label: 'Alarm' }, + { type: 'state', entity: 'switch.heater', state: 'on', label: 'Heating on', shade: true }, + { type: 'state', entity: 'switch.heater', color: 'grey' }, + ], + }); + expect(qa(card, 'line.mark-line.time')).toHaveLength(1); + // On at 10 h and 4 h ago, then on again an hour ago (from the live state). + expect(qa(card, 'line.mark-line.state')).toHaveLength(3 + 5); + expect(qa(card, 'rect.state-shade')).toHaveLength(3); + const labels = text(card, '.annotation-label.mark'); + // On a narrow card not every label fits; those that do never share a row with a neighbour. + expect(labels).toContain('Heating on'); + expect(labels).toContain('Alarm'); + expect(labels.length).toBeLessThan(4); + expect(qa(card, '.annotation-label.mark')[0].getAttribute('title')).toMatch(/ at /); + }); + + it('draws the chart without state marks while they load or if they fail', async () => { + const card = await mount({ annotations: [{ type: 'state', entity: 'switch.heater' }] }, hass(true)); + expect(qa(card, 'path.line')).toHaveLength(2); + expect(qa(card, 'line.mark-line')).toHaveLength(0); + }); + + it('labels the highest, lowest and latest readings of a series', async () => { + const card = await mount({ + annotations: [ + { type: 'point', label: 'Peak' }, + { type: 'point', at: 'min', entity: 'sensor.in', color: 'green' }, + { type: 'point', at: 'last', entity: 'sensor.out' }, + { type: 'point', at: 'max', entity: 'sensor.missing' }, + ], + }); + expect(text(card, '.annotation-label.point')).toEqual(['Peak 23.0 °C', '19.0 °C', '5.0 °C', '23.0 °C']); + expect(qa(card, '.annotation-dot')).toHaveLength(4); + }); + + it('redraws when an entity it reads changes', async () => { + const card = await mount({ annotations: [{ type: 'line', value: 'input_number.target' }] }); + const before = qa(card, 'line.threshold')[0].getAttribute('y1'); + const h = card.hass!; + card.hass = { + ...h, + states: { ...h.states, 'input_number.target': { ...h.states['input_number.target'], state: '25' } }, + }; + await card.updateComplete; + expect(qa(card, 'line.threshold')[0].getAttribute('y1')).not.toBe(before); + card.remove(); + }); +}); + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} + +describe('annotations in the editor', () => { + it('turns annotations into form data and back', () => { + expect(annotationData({ type: 'line', value: 21 })).toEqual({ + type: 'line', + value: '21', + dash: true, + label: undefined, + color: undefined, + }); + expect(annotationData({ type: 'time', times: ['07:00', '08:00'] }).times).toBe('07:00, 08:00'); + expect(annotationData({ type: 'point', entity: 'sensor.in' })).toMatchObject({ at: 'max', series: 'sensor.in' }); + const line = { type: 'line' as const, value: 21 }; + expect(annotationFromData({ type: 'line', value: ' 22.5 ', dash: true, label: '', color: 'red' }, line)).toEqual({ + type: 'line', + value: 22.5, + color: 'red', + }); + expect(annotationFromData({ type: 'line', value: 'input_number.t', dash: false }, line)).toEqual({ + type: 'line', + value: 'input_number.t', + dash: false, + }); + expect(annotationFromData({ type: 'time', times: '07:00, 19:00' }, { type: 'time' })).toEqual({ + type: 'time', + times: ['07:00', '19:00'], + }); + expect(annotationFromData({ type: 'point', series: 'sensor.out', at: 'min' }, { type: 'point' })).toEqual({ + type: 'point', + entity: 'sensor.out', + at: 'min', + }); + // A new kind keeps the label and colour only. + expect(annotationFromData({ type: 'band', value: '3', label: 'L', color: 'red' }, line)).toEqual({ + type: 'band', + label: 'L', + color: 'red', + }); + expect(annotationFromData({}, line)).toEqual({ type: 'line' }); + expect(withAnnotations({ type: 'x', annotations: [line] } as CardConfig, [])).toEqual({ type: 'x' }); + expect(annotationSchema('line', undefined, [])).toBe(annotationSchema('line', undefined, [])); + }); + + it('adds, edits and removes annotations, and shades the zones', async () => { + await import('../src/editor/chartdeck-card-editor'); + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => { + const config = (e as CustomEvent).detail.config as CardConfig; + events.push(config); + ed.setConfig(config); + }); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const root = ed.shadowRoot!; + const chart = root.querySelector('.section-chart ha-form') as Stub; + expect((chart.data as Record).zones).toBe(false); + chart.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(chart.data as object), zones: true } } }), + ); + expect(events.at(-1)!.zones).toBe(true); + const add = (type: string) => + (root.querySelector(`.annotation-add button[data-type="${type}"]`) as HTMLElement).click(); + for (const type of ['line', 'band', 'time', 'state', 'point']) { + add(type); + await ed.updateComplete; + } + expect(events.at(-1)!.annotations).toEqual([ + { type: 'line', value: 0 }, + { type: 'band', from: 0, to: 1 }, + { type: 'time', times: ['07:00'] }, + { type: 'state', entity: 'sensor.in', state: 'on' }, + { type: 'point', at: 'max' }, + ]); + const forms = [...root.querySelectorAll('.annotation ha-form')] as Stub[]; + expect(forms).toHaveLength(5); + forms[0].dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(forms[0].data as object), value: '21', label: 'T' } }, + }), + ); + expect((events.at(-1)!.annotations as unknown[])[0]).toEqual({ type: 'line', value: 21, label: 'T' }); + await ed.updateComplete; + for (let i = 0; i < 5; i++) { + (root.querySelector('.annotation .remove') as HTMLElement).click(); + await ed.updateComplete; + } + expect(events.at(-1)!.annotations).toBeUndefined(); + }); + + it('starts a state mark on a placeholder when the card has no entities', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + ed.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: [{ entity: 'sensor.a' }] } as CardConfig); + document.body.appendChild(ed); + await ed.updateComplete; + (ed.shadowRoot!.querySelector('.annotation-add button[data-type="state"]') as HTMLElement).click(); + expect((events.at(-1)!.annotations as { entity: string }[])[0].entity).toBe('sensor.a'); + }); +}); diff --git a/test/line.test.ts b/test/line.test.ts index bb83ae1..202354c 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -278,6 +278,7 @@ describe('line chart editor', () => { tooltip: true, zoom: false, overview: false, + zones: false, header: 'above', compact: false, }); From 4a43953bda6c7a0173e5c197c59c4c71b5d68ff8 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:24:27 +0000 Subject: [PATCH 30/41] feat: choose which readings get value labels and where, with overlapping ones hidden The line chart's "Show values on the points" switch used to label every reading and give up entirely past 40 of them, so a busy chart had no labels and a quiet one had them piled on top of each other. Value labels are now a shared feature with three more options, shown in the Chart section under the switch once it is on: - "Which readings" (`labels_show`): all of them (the default, as many as fit), the latest, the highest and lowest, or the first and latest. - "Label position" (`labels_position`): above peaks and below dips (`auto`, the default), above, below, or on the point (`center`, for columns and bars). - "Let labels overlap" (`labels_overlap: true`): off by default, so a label that would land on another is left out. Placement is greedy and in pixels: each series' latest, highest and lowest readings are placed first so they survive on a crowded chart, then the rest in time order; a label that doesn't fit on its side tries the other, and is hidden if neither works or it would leave the plot. Labels near the left or right edge line up with it instead of running off. Each label has a thin underline in its series' colour, so labels of two series can be told apart. With overlapping allowed, a series of more than 40 readings still gets none, as before. Carried values at the chart's edges are still never labelled. The choice and placement live in src/chart/data-labels.ts: dataLabels takes the series and a layout that says where a reading sits, so the column chart can pass the top of a column, and sliceAnchor plus placeLabels give a pie or donut its slice labels. The editor fields are LABEL_FIELDS and labelSchema in chart-editors.ts. Closes #42 --- src/chart/data-labels.ts | 280 ++++++++++++++++++++++++++++++++++++ src/editor/chart-editors.ts | 50 ++++++- src/styles/line.ts | 56 ++++---- test/data-labels.test.ts | 216 ++++++++++++++++++++++++++++ test/line.test.ts | 3 + 5 files changed, 575 insertions(+), 30 deletions(-) create mode 100644 src/chart/data-labels.ts create mode 100644 test/data-labels.test.ts diff --git a/src/chart/data-labels.ts b/src/chart/data-labels.ts new file mode 100644 index 0000000..d96b6d8 --- /dev/null +++ b/src/chart/data-labels.ts @@ -0,0 +1,280 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatNumber } from '../format'; +import type { Point } from '../history/types'; +import type { CardConfig, HomeAssistant } from '../types'; + +/** Which readings get a value label. */ +export const LABEL_SHOW = ['all', 'last', 'minmax', 'ends'] as const; +export type LabelShow = (typeof LABEL_SHOW)[number]; + +/** Where a label sits against its point: `auto` puts peaks' labels above and dips' below. */ +export const LABEL_POSITIONS = ['auto', 'above', 'below', 'center'] as const; +export type LabelPosition = (typeof LABEL_POSITIONS)[number]; + +export interface LabelOptions { + show: LabelShow; + position: LabelPosition; + /** Leave out a label that would overlap one already placed (on unless set false). */ + hideOverlap: boolean; +} + +/** Without overlap hiding, a series with more readings than this gets no labels at all. */ +const MAX_UNHIDDEN = 40; +/** At most this many candidates per chart are tried; the rest could never fit. */ +const MAX_CANDIDATES = 600; +/** Gap between a point and its label, px. */ +const OFFSET_PX = 6; +/** Label height, px (10 px text and its padding). */ +const LABEL_H = 14; + +type LabelRaw = CardConfig & { + labels?: unknown; + labels_show?: unknown; + labels_position?: unknown; + labels_overlap?: unknown; +}; + +export function validateLabelOptions(config: CardConfig): void { + const raw = config as LabelRaw; + if (raw.labels !== undefined && typeof raw.labels !== 'boolean') throw new Error('"labels" must be true or false'); + if (raw.labels_show !== undefined && !LABEL_SHOW.includes(raw.labels_show as LabelShow)) { + throw new Error(`"labels_show" must be one of: ${LABEL_SHOW.join(', ')}`); + } + if (raw.labels_position !== undefined && !LABEL_POSITIONS.includes(raw.labels_position as LabelPosition)) { + throw new Error(`"labels_position" must be one of: ${LABEL_POSITIONS.join(', ')}`); + } + if (raw.labels_overlap !== undefined && typeof raw.labels_overlap !== 'boolean') { + throw new Error('"labels_overlap" must be true or false'); + } +} + +/** The card's label options, or undefined when it has no labels. */ +export function labelOptions(config: CardConfig): LabelOptions | undefined { + const raw = config as LabelRaw; + if (raw.labels !== true) return undefined; + return { + show: (raw.labels_show as LabelShow | undefined) ?? 'all', + position: (raw.labels_position as LabelPosition | undefined) ?? 'auto', + hideOverlap: raw.labels_overlap !== true, + }; +} + +/** + * Indexes of the readings to label, most important first: the last, then the highest and lowest, + * then the rest in time order. Gaps are never labelled. + */ +export function pickPoints(points: readonly Point[], show: LabelShow): number[] { + const real = points.map((p, i) => (p.v === null ? -1 : i)).filter((i) => i >= 0); + if (real.length === 0) return []; + const last = real[real.length - 1]; + const first = real[0]; + let hi = first; + let lo = first; + for (const i of real) { + if ((points[i].v as number) > (points[hi].v as number)) hi = i; + if ((points[i].v as number) < (points[lo].v as number)) lo = i; + } + const picked = + show === 'last' ? [last] : show === 'ends' ? [last, first] : show === 'minmax' ? [hi, lo] : [last, hi, lo, ...real]; + return [...new Set(picked)]; +} + +/** Whether a reading is a peak (label above) or a dip (label below) against its neighbours. */ +export function autoSide(points: readonly Point[], i: number): 'above' | 'below' { + const v = points[i].v as number; + const prev = points[i - 1]?.v ?? null; + const next = points[i + 1]?.v ?? null; + const around = [prev, next].filter((x): x is number => x !== null); + if (around.length === 0) return 'above'; + const mean = around.reduce((a, b) => a + b, 0) / around.length; + return v < mean ? 'below' : 'above'; +} + +/** A label to place: its anchor and size in px, and where it would rather go. */ +export interface LabelBox { + x: number; + y: number; + width: number; + /** Positions to try in order; the first that fits wins. */ + sides: LabelPosition[]; +} + +export interface Placed { + side: Exclude; + /** The label's box, px: left, top, right, bottom. */ + box: [number, number, number, number]; +} + +function boxFor(l: LabelBox, side: Exclude): [number, number, number, number] { + const left = l.x - l.width / 2; + const top = side === 'above' ? l.y - OFFSET_PX - LABEL_H : side === 'below' ? l.y + OFFSET_PX : l.y - LABEL_H / 2; + return [left, top, left + l.width, top + LABEL_H]; +} + +const overlaps = (a: number[], b: number[]) => a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]; + +/** + * Place labels in order, each on the first of its sides that overlaps none placed before it and + * stays inside `height` px. With `hide`, a label with no such side is left out (undefined); + * without, it takes its first side regardless. Labels are given most important first. + */ +export function placeLabels(labels: LabelBox[], height: number, hide: boolean): (Placed | undefined)[] { + const taken: number[][] = []; + return labels.map((l) => { + for (const s of l.sides) { + const side = s === 'auto' ? 'above' : s; + const box = boxFor(l, side); + if (!hide) { + return { side, box }; + } + if (box[1] < -2 || box[3] > height + 2) continue; + if (taken.some((t) => overlaps(t, box))) continue; + taken.push(box); + return { side, box }; + } + return undefined; + }); +} + +/** Rough width of a label in px: 10 px digits and its padding. */ +export function labelWidth(text: string): number { + return Math.ceil(text.length * 6) + 6; +} + +/** One series' readings to label, as the chart draws them. */ +export interface LabelSeries { + points: readonly Point[]; + decimals: number; + color: string; +} + +export interface LabelLayout { + /** Point position as a fraction of the plot's width and height (0 at the top). */ + at(p: Point): [number, number]; + /** Plot size in px, for spacing. */ + width: number; + height: number; + /** Leave out readings that aren't real ones (carried values at the edges). */ + skip?(p: Point): boolean; +} + +/** A label that would run off the left or right of the plot lines up with that edge instead. */ +export function edge(x: number, width: number, plotWidth: number): '' | 'start' | 'end' { + if (x - width / 2 < 0) return 'start'; + if (x + width / 2 > plotWidth) return 'end'; + return ''; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** + * Value labels for every series, chosen, positioned and kept apart as the options say. Drawn in + * HTML over the plot, in percent, so the stretched SVG doesn't distort them. + */ +export function dataLabels( + series: readonly LabelSeries[], + options: LabelOptions, + layout: LabelLayout, + hass?: HomeAssistant, +): TemplateResult | typeof nothing { + const boxes: (LabelBox & { text: string; color: string; fx: number; fy: number })[] = []; + const rest: typeof boxes = []; + for (const s of series) { + const real = layout.skip ? s.points.map((p) => (layout.skip!(p) ? { ...p, v: null } : p)) : s.points; + if (!options.hideOverlap && options.show === 'all' && real.filter((p) => p.v !== null).length > MAX_UNHIDDEN) { + continue; + } + pickPoints(real, options.show).forEach((i, rank) => { + const p = real[i]; + const [fx, fy] = layout.at(p); + if (!(fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1)) return; + const text = formatNumber(p.v as number, s.decimals, hass); + const preferred: LabelPosition = options.position === 'auto' ? autoSide(real, i) : options.position; + const other: LabelPosition = preferred === 'above' ? 'below' : 'above'; + const box = { + x: fx * layout.width, + y: fy * layout.height, + width: labelWidth(text), + sides: preferred === 'center' ? ['center' as const] : [preferred, other], + text, + color: s.color, + fx, + fy, + }; + // Each series' key readings go before any series' ordinary ones. + (rank < 3 && options.show === 'all' ? boxes : options.show === 'all' ? rest : boxes).push(box); + }); + } + const all = [...boxes, ...rest].slice(0, MAX_CANDIDATES); + const placed = placeLabels(all, layout.height, options.hideOverlap); + const shown = all.map((b, i) => ({ b, p: placed[i] })).filter((x) => x.p !== undefined); + if (shown.length === 0) return nothing; + // Back in time order, so the DOM reads left to right. + shown.sort((a, b) => a.b.fx - b.b.fx); + return html`
+ ${shown.map( + ({ b, p }) => + html`${b.text}`, + )} +
`; +} + +/** + * Where a slice's label goes: on the slice's middle angle, `r` out from the centre. Angles are + * radians clockwise from 12 o'clock, as pie and donut charts draw them. Returns fractions of a + * square drawing. + */ +export function sliceAnchor(start: number, end: number, r: number): [number, number] { + const mid = (start + end) / 2; + return [0.5 + r * Math.sin(mid), 0.5 - r * Math.cos(mid)]; +} + +export const dataLabelStyles = css` + .point-labels { + position: absolute; + inset: 0; + pointer-events: none; + } + .point-label { + position: absolute; + transform: translate(-50%, calc(-100% - 6px)); + font-size: 10px; + line-height: 1; + padding: 2px 3px; + border-radius: 3px; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); + box-shadow: inset 0 -1.5px 0 color-mix(in srgb, var(--cd-label, transparent) 70%, transparent); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .point-label.below { + transform: translate(-50%, 6px); + } + .point-label.center { + transform: translate(-50%, -50%); + } + .point-label.start { + --cd-label-x: -4px; + } + .point-label.end { + --cd-label-x: calc(-100% + 4px); + } + .point-label.start, + .point-label.end { + transform: translate(var(--cd-label-x), calc(-100% - 6px)); + } + .point-label.start.below, + .point-label.end.below { + transform: translate(var(--cd-label-x), 6px); + } + .point-label.start.center, + .point-label.end.center { + transform: translate(var(--cd-label-x), -50%); + } +`; diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 83ec28a..0ec12aa 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -3,6 +3,7 @@ import type { TemplateResult } from 'lit'; import { AXIS_LABELS, DEFAULT_AXES } from '../chart/axes'; import { HEADER_PLACES } from '../chart/layout'; import { CURVES } from '../chart/paths'; +import { LABEL_POSITIONS, LABEL_SHOW } from '../chart/data-labels'; import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; import { SOURCES } from '../chart/frame'; import { AGGREGATES, GROUP_UNITS } from '../history/group'; @@ -223,6 +224,49 @@ export const ZOOM_SCHEMA = [ /** Shading the severity bands behind a history chart; the annotation list has its own editor. */ export const ANNOTATION_FIELDS: FieldBinding[] = [{ name: 'zones', path: ['zones'], default: false }]; export const ANNOTATION_SCHEMA = [{ name: 'zones', selector: { boolean: {} } }]; +registerStrings('en', { + editor_labels_show: 'Which readings', + editor_labels_show_all: 'All (as many as fit)', + editor_labels_show_last: 'The latest', + editor_labels_show_minmax: 'Highest and lowest', + editor_labels_show_ends: 'First and latest', + editor_labels_position: 'Label position', + editor_labels_position_auto: 'Above peaks, below dips', + editor_labels_position_above: 'Above', + editor_labels_position_below: 'Below', + editor_labels_position_center: 'On the point', + editor_labels_overlap: 'Let labels overlap', +}); + +/** Value labels on a chart's readings: on/off, then which, where, and whether they may overlap. */ +export const LABEL_FIELDS: FieldBinding[] = [ + { name: 'labels', path: ['labels'], default: false }, + { name: 'labels_show', path: ['labels_show'], default: 'all' }, + { name: 'labels_position', path: ['labels_position'], default: 'auto' }, + { name: 'labels_overlap', path: ['labels_overlap'], default: false }, +]; + +/** The label fields; the choices show once labels are on (cache under `labelsVariant`). */ +export function labelSchema(hass: HomeAssistant | undefined, config: CardConfig | undefined) { + return [ + { name: 'labels', selector: { boolean: {} } }, + ...(config?.labels === true + ? [ + { + type: 'grid', + name: '', + schema: [ + { name: 'labels_show', selector: choice(hass, 'labels_show', LABEL_SHOW) }, + { name: 'labels_position', selector: choice(hass, 'labels_position', LABEL_POSITIONS) }, + ], + }, + { name: 'labels_overlap', selector: { boolean: {} } }, + ] + : []), + ]; +} + +export const labelsVariant = (config: CardConfig | undefined) => (config?.labels === true ? 'labels' : ''); const cache = new Map(); @@ -262,7 +306,7 @@ const LINE_FIELDS: FieldBinding[] = [ ...HISTORY_FIELDS, { name: 'curve', path: ['curve'], default: 'smooth' }, { name: 'color_by', path: ['color_by'], default: 'series' }, - { name: 'labels', path: ['labels'], default: false }, + ...LABEL_FIELDS, { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, { name: 'dots', path: ['dots'], default: false }, ...AXES_FIELDS, @@ -272,7 +316,7 @@ const LINE_FIELDS: FieldBinding[] = [ ]; export function lineEditor(ctx: ChartEditorContext): TemplateResult { - const schema = cachedSchema(`line|${historyVariant(ctx.config)}`, ctx.hass, () => [ + const schema = cachedSchema(`line|${historyVariant(ctx.config)}|${labelsVariant(ctx.config)}`, ctx.hass, () => [ ...historySchema(ctx.hass, ctx.config), { type: 'grid', @@ -290,7 +334,7 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { { name: 'dots', selector: { boolean: {} } }, ], }, - { name: 'labels', selector: { boolean: {} } }, + ...labelSchema(ctx.hass, ctx.config), ...axesSchema(ctx.hass), ...ZOOM_SCHEMA, ...ANNOTATION_SCHEMA, diff --git a/src/styles/line.ts b/src/styles/line.ts index b03d9ce..7db652b 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -50,9 +50,9 @@ import { stopAnnotations, validateAnnotations, } from '../chart/annotations'; +import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; -import { formatNumber } from '../format'; import { localize, registerStrings } from '../localize'; import type { ChartStyleModule } from './index'; import type { Range } from '../history/types'; @@ -60,8 +60,6 @@ import type { CardConfig, ResolvedBand } from '../types'; /** Points kept per series after thinning: about two per pixel on a wide card. */ const MAX_POINTS = 500; -/** Above this many points a series gets no value labels: they would overlap. */ -const MAX_LABELS = 40; const COLOR_BY = ['series', 'bands'] as const; @@ -128,34 +126,34 @@ export function seriesPath(s: ChartSeries, scales: PlotScales, stroke: string, r `; } -function valueLabels( +/** Value labels on the line's readings, kept clear of each other. */ +function pointLabels( frame: HistoryFrame, series: ChartSeries[], view: Range, scales: PlotScales, + options: NonNullable>, + compact: boolean, ctx: Parameters[0], ) { - return html`
- ${series.flatMap((s) => { - // The points at either edge are carried readings, not new ones: no label (groups are all real). - const readings = s.points.filter( - (p) => - p.v !== null && - p.t >= view.start && - p.t <= view.end && - (frame.group !== undefined || (p.t !== frame.range.start && p.t !== frame.range.end)), - ); - if (readings.length > MAX_LABELS) return []; - return readings.map( - (p) => - html`${formatNumber(p.v as number, s.entity.decimals, ctx.hass)}`, - ); - })} -
`; + const { range } = frame; + return dataLabels( + series.map((s) => ({ + points: s.points.filter((p) => p.t >= view.start && p.t <= view.end), + decimals: s.entity.decimals, + color: s.color, + })), + options, + { + at: (p) => [scales.x(p.t) / VIEW_W, scales.y(p.v as number) / VIEW_H], + width: plotWidth(ctx), + // The plot's height less its time labels, roughly. + height: compact ? 96 : 162, + // The points at either edge are carried readings, not new ones (groups are all real). + skip: (p) => frame.group === undefined && (p.t === range.start || p.t === range.end), + }, + ctx.hass, + ); } export const line: ChartStyleModule = { @@ -169,6 +167,7 @@ export const line: ChartStyleModule = { ${layoutStyles} ${zoomStyles} ${annotationStyles} + ${dataLabelStyles} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -193,6 +192,7 @@ export const line: ChartStyleModule = { validateLayout(raw); validateZoomOptions(raw); validateAnnotations(raw); + validateLabelOptions(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } @@ -246,6 +246,7 @@ export const line: ChartStyleModule = { ) : undefined; const notes = annotationLayers(ctx, frame, scales, plotWidth(ctx)); + const labels = labelOptions(ctx.raw); const label = localize(ctx.hass, 'chart_aria', { style: localize(ctx.hass, 'editor_style_line'), names: frame.series.map((s) => s.entity.name).join(', '), @@ -260,8 +261,9 @@ export const line: ChartStyleModule = { label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} - ${ctx.raw.labels === true ? valueLabels(frame, shown, view, scales, ctx) : nothing} ${zoomLayer(ctx, zoom)} - ${notes.overlay} ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, + ${labels ? pointLabels(frame, shown, view, scales, labels, layout.compact, ctx) : nothing} + ${zoomLayer(ctx, zoom)} ${notes.overlay} + ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, hover, gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, classes: zoom.options.zoom ? `zoomable ${zoom.zoomed ? 'zoomed' : ''}` : '', diff --git a/test/data-labels.test.ts b/test/data-labels.test.ts new file mode 100644 index 0000000..62e3eb2 --- /dev/null +++ b/test/data-labels.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + autoSide, + edge, + labelOptions, + labelWidth, + pickPoints, + placeLabels, + sliceAnchor, + validateLabelOptions, +} from '../src/chart/data-labels'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +const pts = (...vs: (number | null)[]) => vs.map((v, t) => ({ t, v })); + +describe('label options', () => { + it('reads and checks them', () => { + const check = (extra: Record) => () => validateLabelOptions({ type: 'x', ...extra } as CardConfig); + expect( + check({ labels: true, labels_show: 'minmax', labels_position: 'below', labels_overlap: true }), + ).not.toThrow(); + expect(check({ labels: 'yes' })).toThrow('"labels" must be true or false'); + expect(check({ labels_show: 'some' })).toThrow('"labels_show" must be one of: all, last, minmax, ends'); + expect(check({ labels_position: 'left' })).toThrow('"labels_position" must be one of: auto, above, below, center'); + expect(check({ labels_overlap: 1 })).toThrow('"labels_overlap" must be true or false'); + expect(labelOptions({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(labelOptions({ type: 'x', labels: true } as CardConfig)).toEqual({ + show: 'all', + position: 'auto', + hideOverlap: true, + }); + expect(labelOptions({ type: 'x', labels: true, labels_overlap: true } as CardConfig)!.hideOverlap).toBe(false); + }); +}); + +describe('choosing and placing labels', () => { + it('picks the readings to label, most important first, never a gap', () => { + const p = pts(3, null, 9, 1, 5, null); + expect(pickPoints(p, 'last')).toEqual([4]); + expect(pickPoints(p, 'ends')).toEqual([4, 0]); + expect(pickPoints(p, 'minmax')).toEqual([2, 3]); + expect(pickPoints(p, 'all')).toEqual([4, 2, 3, 0]); + expect(pickPoints(pts(null), 'all')).toEqual([]); + }); + + it('puts a peak label above and a dip label below', () => { + const p = pts(1, 5, 2, null, 4); + expect(autoSide(p, 1)).toBe('above'); + expect(autoSide(p, 2)).toBe('below'); + expect(autoSide(p, 4)).toBe('above'); + expect(autoSide(pts(7), 0)).toBe('above'); + }); + + it('places labels where they fit, tries the other side, and hides the rest', () => { + const at = (x: number, y: number, sides: ('above' | 'below' | 'center' | 'auto')[] = ['above', 'below']) => ({ + x, + y, + width: 30, + sides, + }); + const placed = placeLabels( + [at(50, 50), at(60, 50), at(70, 50), at(50, 5, ['above']), at(200, 50, ['auto'])], + 100, + true, + ); + expect(placed.map((p) => p?.side)).toEqual(['above', 'below', undefined, undefined, 'above']); + expect(placed[0]!.box).toEqual([35, 30, 65, 44]); + expect(placeLabels([at(50, 50, ['center'])], 100, true)[0]!.box).toEqual([35, 43, 65, 57]); + // Allowed to overlap, each takes its first side. + expect(placeLabels([at(50, 50), at(50, 50), at(50, 2)], 100, false).map((p) => p?.side)).toEqual([ + 'above', + 'above', + 'above', + ]); + }); + + it('sizes labels, keeps them inside the plot, and anchors slice labels', () => { + expect(labelWidth('21.5')).toBe(30); + expect(edge(5, 30, 300)).toBe('start'); + expect(edge(295, 30, 300)).toBe('end'); + expect(edge(150, 30, 300)).toBe(''); + const [x, y] = sliceAnchor(0, Math.PI / 2, 0.4); + expect(x).toBeCloseTo(0.5 + 0.4 * Math.SQRT1_2); + expect(y).toBeCloseTo(0.5 - 0.4 * Math.SQRT1_2); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass(rows = 6) { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': Array.from({ length: rows }, (_, i) => ({ + s: String(20 + (i % 3)), + lu: sec((22 - (i * 20) / rows) * H), + })), + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '2', lu: sec(10 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record, rows?: number) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...extra, + } as CardConfig); + card.hass = hass(rows); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const labels = (card: Card) => + [...card.shadowRoot!.querySelectorAll('.point-label')].map((e) => ({ text: e.textContent, cls: e.className })); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('data labels on the line chart', () => { + it('labels the readings it is asked to, not the carried edges', async () => { + const all = await mount({ labels: true }); + const texts = labels(all).map((l) => l.text); + expect(texts).toContain('5.0'); + expect(texts).toContain('2.0'); + expect(texts.filter((t) => t === '9.0')).toHaveLength(1); + const minmax = await mount({ labels: true, labels_show: 'minmax' }); + expect(labels(minmax).map((l) => l.text)).toEqual(expect.arrayContaining(['20.0', '22.0', '2.0', '9.0'])); + expect(labels(minmax)).toHaveLength(4); + const last = await mount({ labels: true, labels_show: 'last' }); + expect(labels(last).map((l) => l.text)).toEqual(expect.arrayContaining(['22.0', '9.0'])); + expect(labels(last)).toHaveLength(2); + const off = await mount({}); + expect(labels(off)).toHaveLength(0); + }); + + it('puts dips below, and honours a fixed position', async () => { + const auto = await mount({ labels: true, labels_show: 'minmax', y_min: -20 }); + expect(labels(auto).find((l) => l.text === '2.0')!.cls).toContain('below'); + const center = await mount({ labels: true, labels_position: 'center' }); + expect(labels(center).every((l) => l.cls.includes('center'))).toBe(true); + }); + + it('hides labels that would overlap, unless told to let them', async () => { + const crowded = await mount({ labels: true, entities: ['sensor.in'] }, 60); + const hidden = labels(crowded).length; + expect(hidden).toBeGreaterThan(3); + expect(hidden).toBeLessThan(60); + const overlap = await mount({ labels: true, labels_overlap: true, entities: ['sensor.in'] }, 30); + expect(labels(overlap)).toHaveLength(30); + // Allowed to overlap, a series too crowded to read gets none. + const tooMany = await mount({ labels: true, labels_overlap: true, entities: ['sensor.in'] }, 60); + expect(labels(tooMany)).toHaveLength(0); + }); +}); + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} + +describe('data labels in the editor', () => { + it('shows the label choices once labels are on, and writes them', async () => { + await import('../src/editor/chartdeck-card-editor'); + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = () => ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(form().schema)).not.toContain('labels_show'); + ed.setConfig({ ...raw, labels: true }); + await ed.updateComplete; + expect(JSON.stringify(form().schema)).toContain('labels_show'); + form().dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form().data as object), labels_show: 'minmax', labels_overlap: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, labels: true, labels_show: 'minmax', labels_overlap: true }); + }); +}); diff --git a/test/line.test.ts b/test/line.test.ts index 202354c..fc6f136 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -268,6 +268,9 @@ describe('line chart editor', () => { curve: 'smooth', color_by: 'series', labels: false, + labels_show: 'all', + labels_position: 'auto', + labels_overlap: false, line_width: 2.5, dots: false, x_axis: true, From d407fba8a43267c2fdd1ad15b12cebc9fde4c8d5 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:28:38 +0000 Subject: [PATCH 31/41] feat: sync history charts so hovering one shows the same moment on the others Give several chart cards the same group name in "Sync group" (Chart section, `sync: house`) and they share one time axis: - Hovering or touching one chart shows the guide, a dot on each series and the tooltip on every chart in the group at that same moment, so a temperature, a battery and a power chart can be read together. The guide goes at the exact moment, not snapped to each chart's own readings, so the guides line up; each chart's values are its readings nearest to it. A chart's own pointer wins over another's, and a moment outside a chart's span shows nothing there. - Stepping back or forward with the previous/next buttons steps every chart in the group that has the same period and its own buttons. Charts covering a different period (the last 24 hours and the last 7 days) keep their own. Charts find each other through one window event, so they can sit anywhere on the dashboard (stacks, grids, different columns) without being wrapped in anything. Each card listens only while it is on the page and in a group, and ignores its own messages and other groups'. A card notices its own changes when it draws, so a mouse leaving, a finger lifting and the tooltip's linger timer all reach the group. The bus is src/chart/sync.ts: syncChart(ctx, frame, hoverTime) is the one call a chart makes from render, and it also carries a "zoom" message with an onSync hook, ready for zoom (#40) to share its window once both are merged; that last step is left for then, as zoom isn't on this branch. Closes #43 --- src/chart/sync.ts | 130 ++++++++++++++++++++++++ src/editor/chart-editors.ts | 9 ++ src/styles/line.ts | 8 +- test/sync.test.ts | 191 ++++++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 src/chart/sync.ts create mode 100644 test/sync.test.ts diff --git a/src/chart/sync.ts b/src/chart/sync.ts new file mode 100644 index 0000000..ea904dd --- /dev/null +++ b/src/chart/sync.ts @@ -0,0 +1,130 @@ +import type { HistoryFrame } from './frame'; +import { setSteps, stepsFor } from '../history/period'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; + +/** + * Synced charts: cards with the same `sync` group name share what the pointer is over, and + * previous / next steps between charts covering the same kind of period. They talk over one + * window event, so cards anywhere on the dashboard (in stacks, grids, other columns) find each + * other without knowing about one another. + */ +export const SYNC_EVENT = 'chartdeck-sync'; + +/** What one chart tells the others in its group. */ +export type SyncMessage = + /** The time under the pointer, or undefined once it has left. */ + | { type: 'hover'; t: number | undefined } + /** Steps back from the period, for charts with the same period. */ + | { type: 'period'; period: string; steps: number } + /** A zoom window over the span (fractions), for charts that zoom; same period only. */ + | { type: 'zoom'; period: string; from: number; to: number }; + +export interface SyncDetail { + group: string; + source: HTMLElement; + message: SyncMessage; +} + +export function validateSyncOptions(raw: CardConfig): void { + if (raw.sync !== undefined && (typeof raw.sync !== 'string' || raw.sync.trim() === '')) { + throw new Error('"sync" must be a group name'); + } +} + +/** The card's sync group, if it has one. */ +export function syncGroup(raw: CardConfig): string | undefined { + return typeof raw.sync === 'string' && raw.sync.trim() !== '' ? raw.sync.trim() : undefined; +} + +interface SyncState { + group: string; + listener: (ev: Event) => void; + /** The last hover time this card sent, so it sends only changes. */ + sent?: number; + /** The time another chart's pointer is over. */ + remote?: number; + /** The period and steps this card last drew, to notice its own buttons being used. */ + period?: string; + steps?: number; + /** Handlers for message kinds features add (zoom). */ + handlers: Map void>; + ctx: ChartContext; +} + +const states = new WeakMap(); + +/** Send a message to the other charts in the card's group. */ +export function syncSend(host: HTMLElement, message: SyncMessage): void { + const s = states.get(host); + if (!s) return; + window.dispatchEvent(new CustomEvent(SYNC_EVENT, { detail: { group: s.group, source: host, message } })); +} + +/** Stop listening (the card leaves the page, or its group is cleared). */ +export function stopSync(host: HTMLElement): void { + const s = states.get(host); + if (s) window.removeEventListener(SYNC_EVENT, s.listener); + states.delete(host); +} + +const periodKey = (frame: Pick) => JSON.stringify(frame.period); + +/** + * Join (or keep up) the card's group for this draw. Call it from `render` with the frame being + * drawn and the time the card's own pointer is over: it tells the group when that changes, or + * when the card's previous / next buttons stepped, and returns the time another chart's pointer + * is over, for the card to show its guide there. + */ +export function syncChart(ctx: ChartContext, frame: HistoryFrame, hover: number | undefined): number | undefined { + const group = syncGroup(ctx.raw); + let s = states.get(ctx.host); + if (!group) { + if (s) stopSync(ctx.host); + return undefined; + } + if (!s || s.group !== group) { + if (s) stopSync(ctx.host); + const state: SyncState = { group, handlers: new Map(), ctx, listener: () => undefined }; + state.listener = (ev: Event) => receive(state, (ev as CustomEvent).detail); + window.addEventListener(SYNC_EVENT, state.listener); + states.set(ctx.host, state); + s = state; + } + s.ctx = ctx; + if (hover !== s.sent) { + s.sent = hover; + syncSend(ctx.host, { type: 'hover', t: hover }); + } + const period = periodKey(frame); + const steps = frame.period.navigate ? stepsFor(ctx.host, frame.period) : 0; + if (s.period === period && s.steps !== steps) syncSend(ctx.host, { type: 'period', period, steps }); + s.period = period; + s.steps = steps; + // The other chart's pointer, while this one's own isn't on the chart. + return hover === undefined ? s.remote : undefined; +} + +/** Let a feature (zoom) act on its kind of message from the group. */ +export function onSync(host: HTMLElement, type: SyncMessage['type'], handler: (m: SyncMessage) => void): void { + states.get(host)?.handlers.set(type, handler); +} + +function receive(s: SyncState, detail: SyncDetail | undefined): void { + if (!detail || detail.group !== s.group || detail.source === s.ctx.host) return; + const m = detail.message; + if (m.type === 'hover') { + if (m.t === s.remote) return; + s.remote = m.t; + s.ctx.requestUpdate(); + } else if (m.type === 'period') { + const frame = { period: JSON.parse(m.period) as HistoryFrame['period'] }; + // Only a chart covering the same kind of period, with its own buttons, follows the steps. + if (m.period !== s.period || !frame.period.navigate || stepsFor(s.ctx.host, frame.period) === m.steps) return; + setSteps(s.ctx.host, frame.period, m.steps); + // Taken from the group, not this card's buttons: nothing to pass on. + s.steps = m.steps; + s.ctx.requestUpdate(); + } + s.handlers.get(m.type)?.(m); +} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 0ec12aa..e12cc98 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -267,6 +267,13 @@ export function labelSchema(hass: HomeAssistant | undefined, config: CardConfig } export const labelsVariant = (config: CardConfig | undefined) => (config?.labels === true ? 'labels' : ''); +registerStrings('en', { + editor_sync: 'Sync group (charts with the same name share the pointer and period steps)', +}); + +/** A group name shared with other charts on the dashboard. */ +export const SYNC_FIELDS: FieldBinding[] = [{ name: 'sync', path: ['sync'] }]; +export const SYNC_SCHEMA = [{ name: 'sync', selector: { text: {} } }]; const cache = new Map(); @@ -312,6 +319,7 @@ const LINE_FIELDS: FieldBinding[] = [ ...AXES_FIELDS, ...ZOOM_FIELDS, ...ANNOTATION_FIELDS, + ...SYNC_FIELDS, ...LAYOUT_FIELDS, ]; @@ -338,6 +346,7 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { ...axesSchema(ctx.hass), ...ZOOM_SCHEMA, ...ANNOTATION_SCHEMA, + ...SYNC_SCHEMA, ...layoutSchema(ctx.hass), ]); return html`${chartForm(ctx, 'chart', schema, LINE_FIELDS)}${annotationsEditor(ctx)}`; diff --git a/src/styles/line.ts b/src/styles/line.ts index 7db652b..fdf650c 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -51,6 +51,7 @@ import { validateAnnotations, } from '../chart/annotations'; import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; +import { stopSync, syncChart, validateSyncOptions } from '../chart/sync'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; import { localize, registerStrings } from '../localize'; @@ -193,6 +194,7 @@ export const line: ChartStyleModule = { validateZoomOptions(raw); validateAnnotations(raw); validateLabelOptions(raw); + validateSyncOptions(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } @@ -209,9 +211,13 @@ export const line: ChartStyleModule = { stopHover(host); stopZoom(host); stopAnnotations(host); + stopSync(host); }, render(ctx) { const frame = historyFrame(ctx); + // Charts in a sync group share the pointer's time: another chart's shows here too. + const own = hoverTime(ctx.host); + const shared = syncChart(ctx, frame, own); const nav = periodNav(frame, ctx); const names = seriesHeader(frame, ctx); const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; @@ -263,7 +269,7 @@ export const line: ChartStyleModule = { overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} ${labels ? pointLabels(frame, shown, view, scales, labels, layout.compact, ctx) : nothing} ${zoomLayer(ctx, zoom)} ${notes.overlay} - ${tooltip ? tooltipLayer(ctx, shown, scales, hoverTime(ctx.host), plotWidth(ctx)) : nothing}`, + ${tooltip ? tooltipLayer(ctx, shown, scales, own ?? shared, plotWidth(ctx)) : nothing}`, hover, gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, classes: zoom.options.zoom ? `zoomable ${zoom.zoomed ? 'zoomed' : ''}` : '', diff --git a/test/sync.test.ts b/test/sync.test.ts new file mode 100644 index 0000000..2031983 --- /dev/null +++ b/test/sync.test.ts @@ -0,0 +1,191 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { SYNC_EVENT, onSync, syncGroup, syncSend, validateSyncOptions } from '../src/chart/sync'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +describe('sync options', () => { + it('reads and checks the group name', () => { + expect(syncGroup({ type: 'x', sync: ' house ' } as CardConfig)).toBe('house'); + expect(syncGroup({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(() => validateSyncOptions({ type: 'x', sync: 'a' } as CardConfig)).not.toThrow(); + expect(() => validateSyncOptions({ type: 'x', sync: 3 } as unknown as CardConfig)).toThrow( + '"sync" must be a group name', + ); + expect(() => validateSyncOptions({ type: 'x', sync: ' ' } as CardConfig)).toThrow('"sync" must be a group name'); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'], ...extra } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel) as HTMLElement | null; + +function point(el: Element, type: string, frac: number) { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160, right: 1000, bottom: 160 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: frac * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + el.dispatchEvent(ev); +} + +const settle = async (...cards: Card[]) => { + for (let i = 0; i < 2; i++) for (const c of cards) await c.updateComplete; +}; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('synced charts', () => { + it('shows the guide on every chart in the group while the pointer is over one', async () => { + const a = await mount({ sync: 'house', entities: ['sensor.in', 'sensor.out'] }); + const b = await mount({ sync: 'house' }); + const c = await mount({ sync: 'garden' }); + const d = await mount({}); + point(q(a, '.plot-area')!, 'pointermove', 0.5); + await settle(a, b, c, d); + // a snaps to its reading 4 h ago (five sixths across); b's guide is at that same moment. + expect(q(a, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + expect(q(b, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + expect(q(b, '.tooltip-value')!.textContent).toBe('21.0 °C'); + expect(q(c, '.crosshair')).toBeNull(); + expect(q(d, '.crosshair')).toBeNull(); + // b's own pointer wins over a's. + point(q(b, '.plot-area')!, 'pointermove', 0.9); + await settle(a, b); + expect(q(b, '.crosshair')!.getAttribute('style')).toMatch(/left:9\d/); + expect(q(a, '.crosshair')!.getAttribute('style')).toMatch(/left:83.33%/); + point(q(b, '.plot-area')!, 'pointerleave', 0.9); + point(q(a, '.plot-area')!, 'pointerleave', 0.5); + await settle(a, b); + expect(q(a, '.crosshair')).toBeNull(); + expect(q(b, '.crosshair')).toBeNull(); + }); + + it('steps charts with the same period together', async () => { + const period = { last: 24, unit: 'hour', navigate: true }; + const a = await mount({ sync: 'house', period }); + const b = await mount({ sync: 'house', period }); + const other = await mount({ sync: 'house', period: { last: 7, unit: 'day', navigate: true } }); + const fixed = await mount({ sync: 'house', period: { last: 24, unit: 'hour' } }); + q(a, '.period-prev')!.click(); + await settle(a, b, other, fixed); + expect(q(a, '.period-return')).not.toBeNull(); + expect(q(b, '.period-return')).not.toBeNull(); + expect(q(other, '.period-return')).toBeNull(); + // b stepping back again moves a with it. + q(b, '.period-prev')!.click(); + await settle(a, b); + expect(q(a, '.period-label')!.textContent).toBe(q(b, '.period-label')!.textContent); + q(a, '.period-return')!.click(); + await settle(a, b); + expect(q(b, '.period-return')).toBeNull(); + }); + + it('passes other messages to a feature that listens, and leaves the group when it should', async () => { + const a = await mount({ sync: 'house' }); + const b = await mount({ sync: 'house' }); + const heard = vi.fn(); + onSync(b, 'zoom', heard); + syncSend(a, { type: 'zoom', period: '{}', from: 0.2, to: 0.4 }); + expect(heard).toHaveBeenCalledWith({ type: 'zoom', period: '{}', from: 0.2, to: 0.4 }); + // Messages from other groups, and stray events, are ignored. + window.dispatchEvent(new CustomEvent(SYNC_EVENT)); + window.dispatchEvent( + new CustomEvent(SYNC_EVENT, { detail: { group: 'other', source: a, message: { type: 'zoom' } } }), + ); + expect(heard).toHaveBeenCalledTimes(1); + // A new group name, then none at all, then leaving the page. + b.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in'], + sync: 'garden', + } as CardConfig); + await settle(b); + point(q(a, '.plot-area')!, 'pointermove', 0.5); + await settle(a, b); + expect(q(b, '.crosshair')).toBeNull(); + b.setConfig({ type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig); + await settle(b); + syncSend(b, { type: 'hover', t: now }); + a.remove(); + point(q(b, '.plot-area')!, 'pointermove', 0.5); + await settle(b); + expect(q(b, '.crosshair')).not.toBeNull(); + }); +}); + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} + +describe('sync in the editor', () => { + it('sets and clears the group name', async () => { + await import('../src/editor/chartdeck-card-editor'); + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(form.schema)).toContain('"sync"'); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sync: 'house' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, sync: 'house' }); + ed.setConfig({ ...raw, sync: 'house' }); + await ed.updateComplete; + form.dispatchEvent(new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), sync: '' } } })); + expect(events.at(-1)).toEqual(raw); + }); +}); From abaa46cad45614c7dcac44005964526f0ce6b150 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:34:17 +0000 Subject: [PATCH 32/41] feat: draw a history chart as a grid of small charts, one per entity Turn on "A small chart for each entity" in the Chart section (`multiples: true`) and a line chart becomes a grid of small charts, one per entity, so many sensors can be compared at a glance instead of tangled on one plot. - They share the time axis, and by default the value axis too, so heights compare directly; "Value scale: each chart its own" (`multiples_scale: independent`) fits each to its own readings instead. - "Columns" (`multiples_columns`, 1 to 6) sets how many go across; left empty, the card fits as many as it can at about 150 px each. A set number drops to fewer before a chart would get narrower than 110 px. - Each chart's title is its entity's colour, name and value, and acts like the header did: tap, hold and double-tap actions work on it, and the Display switches for name and value hide those parts. The period and its previous/next buttons stay above the grid. - Pointing at any of them puts the guide on all of them at that moment, and every title shows its reading then; the tooltip box shows only on the chart under the pointer. - An entity with no readings in the period (unavailable, missing) shows "No readings in this period." in its own place without upsetting the others. It is a layout option rather than a style of its own, so every history chart option (periods, grouping, curves, widths, dots, band colours, labels, compact) carries over. The grid is src/chart/multiples.ts: multiplesGrid takes a function that draws one series into its plot, so the area and column charts can offer it the same way. To do that, the line chart's drawing moved into one function used for the single chart and each small one, and plotAxes now takes the width it is drawn at. Closes #45 --- src/chart/grid.ts | 17 +++- src/chart/multiples.ts | 174 ++++++++++++++++++++++++++++++++ src/editor/chart-editors.ts | 97 +++++++++++++----- src/styles/line.ts | 68 ++++++++++++- test/line.test.ts | 2 + test/multiples.test.ts | 192 ++++++++++++++++++++++++++++++++++++ 6 files changed, 518 insertions(+), 32 deletions(-) create mode 100644 src/chart/multiples.ts create mode 100644 test/multiples.test.ts diff --git a/src/chart/grid.ts b/src/chart/grid.ts index 5a35ad7..29d1400 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -76,12 +76,21 @@ export interface PlotAxes { label?: (t: number) => string; } -/** Work out the axes for readings spanning `data` over `range`, sized to the card. */ -export function plotAxes(ctx: ChartContext, range: Range, data: [number, number] | undefined): PlotAxes { +/** + * Work out the axes for readings spanning `data` over `range`, sized to the plot (or to `width` + * px, for a chart drawn smaller than the card) and `gaps` value labels. + */ +export function plotAxes( + ctx: ChartContext, + range: Range, + data: [number, number] | undefined, + width?: number, + gaps = VALUE_GAPS, +): PlotAxes { const options = axisOptions(ctx.raw); - const value = valueAxis(data, options, VALUE_GAPS); + const value = valueAxis(data, options, gaps); // Count labels across the plot itself, not the card: the value labels take part of the width. - const count = Math.max(2, Math.min(8, Math.floor(plotWidth(ctx) / TIME_LABEL_PX))); + const count = Math.max(2, Math.min(8, Math.floor((width ?? plotWidth(ctx)) / TIME_LABEL_PX))); const { unit, ticks } = timeTicks(range, count, zoneOffset(timeZoneOf(ctx.hass))); return { options, diff --git a/src/chart/multiples.ts b/src/chart/multiples.ts new file mode 100644 index 0000000..611fd93 --- /dev/null +++ b/src/chart/multiples.ts @@ -0,0 +1,174 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import { formatNumber, withUnit } from '../format'; +import { localize } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import type { ChartSeries, HistoryFrame } from './frame'; +import { chartWidth, plotAxes } from './grid'; +import type { PlotAxes } from './grid'; +import { extent } from './scale'; +import { nearestIndex } from './tooltip'; + +/** How small multiples share their value axis: one scale for all, or each its own. */ +export const MULTIPLES_SCALES = ['auto', 'shared', 'independent'] as const; +export type MultiplesScale = (typeof MULTIPLES_SCALES)[number]; + +export const MAX_MULTIPLES_COLUMNS = 6; +/** Narrowest a small chart gets when the card picks the number of columns, px. */ +export const MIN_PANEL_PX = 150; +/** Room between small charts, px (matches the grid's gap). */ +const GAP_PX = 12; +/** Fewer value labels on a small chart. */ +const PANEL_GAPS = 2; + +export interface MultiplesOptions { + scale: MultiplesScale; + /** Columns in the grid; undefined lets the card fit as many as are at least MIN_PANEL_PX. */ + columns?: number; +} + +export function validateMultiples(raw: CardConfig): void { + if (raw.multiples !== undefined && typeof raw.multiples !== 'boolean') { + throw new Error('"multiples" must be true or false'); + } + if (raw.multiples_scale !== undefined && !MULTIPLES_SCALES.includes(raw.multiples_scale as MultiplesScale)) { + throw new Error(`"multiples_scale" must be one of: ${MULTIPLES_SCALES.join(', ')}`); + } + const c = raw.multiples_columns; + if (c !== undefined && !(Number.isInteger(c) && (c as number) >= 1 && (c as number) <= MAX_MULTIPLES_COLUMNS)) { + throw new Error(`"multiples_columns" must be a whole number from 1 to ${MAX_MULTIPLES_COLUMNS}`); + } +} + +/** The card's small-multiples options, or undefined when it draws one chart. */ +export function multiplesOptions(raw: CardConfig): MultiplesOptions | undefined { + if (raw.multiples !== true) return undefined; + return { + scale: (raw.multiples_scale as MultiplesScale | undefined) ?? 'auto', + columns: raw.multiples_columns as number | undefined, + }; +} + +/** A set number of columns drops to fewer before a chart gets narrower than this, px. */ +export const MIN_SET_PANEL_PX = 110; + +/** + * Columns the grid will have for `count` charts on a card `width` px wide: as many as fit at + * MIN_PANEL_PX, or the number asked for while each stays at least MIN_SET_PANEL_PX. + */ +export function multiplesColumns(count: number, width: number, columns?: number): number { + const fits = (px: number) => Math.max(1, Math.floor((width + GAP_PX) / (px + GAP_PX))); + const cols = columns === undefined ? fits(MIN_PANEL_PX) : Math.min(columns, fits(MIN_SET_PANEL_PX)); + return Math.max(1, Math.min(count, cols)); +} + +/** Width of one small chart, px, for `cols` columns on a card `width` px wide. */ +export function panelWidth(width: number, cols: number): number { + return Math.max(0, (width - (cols - 1) * GAP_PX) / cols); +} + +/** A small chart's title value: its reading at the guide while one is shown, else its current value. */ +function titleValue(s: ChartSeries, t: number | undefined, ctx: ChartContext): string { + if (t === undefined) return s.entity.display; + const i = nearestIndex(s.points, t); + const p = i < 0 ? undefined : s.points[i]; + if (!p || p.v === null) return '—'; + return withUnit(formatNumber(p.v, s.entity.decimals, ctx.hass), s.entity.unit); +} + +/** + * One small chart per series in a grid, sharing the time axis and (with `scale: shared`) the + * value axis, so many sensors compare at a glance. `draw` draws one series into its own plot, + * with its axes and its width in px. Each chart's title is the series' name and value, which + * opens it like the header does; while the pointer is over any chart, every title shows its + * reading at that moment. + */ +export function multiplesGrid( + ctx: ChartContext, + frame: HistoryFrame, + options: MultiplesOptions, + hover: number | undefined, + draw: (series: ChartSeries, axes: PlotAxes, width: number) => TemplateResult, +): TemplateResult { + const width = Math.max(0, chartWidth(ctx) - 32); + const cols = multiplesColumns(frame.series.length, width, options.columns); + const panel = panelWidth(width, cols); + // Automatic: one scale when every entity reads in the same unit; a scale each when they don't + // (temperatures on a power scale would be flat lines). + const units = new Set(frame.series.map((s) => s.entity.unit).filter((u) => u !== '')); + const share = options.scale === 'shared' || (options.scale === 'auto' && units.size <= 1); + const shared = share ? extent(frame.series.map((s) => s.points)) : undefined; + const { show } = ctx.config; + const grid = `grid-template-columns:repeat(${cols}, minmax(0, 1fr))`; + return html`
+ ${frame.series.map((s) => { + const axes = plotAxes(ctx, frame.range, shared ?? extent([s.points]), panel, PANEL_GAPS); + const title = + show.name || show.value + ? html`
+ + ${show.name ? html`${s.entity.name}` : nothing} + ${show.value ? html`${titleValue(s, hover, ctx)}` : nothing} +
` + : nothing; + // An entity with no readings in the span (unavailable, missing) says so in its own place. + const body = s.points.some((p) => p.v !== null) + ? draw(s, axes, panel) + : html`
${localize(ctx.hass, 'chart_no_data')}
`; + return html`
${title}${body}
`; + })} +
`; +} + +export const multiplesStyles = css` + .multiples { + display: grid; + gap: 8px ${GAP_PX}px; + } + .panel { + min-width: 0; + } + .panel-title { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + padding-bottom: 2px; + cursor: pointer; + border-radius: 4px; + } + .panel-title:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .panel-title .series-name { + flex: 0 1 auto; + } + .panel-title .series-value { + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.9375em; + font-variant-numeric: tabular-nums; + } + .multiples .plot-wrap, + .multiples .chart-message { + height: 100px; + } + .frame.compact .multiples .plot-wrap, + .frame.compact .multiples .chart-message { + height: 70px; + } + .multiples .chart-message { + font-size: 0.8125em; + } + .frame.compact .multiples { + gap: 4px 10px; + } + /* The guide shows on every small chart; the box only on the one being pointed at. */ + .panel:not(:hover) .tooltip { + display: none; + } +`; diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index e12cc98..9e6ad5d 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -4,6 +4,7 @@ import { AXIS_LABELS, DEFAULT_AXES } from '../chart/axes'; import { HEADER_PLACES } from '../chart/layout'; import { CURVES } from '../chart/paths'; import { LABEL_POSITIONS, LABEL_SHOW } from '../chart/data-labels'; +import { MAX_MULTIPLES_COLUMNS, MULTIPLES_SCALES } from '../chart/multiples'; import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; import { SOURCES } from '../chart/frame'; import { AGGREGATES, GROUP_UNITS } from '../history/group'; @@ -275,6 +276,46 @@ registerStrings('en', { export const SYNC_FIELDS: FieldBinding[] = [{ name: 'sync', path: ['sync'] }]; export const SYNC_SCHEMA = [{ name: 'sync', selector: { text: {} } }]; +registerStrings('en', { + editor_multiples: 'A small chart for each entity', + editor_multiples_scale: 'Value scale', + editor_multiples_scale_auto: 'The same when the units match', + editor_multiples_scale_shared: 'The same for every chart', + editor_multiples_scale_independent: 'Each chart its own', + editor_multiples_columns: 'Columns (empty fits the card)', +}); + +/** Small multiples: on/off, then how the charts share a scale and how many go across. */ +export const MULTIPLES_FIELDS: FieldBinding[] = [ + { name: 'multiples', path: ['multiples'], default: false }, + { name: 'multiples_scale', path: ['multiples_scale'], default: 'auto' }, + { name: 'multiples_columns', path: ['multiples_columns'] }, +]; + +export const multiplesVariant = (config: CardConfig | undefined) => (config?.multiples === true ? 'multiples' : ''); + +/** The small-multiples fields; scale and columns show once it is on (cache under `multiplesVariant`). */ +export function multiplesSchema(hass: HomeAssistant | undefined, config: CardConfig | undefined) { + return [ + { name: 'multiples', selector: { boolean: {} } }, + ...(config?.multiples === true + ? [ + { + type: 'grid', + name: '', + schema: [ + { name: 'multiples_scale', selector: choice(hass, 'multiples_scale', MULTIPLES_SCALES) }, + { + name: 'multiples_columns', + selector: { number: { min: 1, max: MAX_MULTIPLES_COLUMNS, mode: 'box' } }, + }, + ], + }, + ] + : []), + ]; +} + const cache = new Map(); /** Build a schema once per language (and per style), so ha-form isn't handed a new one each render. */ @@ -321,33 +362,39 @@ const LINE_FIELDS: FieldBinding[] = [ ...ANNOTATION_FIELDS, ...SYNC_FIELDS, ...LAYOUT_FIELDS, + ...MULTIPLES_FIELDS, ]; export function lineEditor(ctx: ChartEditorContext): TemplateResult { - const schema = cachedSchema(`line|${historyVariant(ctx.config)}|${labelsVariant(ctx.config)}`, ctx.hass, () => [ - ...historySchema(ctx.hass, ctx.config), - { - type: 'grid', - name: '', - schema: [ - { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, - { name: 'color_by', selector: choice(ctx.hass, 'color_by', ['series', 'bands']) }, - ], - }, - { - type: 'grid', - name: '', - schema: [ - { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, - { name: 'dots', selector: { boolean: {} } }, - ], - }, - ...labelSchema(ctx.hass, ctx.config), - ...axesSchema(ctx.hass), - ...ZOOM_SCHEMA, - ...ANNOTATION_SCHEMA, - ...SYNC_SCHEMA, - ...layoutSchema(ctx.hass), - ]); + const schema = cachedSchema( + `line|${historyVariant(ctx.config)}|${labelsVariant(ctx.config)}|${multiplesVariant(ctx.config)}`, + ctx.hass, + () => [ + ...historySchema(ctx.hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'curve', selector: choice(ctx.hass, 'curve', CURVES) }, + { name: 'color_by', selector: choice(ctx.hass, 'color_by', ['series', 'bands']) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'line_width', selector: LINE_WIDTH_SELECTOR }, + { name: 'dots', selector: { boolean: {} } }, + ], + }, + ...labelSchema(ctx.hass, ctx.config), + ...axesSchema(ctx.hass), + ...ZOOM_SCHEMA, + ...ANNOTATION_SCHEMA, + ...SYNC_SCHEMA, + ...layoutSchema(ctx.hass), + ...multiplesSchema(ctx.hass, ctx.config), + ], + ); return html`${chartForm(ctx, 'chart', schema, LINE_FIELDS)}${annotationsEditor(ctx)}`; } diff --git a/src/styles/line.ts b/src/styles/line.ts index fdf650c..902434d 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -1,5 +1,5 @@ import { css, html, nothing, svg } from 'lit'; -import type { SVGTemplateResult } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; import { baseColor } from '../bands'; import { downsample } from '../chart/downsample'; import { validateAxisOptions } from '../chart/axes'; @@ -16,7 +16,7 @@ import { } from '../chart/frame'; import type { ChartSeries, HistoryFrame } from '../chart/frame'; import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from '../chart/grid'; -import type { PlotScales } from '../chart/grid'; +import type { PlotAxes, PlotScales } from '../chart/grid'; import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; import { runPath, runs } from '../chart/paths'; import { MAX_DOTS, dotSize, dotsPath, drawOrder, seriesStyle, validateSeriesStyle } from '../chart/series-style'; @@ -52,10 +52,11 @@ import { } from '../chart/annotations'; import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; import { stopSync, syncChart, validateSyncOptions } from '../chart/sync'; +import { multiplesGrid, multiplesOptions, multiplesStyles, validateMultiples } from '../chart/multiples'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; import { localize, registerStrings } from '../localize'; -import type { ChartStyleModule } from './index'; +import type { ChartContext, ChartStyleModule } from './index'; import type { Range } from '../history/types'; import type { CardConfig, ResolvedBand } from '../types'; @@ -157,6 +158,54 @@ function pointLabels( ); } +/** + * One plot of `series`: the lines, value labels, tooltip and gridlines. `id` keeps band gradient + * ids apart when a card draws several plots (small multiples). + */ +function linePlot( + ctx: ChartContext, + frame: HistoryFrame, + series: ChartSeries[], + axes: PlotAxes, + width: number, + id: string, + over: TemplateResult | typeof nothing = nothing, + time = hoverTime(ctx.host), +) { + const { scales } = axes; + const labels = labelOptions(ctx.raw); + const tooltip = tooltipEnabled(ctx.raw); + const byBands = ctx.raw.color_by === 'bands'; + const defs = byBands + ? svg`${series.map((s) => bandGradient(`cd-bands-${s.index}${id}`, s.entity.bands, scales))}` + : nothing; + // Listed order, last on top, with any series marked "on top" drawn after the rest. + const ordered = drawOrder(series, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); + const lines = svg`${ordered.map((s) => + seriesPath(s, scales, byBands ? `url(#cd-bands-${s.index}${id})` : s.color, frame.range, ctx.raw), + )}`; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_line'), + names: series.map((s) => s.entity.name).join(', '), + }); + return chartArea({ + axes, + ctx, + content: lines, + label, + defs, + overlay: html`${over} ${labels ? pointLabels(frame, series, frame.range, scales, labels, true, ctx) : nothing} + ${tooltip ? tooltipLayer(ctx, series, scales, time, width) : nothing}`, + hover: tooltip + ? hoverHandlers( + ctx, + frame.range, + series.map((s) => s.points), + ) + : undefined, + }); +} + export const line: ChartStyleModule = { layout: 'chart', group: 'history', @@ -169,6 +218,7 @@ export const line: ChartStyleModule = { ${zoomStyles} ${annotationStyles} ${dataLabelStyles} + ${multiplesStyles} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -195,6 +245,7 @@ export const line: ChartStyleModule = { validateAnnotations(raw); validateLabelOptions(raw); validateSyncOptions(raw); + validateMultiples(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { throw new Error(`"color_by" must be one of: ${COLOR_BY.join(', ')}`); } @@ -224,6 +275,17 @@ export const line: ChartStyleModule = { const message = frameMessage(frame, ctx); const layout = chartLayout(ctx.raw); if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const multiples = multiplesOptions(ctx.raw); + if (multiples) { + // One small chart per series: the period buttons stay above, each chart has its own title. + return chartFrame( + { ...layout, header: 'above' }, + nav, + multiplesGrid(ctx, frame, multiples, own ?? shared, (s, axes, width) => + linePlot(ctx, frame, [s], axes, width, `-${s.index}`, nothing, own ?? shared), + ), + ); + } // Zoomed, the chart draws the readings in its window (and one either side), fitted to them. const zoom = zoomView(ctx, frame); const view = zoom.range; diff --git a/test/line.test.ts b/test/line.test.ts index fc6f136..3dccdce 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -284,6 +284,8 @@ describe('line chart editor', () => { zones: false, header: 'above', compact: false, + multiples: false, + multiples_scale: 'auto', }); form.dispatchEvent( new CustomEvent('value-changed', { diff --git a/test/multiples.test.ts b/test/multiples.test.ts new file mode 100644 index 0000000..ee21d52 --- /dev/null +++ b/test/multiples.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { multiplesColumns, multiplesOptions, panelWidth, validateMultiples } from '../src/chart/multiples'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +describe('small multiples options', () => { + it('reads and checks them', () => { + const check = (extra: Record) => () => validateMultiples({ type: 'x', ...extra } as CardConfig); + expect(check({ multiples: true, multiples_scale: 'independent', multiples_columns: 3 })).not.toThrow(); + expect(check({ multiples: 'yes' })).toThrow('"multiples" must be true or false'); + expect(check({ multiples_scale: 'own' })).toThrow('"multiples_scale" must be one of: auto, shared, independent'); + expect(check({ multiples_columns: 0 })).toThrow('"multiples_columns" must be a whole number from 1 to 6'); + expect(check({ multiples_columns: 2.5 })).toThrow('"multiples_columns" must be a whole number from 1 to 6'); + expect(multiplesOptions({ type: 'x' } as CardConfig)).toBeUndefined(); + expect(multiplesOptions({ type: 'x', multiples: true } as CardConfig)).toEqual({ + scale: 'auto', + columns: undefined, + }); + }); + + it('fits columns to the card, never more than there are charts', () => { + expect(multiplesColumns(4, 488)).toBe(3); + expect(multiplesColumns(2, 488)).toBe(2); + expect(multiplesColumns(4, 288)).toBe(1); + expect(multiplesColumns(5, 488, 4)).toBe(4); + // A set count drops to fewer on a narrow card. + expect(multiplesColumns(5, 288, 4)).toBe(2); + expect(multiplesColumns(0, 488)).toBe(1); + expect(panelWidth(488, 3)).toBeCloseTo(154.67, 2); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; +const now = Date.now(); +const H = 3_600_000; +const sec = (ago: number) => (now - ago) / 1000; + +function hass() { + const h = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.off': 'unavailable', + }); + h.callWS = vi.fn(async () => ({ + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '23', lu: sec(12 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '50', lu: sec(20 * H) }, + { s: '90', lu: sec(4 * H) }, + ], + 'sensor.off': [{ s: 'unavailable', lu: sec(20 * H) }], + })) as HomeAssistant['callWS']; + return h; +} + +async function mount(extra: Record) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out', 'sensor.off'], + multiples: true, + ...extra, + } as CardConfig); + card.hass = hass(); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel) as HTMLElement | null; +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)] as HTMLElement[]; +const yLabels = (panel: Element) => [...panel.querySelectorAll('.y-axis .axis-label')].map((e) => e.textContent); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('small multiples on the line chart', () => { + it('draws one titled chart per entity, on one value scale', async () => { + const card = await mount({ period: { last: 24, unit: 'hour', navigate: true } }); + const panels = qa(card, '.panel'); + expect(panels).toHaveLength(3); + expect(qa(card, '.series-header')).toHaveLength(0); + expect(q(card, '.period-nav')).not.toBeNull(); + expect(qa(card, '.panel-title').map((t) => t.textContent!.replace(/\s+/g, ' ').trim())).toEqual([ + 'Inside 21.0 °C', + 'Outside 9.0 °C', + 'sensor.off Unavailable', + ]); + expect(qa(card, '.panel-title')[1].dataset.index).toBe('1'); + expect(panels[0].querySelectorAll('path.line')).toHaveLength(1); + expect(panels[0].querySelector('path.line')!.getAttribute('stroke')).toContain('--primary-color'); + expect(panels[1].querySelector('path.line')!.getAttribute('stroke')).toContain('--warning-color'); + expect(yLabels(panels[0])).toEqual(yLabels(panels[1])); + expect(panels[2].querySelector('.chart-message')!.textContent).toBe('No readings in this period.'); + expect(q(card, '.multiples')!.getAttribute('style')).toMatch(/repeat\(2,/); + }); + + it('gives each chart its own scale when asked, and a set number of columns', async () => { + const card = await mount({ multiples_scale: 'independent', multiples_columns: 1 }); + const [a, b] = qa(card, '.panel'); + expect(yLabels(a)).not.toEqual(yLabels(b)); + expect(q(card, '.multiples')!.getAttribute('style')).toMatch(/repeat\(1,/); + }); + + it('shows every chart its reading at the pointer, in its title', async () => { + const card = await mount({}); + const area = q(card, '.panel .plot-area')!; + area.getBoundingClientRect = () => + ({ left: 0, top: 0, width: 1000, height: 100, right: 1000, bottom: 100 }) as DOMRect; + const ev = new MouseEvent('pointermove', { bubbles: true, clientX: 500 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + area.dispatchEvent(ev); + await card.updateComplete; + expect(qa(card, '.crosshair')).toHaveLength(2); + expect(qa(card, '.panel-title .series-value').map((e) => e.textContent)).toEqual(['23.0 °C', '50.0 °C', '—']); + }); + + it('keeps band gradients apart between charts, and can hide the titles', async () => { + const card = await mount({ + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 20, color: 'red' }, + ], + show: { name: false, value: false }, + }); + const ids = qa(card, 'linearGradient').map((g) => g.id); + expect(new Set(ids).size).toBe(ids.length); + expect(qa(card, 'path.line')[1].getAttribute('stroke')).toBe('url(#cd-bands-1-1)'); + expect(qa(card, '.panel-title')).toHaveLength(0); + }); +}); + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} + +describe('small multiples in the editor', () => { + it('shows scale and columns once switched on, and writes them', async () => { + await import('../src/editor/chartdeck-card-editor'); + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = () => ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(form().schema)).not.toContain('multiples_scale'); + ed.setConfig({ ...raw, multiples: true }); + await ed.updateComplete; + expect(JSON.stringify(form().schema)).toContain('multiples_scale'); + form().dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form().data as object), multiples_scale: 'independent', multiples_columns: 2 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, multiples: true, multiples_scale: 'independent', multiples_columns: 2 }); + }); +}); + +describe('small multiples scale', () => { + it('shares one scale only when the units match, unless told', async () => { + const { MULTIPLES_SCALES, multiplesOptions } = await import('../src/chart/multiples'); + expect(MULTIPLES_SCALES[0]).toBe('auto'); + expect(multiplesOptions({ type: 'x', multiples: true } as never).scale).toBe('auto'); + }); +}); From 052ebe86dc5f9d34fb8d67fced2c702bee312c00 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:08:09 +0000 Subject: [PATCH 33/41] feat: mark the highest and lowest readings and the average on a line chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A line chart can now put a ringed dot at each series' highest and lowest reading in the period, labelled with the value and when it happened ("High 14.2 °C · 14:10"), and show each series' average either as a dashed line across the chart labelled "Avg 8.6 °C" at its right end, or as a figure only, in the plot's top-right corner. extremes: max: true min: true average: line # none (default), line or figure entities: - sensor.outside - entity: sensor.inside extremes: false # no marks for this series The time is a clock time when the period is a day or less and a date with the time beyond that; grouped readings name their day or month. It follows the user's language, 12/24-hour clock and time zone setting. Highs and lows ignore unavailable stretches and the reading carried in from before the period (it was taken earlier); on a tie the earliest wins, so the value carried on to the right edge never beats the reading it repeats. History readings arrive irregularly, so their average is weighted by how long each value held, up to now. Grouped readings and long-term statistics are one per equal period, so they get a plain mean. Labels prefer above the high and below the low, flip when they would leave the plot or cover another label, a dot or a header laid over the chart, step one label further out on a crowded chart, and hold against the left or right edge instead of running off it. They fade in with the line, so reduced motion shows them at once. The maths, the label placing and the drawing live in src/chart/extremes.ts, which any history chart can use: it hands back the SVG average lines and the HTML overlay. In the editor: Chart section, "Mark the highest reading", "Mark the lowest reading" and "Average". While any is on, each entity's options get "Mark highest, lowest and average" to switch one series off. Closes #57 --- src/chart/extremes.ts | 537 ++++++++++++++++++++++++++++++++++++ src/editor/chart-editors.ts | 3 + src/styles/line.ts | 56 +++- test/extremes.test.ts | 405 +++++++++++++++++++++++++++ test/line.test.ts | 3 + 5 files changed, 996 insertions(+), 8 deletions(-) create mode 100644 src/chart/extremes.ts create mode 100644 test/extremes.test.ts diff --git a/src/chart/extremes.ts b/src/chart/extremes.ts new file mode 100644 index 0000000..a7dd733 --- /dev/null +++ b/src/chart/extremes.ts @@ -0,0 +1,537 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Grouping } from '../history/group'; +import type { Point, Range } from '../history/types'; +import { languageOf, localize, registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { formatDateTime, formatTime, timeZoneOf } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import type { ChartSeries } from './frame'; +import type { PlotScales } from './grid'; + +/** How a chart shows each series' average: not at all, a dashed line across, or just the figure. */ +export const AVERAGE_MODES = ['none', 'line', 'figure'] as const; +export type AverageMode = (typeof AVERAGE_MODES)[number]; + +/** Which marks a chart draws. Every key has a default, so none is required in YAML. */ +export interface ExtremesOptions { + min: boolean; + max: boolean; + average: AverageMode; +} + +export const DEFAULT_EXTREMES: ExtremesOptions = { min: false, max: false, average: 'none' }; + +registerStrings('en', { + extremes_high: 'High', + extremes_low: 'Low', + extremes_average: 'Avg', + editor_extremes_max: 'Mark the highest reading', + editor_extremes_min: 'Mark the lowest reading', + editor_extremes_average: 'Average', + editor_extremes_average_none: "Don't show", + editor_extremes_average_line: 'Line across the chart', + editor_extremes_average_figure: 'Figure only', + editor_extremes: 'Mark highest, lowest and average', +}); + +type Raw = CardConfig & { extremes?: unknown }; + +/** Throw a readable error for a bad `extremes` option, on the card or an entity. */ +export function validateExtremes(config: CardConfig): void { + const raw = (config as Raw).extremes; + if (raw !== undefined) { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + throw new Error('"extremes" must have "min", "max" (true or false) and "average"'); + } + const e = raw as Record; + for (const k of ['min', 'max']) { + if (e[k] !== undefined && typeof e[k] !== 'boolean') throw new Error(`"extremes.${k}" must be true or false`); + } + if (e.average !== undefined && !AVERAGE_MODES.includes(e.average as AverageMode)) { + throw new Error(`"extremes.average" must be one of: ${AVERAGE_MODES.join(', ')}`); + } + } + for (const item of config.entities ?? []) { + if (typeof item !== 'object') continue; + const own = (item as { extremes?: unknown }).extremes; + if (own !== undefined && typeof own !== 'boolean') + throw new Error(`"extremes" of ${item.entity} must be true or false`); + } +} + +/** The card's marks with defaults filled in. Call after `validateExtremes`. */ +export function extremesOptions(config: CardConfig): ExtremesOptions { + const e = ((config as Raw).extremes ?? {}) as Partial; + return { + min: e.min ?? DEFAULT_EXTREMES.min, + max: e.max ?? DEFAULT_EXTREMES.max, + average: e.average ?? DEFAULT_EXTREMES.average, + }; +} + +/** Whether the card draws any mark at all. */ +export function extremesOn(o: ExtremesOptions): boolean { + return o.min || o.max || o.average !== 'none'; +} + +/** Whether one series gets the card's marks: on unless the entity sets `extremes: false`. */ +export function seriesHasExtremes(entity: object): boolean { + return (entity as { extremes?: unknown }).extremes !== false; +} + +/* ------------------------------------------------------------------ maths ------------------------------------------------------------------ */ + +/** + * The lowest and highest readings taken in the period, as points. Gaps don't count, nor does the + * reading carried in from before the start (it was taken earlier). On a tie the earliest wins, so + * the value carried on to the right edge never beats the reading it repeats. + */ +export function extremePoints(points: readonly Point[]): { min?: Point; max?: Point } { + let min: Point | undefined; + let max: Point | undefined; + for (const p of points) { + if (p.v === null || p.carried) continue; + if (!min || p.v < (min.v as number)) min = p; + if (!max || p.v > (max.v as number)) max = p; + } + return { min, max }; +} + +/** + * The average over the period. Raw history is irregular (a sensor reports when it changes), so + * each reading counts for as long as it held: a value that sat at 20 all night and flickered to 25 + * for a minute averages near 20, not 22.5. That includes the reading carried in at the start, and + * stops at `until` (now); unavailable stretches count for nothing. Grouped or statistics points + * are one per equal period (an hour, a day), so they get a plain mean. A single reading, or + * readings all at one instant, fall back to the plain mean too. + */ +export function averageOf(points: readonly Point[], timeWeighted: boolean, until = Infinity): number | undefined { + const values = points.filter((p) => p.v !== null && !(p.carried && !timeWeighted)); + if (values.length === 0) return undefined; + if (timeWeighted) { + let area = 0; + let span = 0; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.v === null) continue; + const next = i + 1 < points.length ? points[i + 1].t : p.t; + const dt = Math.max(0, Math.min(next, until) - p.t); + area += p.v * dt; + span += dt; + } + if (span > 0) return area / span; + } + return values.reduce((sum, p) => sum + (p.v as number), 0) / values.length; +} + +/* ---------------------------------------------------------------- placing ---------------------------------------------------------------- */ + +/** One label to place: where its dot is (fractions of the plot), its text, and the side it prefers. */ +export interface LabelSpec { + x: number; + y: number; + text: string; + prefer: 'above' | 'below'; + /** It marks a dot there, which other labels keep clear of. */ + dot?: boolean; +} + +export interface PlacedLabel { + side: 'above' | 'below'; + /** Centred on the dot, or held against the plot's left or right edge near it. */ + align: 'center' | 'start' | 'end'; + /** 0 beside the dot; 1 a label's height further out, past a neighbour. */ + tier: number; +} + +/** A rectangle on the plot, in px from its top-left corner. */ +export interface Box { + l: number; + r: number; + t: number; + b: number; +} + +/** A label's rough size in px: 11 px text plus padding. Real text varies; this errs wide. */ +export const LABEL_H = 18; +const LABEL_GAP = 8; +const DOT_R = 6; +export const labelWidth = (text: string) => Math.ceil(text.length * 6.2) + 12; + +const overlaps = (a: Box, b: Box) => a.l < b.r && b.l < a.r && a.t < b.b && b.t < a.b; + +/** + * Where each label goes on a plot `width` × `height` px. Each is centred over its dot, or held + * against the edge it would run past, on its preferred side (the high above, the low below) + * unless that leaves the plot or covers a dot, an earlier label or one of `obstacles` (a header + * laid over the plot); then the other side. If neither side is clear it takes a side that fits. + */ +export function placeLabels( + labels: readonly LabelSpec[], + width: number, + height: number, + obstacles: readonly Box[] = [], +): PlacedLabel[] { + const dots: Box[] = labels + .filter((l) => l.dot) + .map((l) => ({ + l: l.x * width - DOT_R, + r: l.x * width + DOT_R, + t: l.y * height - DOT_R, + b: l.y * height + DOT_R, + })); + const taken: Box[] = [...obstacles, ...dots]; + return labels.map((label) => { + const w = labelWidth(label.text); + const px = label.x * width; + const py = label.y * height; + const align = px - w / 2 < 0 ? 'start' : px + w / 2 > width ? 'end' : 'center'; + const left = align === 'start' ? 0 : align === 'end' ? width - w : px - w / 2; + // Each side has a near spot and, on a crowded plot, one a label's height further out. + const box = (side: 'above' | 'below', tier = 0): Box => { + const gap = LABEL_GAP + tier * (LABEL_H + 2); + const t = side === 'above' ? py - gap - LABEL_H : py + gap; + return { l: left, r: left + w, t, b: t + LABEL_H }; + }; + const inside = (b: Box) => b.t >= 0 && b.b <= height; + const clear = (b: Box) => inside(b) && !taken.some((p) => overlaps(p, b)); + const other: 'above' | 'below' = label.prefer === 'above' ? 'below' : 'above'; + const spots: { side: 'above' | 'below'; tier: number }[] = [ + { side: label.prefer, tier: 0 }, + { side: other, tier: 0 }, + { side: label.prefer, tier: 1 }, + { side: other, tier: 1 }, + ]; + const spot = + spots.find((p) => clear(box(p.side, p.tier))) ?? + spots.find((p) => p.tier === 0 && inside(box(p.side))) ?? + spots[0]; + taken.push(box(spot.side, spot.tier)); + const side = spot.side; + const tier = spot.tier; + return { side, align, tier }; + }); +} + +/* ---------------------------------------------------------------- drawing ---------------------------------------------------------------- */ + +const DAY = 86_400_000; + +/** + * When a mark happened, in the user's language, clock and zone: a clock time over a day or less, + * with the date beyond. A grouped point names its group: the day for days and weeks, the month + * for months. + */ +export function extremeTime(t: number, range: Range, hass?: HomeAssistant, group?: Grouping): string { + if (group?.unit === 'day' || group?.unit === 'week' || group?.unit === 'month') { + const opts: Intl.DateTimeFormatOptions = + group.unit === 'month' + ? { month: 'short', year: 'numeric' } + : { weekday: 'short', day: 'numeric', month: 'short' }; + try { + return new Intl.DateTimeFormat(hass?.locale?.language ?? 'en', { ...opts, timeZone: timeZoneOf(hass) }).format(t); + } catch { + return new Intl.DateTimeFormat('en', opts).format(t); + } + } + return range.end - range.start > DAY ? formatDateTime(t, hass) : formatTime(t, hass); +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** One series' marks, worked out for drawing. */ +interface SeriesMarks { + s: ChartSeries; + min?: Point; + max?: Point; + average?: number; +} + +/** Inputs for `extremesLayer`; the frame's `range`, `group` and `source` say how to average. */ +export interface ExtremesInput { + ctx: ChartContext; + series: readonly ChartSeries[]; + scales: PlotScales; + range: Range; + group?: Grouping; + /** Raw history (true) is averaged by time; statistics and groups by point. */ + timeWeighted: boolean; + /** The plot's size in px, for keeping labels inside it. */ + width: number; + height: number; + /** Nothing is known after this (now): the last reading holds until here. */ + until?: number; + /** Parts of the plot labels keep clear of, in px (a header laid over it). */ + obstacles?: Box[]; +} + +export interface ExtremesLayer { + /** Dashed average lines, for the plot's SVG. */ + lines: SVGTemplateResult | typeof nothing; + /** Dots and labels, for the HTML over the plot. */ + overlay: TemplateResult | typeof nothing; +} + +/** + * The marks for a history chart: a ringed dot and a label ("High 14.2 °C · 14:10") at each + * series' highest and lowest reading, and its average as a dashed line labelled at the right edge + * or as a figure in the plot's top-right corner. Any history chart can draw it: the lines go in + * its SVG, the overlay over the plot. + */ +export function extremesLayer(input: ExtremesInput): ExtremesLayer { + const { ctx, scales, width, height } = input; + const options = extremesOptions(ctx.raw); + if (!extremesOn(options)) return { lines: nothing, overlay: nothing }; + const marks: SeriesMarks[] = input.series + .filter((s) => seriesHasExtremes(s.entity.config)) + .map((s) => { + const { min, max } = extremePoints(s.points); + const average = options.average === 'none' ? undefined : averageOf(s.points, input.timeWeighted, input.until); + return { s, min: options.min ? min : undefined, max: options.max ? max : undefined, average }; + }); + const value = (v: number, s: ChartSeries) => withUnit(formatNumber(v, s.entity.decimals, ctx.hass), s.entity.unit); + const t = (k: string) => localize(ctx.hass, k); + const inPlot = (y: number) => y >= 0 && y <= 1; + + interface Mark { + s: ChartSeries; + kind: 'max' | 'min' | 'average'; + spec: LabelSpec; + } + const items: Mark[] = []; + for (const m of marks) { + for (const kind of ['max', 'min'] as const) { + const p = m[kind]; + if (!p) continue; + const y = scales.y(p.v as number) / VIEW_H; + if (!inPlot(y)) continue; + const word = t(kind === 'max' ? 'extremes_high' : 'extremes_low'); + items.push({ + s: m.s, + kind, + spec: { + x: scales.x(p.t) / VIEW_W, + y, + text: `${word} ${value(p.v as number, m.s)} · ${extremeTime(p.t, input.range, ctx.hass, input.group)}`, + prefer: kind === 'max' ? 'above' : 'below', + dot: true, + }, + }); + } + if (options.average === 'line' && m.average !== undefined) { + const y = scales.y(m.average) / VIEW_H; + if (inPlot(y)) { + items.push({ + s: m.s, + kind: 'average', + spec: { x: 1, y, text: `${t('extremes_average')} ${value(m.average, m.s)}`, prefer: 'above' }, + }); + } + } + } + const placed = placeLabels( + items.map((i) => i.spec), + width, + height, + input.obstacles, + ); + + const lines = + options.average === 'line' + ? svg`${marks.map((m) => { + if (m.average === undefined) return nothing; + const y = scales.y(m.average); + return svg``; + })}` + : nothing; + + const figures = + options.average === 'figure' && marks.some((m) => m.average !== undefined) + ? html`
+ ${marks.map((m) => + m.average === undefined + ? nothing + : html`
+ ${input.series.length > 1 ? html`` : nothing} + ${t('extremes_average')} ${value(m.average, m.s)} +
`, + )} +
` + : nothing; + + // Labels first, then the dots over them, so no label hides another series' dot. + const overlay = html`
+ ${items.map((item, i) => { + const { side, align, tier } = placed[i]; + const { x, y, text } = item.spec; + const across = align === 'start' ? 'left:0' : align === 'end' ? 'right:0' : `left:${pct(x)}`; + return html`${text}`; + })} + ${items.map((item) => + item.spec.dot + ? html`` + : nothing, + )} + ${figures} +
`; + return { lines, overlay }; +} + +/** The look of the marks, from the theme and each series' colour, light and dark. */ +export const extremesStyles = css` + .plot .average-line { + stroke-width: 1.5; + stroke-dasharray: 6 5; + opacity: 0.75; + } + .extremes-layer { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .extremes-layer { + opacity: 1; + } + .extreme-dot { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2.5px solid; + background: var(--card-background-color, #fff); + transform: translate(-50%, -50%); + } + .extreme-label { + position: absolute; + height: ${LABEL_H}px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: ${LABEL_H}px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .extreme-label.above { + --dy: calc(-100% - ${LABEL_GAP}px); + } + .extreme-label.below { + --dy: ${LABEL_GAP}px; + } + .extreme-label.above.far { + --dy: calc(-200% - ${LABEL_GAP + 2}px); + } + .extreme-label.below.far { + --dy: calc(100% + ${LABEL_GAP + 2}px); + } + .extreme-label { + transform: translateY(var(--dy)); + } + .extreme-label.center { + transform: translate(-50%, var(--dy)); + } + .extreme-label.average { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); + } + .extreme-figures { + position: absolute; + top: 2px; + right: 4px; + padding: 2px 6px; + border-radius: 6px; + font-size: 12px; + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 75%, transparent); + font-variant-numeric: tabular-nums; + } + .extreme-figure { + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .extreme-figure .swatch { + width: 8px; + height: 8px; + } +`; + +/* ---------------------------------------------------------------- editor ---------------------------------------------------------------- */ + +/** The card's marks in its Chart section. */ +export const EXTREMES_FIELDS: FieldBinding[] = [ + { name: 'extremes_max', path: ['extremes', 'max'], default: DEFAULT_EXTREMES.max }, + { name: 'extremes_min', path: ['extremes', 'min'], default: DEFAULT_EXTREMES.min }, + { name: 'extremes_average', path: ['extremes', 'average'], default: DEFAULT_EXTREMES.average }, +]; + +export function extremesSchema(hass?: HomeAssistant) { + return [ + { + type: 'grid', + name: '', + schema: [ + { name: 'extremes_max', selector: { boolean: {} } }, + { name: 'extremes_min', selector: { boolean: {} } }, + ], + }, + { + name: 'extremes_average', + selector: { + select: { + mode: 'dropdown', + options: AVERAGE_MODES.map((value) => ({ + value, + label: localize(hass, `editor_extremes_average_${value}`), + })), + }, + }, + }, + ]; +} + +/** Each series' own switch, shown in its options while the card draws any mark. */ +export const ENTITY_EXTREMES_FIELD: FieldBinding = { name: 'extremes', path: ['extremes'], default: true }; + +const entitySchemas = new Map(); + +/** + * A series' options with the marks switch added after `base` (built once per language and base, + * so ha-form keeps the same schema). + */ +export function withEntityExtremes(base: unknown[], hass: HomeAssistant | undefined): unknown[] { + const k = languageOf(hass); + let schema = entitySchemas.get(k); + if (!schema || schema[0] !== base[0]) { + schema = [...base, { name: 'extremes', selector: { boolean: {} } }]; + entitySchemas.set(k, schema); + } + return schema; +} + +const entityFieldLists = new WeakMap(); + +export function withEntityExtremesFields(base: FieldBinding[]): FieldBinding[] { + let fields = entityFieldLists.get(base); + if (!fields) { + fields = [...base, ENTITY_EXTREMES_FIELD]; + entityFieldLists.set(base, fields); + } + return fields; +} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 9e6ad5d..d057737 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -4,6 +4,7 @@ import { AXIS_LABELS, DEFAULT_AXES } from '../chart/axes'; import { HEADER_PLACES } from '../chart/layout'; import { CURVES } from '../chart/paths'; import { LABEL_POSITIONS, LABEL_SHOW } from '../chart/data-labels'; +import { EXTREMES_FIELDS, extremesSchema } from '../chart/extremes'; import { MAX_MULTIPLES_COLUMNS, MULTIPLES_SCALES } from '../chart/multiples'; import { DEFAULT_LINE_WIDTH } from '../chart/series-style'; import { SOURCES } from '../chart/frame'; @@ -360,6 +361,7 @@ const LINE_FIELDS: FieldBinding[] = [ ...AXES_FIELDS, ...ZOOM_FIELDS, ...ANNOTATION_FIELDS, + ...EXTREMES_FIELDS, ...SYNC_FIELDS, ...LAYOUT_FIELDS, ...MULTIPLES_FIELDS, @@ -391,6 +393,7 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { ...axesSchema(ctx.hass), ...ZOOM_SCHEMA, ...ANNOTATION_SCHEMA, + ...extremesSchema(ctx.hass), ...SYNC_SCHEMA, ...layoutSchema(ctx.hass), ...multiplesSchema(ctx.hass, ctx.config), diff --git a/src/styles/line.ts b/src/styles/line.ts index 902434d..9f63188 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -50,6 +50,15 @@ import { stopAnnotations, validateAnnotations, } from '../chart/annotations'; +import { + extremesLayer, + extremesOn, + extremesOptions, + extremesStyles, + validateExtremes, + withEntityExtremes, + withEntityExtremesFields, +} from '../chart/extremes'; import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; import { stopSync, syncChart, validateSyncOptions } from '../chart/sync'; import { multiplesGrid, multiplesOptions, multiplesStyles, validateMultiples } from '../chart/multiples'; @@ -162,6 +171,18 @@ function pointLabels( * One plot of `series`: the lines, value labels, tooltip and gridlines. `id` keeps band gradient * ids apart when a card draws several plots (small multiples). */ +/** Rough height of the plot itself in px: the frame less the time labels and top padding. */ +function plotHeight(compact: boolean, axes: { x: boolean; y: boolean; labels: string }): number { + const outside = axes.labels === 'outside'; + return (compact ? 110 : 180) - (outside && axes.x ? (compact ? 14 : 18) : 0) - (outside && axes.y ? 6 : 0); +} + +/** Roughly where a header laid over the plot sits, in px, so marks' labels keep clear of it. */ +function overHeaderBox(frame: HistoryFrame, compact: boolean, width: number) { + const chars = frame.series.reduce((n, s) => n + s.entity.name.length + s.entity.display.length + 6, 0); + return { l: 0, t: 0, r: Math.min(width, chars * 8), b: compact ? 24 : 34 }; +} + function linePlot( ctx: ChartContext, frame: HistoryFrame, @@ -218,6 +239,7 @@ export const line: ChartStyleModule = { ${zoomStyles} ${annotationStyles} ${dataLabelStyles} + ${extremesStyles} ${multiplesStyles} .style-line .line { stroke-linecap: round; @@ -244,6 +266,7 @@ export const line: ChartStyleModule = { validateZoomOptions(raw); validateAnnotations(raw); validateLabelOptions(raw); + validateExtremes(raw); validateSyncOptions(raw); validateMultiples(raw); if (raw.color_by !== undefined && !COLOR_BY.includes(raw.color_by as (typeof COLOR_BY)[number])) { @@ -251,10 +274,14 @@ export const line: ChartStyleModule = { } }, editor: lineEditor, - entityFields: (hass, config) => ({ - schema: entitySeriesSchema(hass), - fields: entitySeriesFields(config.dots === true), - }), + entityFields: (hass, config) => { + const schema = entitySeriesSchema(hass); + const fields = entitySeriesFields(config.dots === true); + // While the card marks highs, lows or averages, each series can opt out. + return extremesOn(extremesOptions(config)) + ? { schema: withEntityExtremes(schema, hass), fields: withEntityExtremesFields(fields) } + : { schema, fields }; + }, watch: annotationEntities, disconnect(host) { disconnectHistory(host); @@ -293,8 +320,8 @@ export const line: ChartStyleModule = { ? frame.series.map((s) => ({ ...s, points: visiblePoints(s.points, view) })) : frame.series; // Threshold lines widen the value axis, so a target off the readings' range still shows. - const marks = annotationValues(ctx.raw, ctx.hass).map((v) => ({ t: view.start, v })); - const axes = plotAxes(ctx, view, extent([...shown.map((s) => s.points), marks])); + const thresholds = annotationValues(ctx.raw, ctx.hass).map((v) => ({ t: view.start, v })); + const axes = plotAxes(ctx, view, extent([...shown.map((s) => s.points), thresholds])); const { scales } = axes; const tooltip = tooltipEnabled(ctx.raw) && !zoomBusy(ctx.host); const byBands = ctx.raw.color_by === 'bands'; @@ -315,6 +342,19 @@ export const line: ChartStyleModule = { : undefined; const notes = annotationLayers(ctx, frame, scales, plotWidth(ctx)); const labels = labelOptions(ctx.raw); + const width = plotWidth(ctx); + const marks = extremesLayer({ + ctx, + series: shown, + scales, + range: view, + group: frame.group, + timeWeighted: frame.source === 'history' && frame.group === undefined, + width, + height: plotHeight(layout.compact, axes.options), + until: Math.min(view.end, Date.now()), + obstacles: layout.header === 'over' ? [overHeaderBox(frame, layout.compact, width)] : [], + }); const label = localize(ctx.hass, 'chart_aria', { style: localize(ctx.hass, 'editor_style_line'), names: frame.series.map((s) => s.entity.name).join(', '), @@ -325,12 +365,12 @@ export const line: ChartStyleModule = { chartArea({ axes, ctx, - content: svg`${notes.behind}${lines}${notes.front}`, + content: svg`${notes.behind}${marks.lines}${lines}${notes.front}`, label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} ${labels ? pointLabels(frame, shown, view, scales, labels, layout.compact, ctx) : nothing} - ${zoomLayer(ctx, zoom)} ${notes.overlay} + ${zoomLayer(ctx, zoom)} ${notes.overlay} ${marks.overlay} ${tooltip ? tooltipLayer(ctx, shown, scales, own ?? shared, plotWidth(ctx)) : nothing}`, hover, gestures: zoom.options.zoom ? zoomGestures(ctx, zoom, hover) : undefined, diff --git a/test/extremes.test.ts b/test/extremes.test.ts new file mode 100644 index 0000000..8991614 --- /dev/null +++ b/test/extremes.test.ts @@ -0,0 +1,405 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { + LABEL_H, + averageOf, + extremePoints, + extremeTime, + extremesOn, + extremesOptions, + labelWidth, + placeLabels, + seriesHasExtremes, + validateExtremes, +} from '../src/chart/extremes'; +import type { Point } from '../src/history/types'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of [ + 'ha-form', + 'ha-expansion-panel', + 'ha-selector', + 'ha-entity-picker', + 'ha-icon-picker', + 'ha-icon-button', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; +const DAY = 24 * H; + +describe('extreme points', () => { + it('finds the lowest and highest readings, skipping gaps and the reading carried in', () => { + const points: Point[] = [ + { t: 0, v: 1, carried: true }, + { t: 1, v: 5 }, + { t: 2, v: null }, + { t: 3, v: 2 }, + { t: 4, v: 9 }, + { t: 5, v: 9 }, + ]; + const { min, max } = extremePoints(points); + expect(min).toEqual({ t: 3, v: 2 }); + // A tie goes to the earliest: the value carried on to the edge never wins. + expect(max).toEqual({ t: 4, v: 9 }); + }); + + it('has nothing to mark without readings', () => { + expect(extremePoints([])).toEqual({ min: undefined, max: undefined }); + expect(extremePoints([{ t: 0, v: null }])).toEqual({ min: undefined, max: undefined }); + }); +}); + +describe('average', () => { + const points: Point[] = [ + { t: 0, v: 20 }, + { t: 10 * H, v: 30 }, + { t: 11 * H, v: null }, + { t: 12 * H, v: 20 }, + { t: 14 * H, v: 20 }, + ]; + + it('weights each history reading by how long it held, leaving out unavailable stretches', () => { + // 20 for 10 h, 30 for 1 h, unavailable for 1 h, 20 for 2 h. + expect(averageOf(points, true)).toBeCloseTo((20 * 10 + 30 + 20 * 2) / 13); + }); + + it('stops at now, and counts the reading carried in from before the start', () => { + expect(averageOf(points, true, 5 * H)).toBe(20); + expect( + averageOf( + [ + { t: 0, v: 10, carried: true }, + { t: H, v: 20 }, + { t: 3 * H, v: 20 }, + ], + true, + ), + ).toBeCloseTo((10 + 40) / 3); + }); + + it('takes a plain mean of groups and statistics, without the row carried in', () => { + expect(averageOf(points, false)).toBe(22.5); + expect( + averageOf( + [ + { t: 0, v: 100, carried: true }, + { t: 1, v: 4 }, + { t: 2, v: 6 }, + ], + false, + ), + ).toBe(5); + }); + + it('falls back to the mean for a single reading, and to nothing without one', () => { + expect(averageOf([{ t: 5, v: 7 }], true)).toBe(7); + expect(averageOf([{ t: 5, v: null }], true)).toBeUndefined(); + }); +}); + +describe('placing labels', () => { + const W = 400; + const Hpx = 160; + + it('puts the high above and the low below, centred on the dot', () => { + const [high, low] = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 1', prefer: 'above', dot: true }, + { x: 0.3, y: 0.7, text: 'Low 1', prefer: 'below', dot: true }, + ], + W, + Hpx, + ); + expect(high).toEqual({ side: 'above', align: 'center', tier: 0 }); + expect(low).toEqual({ side: 'below', align: 'center', tier: 0 }); + }); + + it('flips a label that would leave the top or bottom, and holds it inside the sides', () => { + const [top, bottom] = placeLabels( + [ + { x: 0.01, y: 0.02, text: 'High 100 °C · 12:00', prefer: 'above' }, + { x: 0.99, y: 0.98, text: 'Low 1 °C · 03:00', prefer: 'below' }, + ], + W, + Hpx, + ); + expect(top).toEqual({ side: 'below', align: 'start', tier: 0 }); + expect(bottom).toEqual({ side: 'above', align: 'end', tier: 0 }); + }); + + it('moves a label off one already placed, a dot or an obstacle', () => { + const near = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 20', prefer: 'above' }, + { x: 0.5, y: 0.52, text: 'High 21', prefer: 'above' }, + ], + W, + Hpx, + ); + expect(near[1].side).toBe('below'); + const dotted = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'High 20', prefer: 'above', dot: true }, + { x: 0.5, y: 0.4, text: 'x', prefer: 'below', dot: true }, + ], + W, + Hpx, + ); + // Above would cover the other dot at 0.4: below instead. + expect(dotted[0].side).toBe('below'); + const blocked = placeLabels([{ x: 0.1, y: 0.5, text: 'High 20', prefer: 'above' }], W, Hpx, [ + { l: 0, r: 200, t: 0, b: 75 }, + ]); + expect(blocked[0].side).toBe('below'); + }); + + it('steps a label further out when both near spots are taken, and keeps a side that fits when nothing is clear', () => { + const crowded = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'A', prefer: 'above' }, + { x: 0.5, y: 0.5, text: 'B', prefer: 'below' }, + { x: 0.5, y: 0.5, text: 'C', prefer: 'above' }, + ], + W, + Hpx, + ); + expect(crowded[2]).toEqual({ side: 'above', align: 'center', tier: 1 }); + const tiny = placeLabels( + [ + { x: 0.5, y: 0.5, text: 'A', prefer: 'above' }, + { x: 0.5, y: 0.5, text: 'B', prefer: 'above' }, + ], + W, + LABEL_H * 2 + 20, + ); + expect(tiny[1].tier).toBe(0); + expect(labelWidth('abc')).toBeGreaterThan(18); + }); +}); + +describe('extreme times', () => { + const hass = makeHass({}, { locale: { language: 'en', number_format: 'language', time_format: '24' } }); + const t = Date.UTC(2026, 9, 27, 14, 10); + + it('shows a clock time within a day and the date beyond', () => { + expect(extremeTime(t, { start: t - H, end: t + H }, hass)).toMatch(/\d{2}:10/); + const long = extremeTime(t, { start: t - 3 * DAY, end: t }, hass); + expect(long).toMatch(/Oct/); + expect(long).toMatch(/:10/); + }); + + it('names a grouped point by its day or month', () => { + const range = { start: t - 7 * DAY, end: t }; + const day = extremeTime(t, range, hass, { unit: 'day', aggregate: 'mean' }); + expect(day).toMatch(/Oct/); + expect(day).not.toMatch(/:/); + expect(extremeTime(t, range, hass, { unit: 'month', aggregate: 'mean' })).toMatch(/Oct 2026/); + expect(extremeTime(t, range, hass, { unit: 'hour', aggregate: 'mean' })).toMatch(/:10/); + }); +}); + +describe('extremes options', () => { + const cfg = (extra: Record) => ({ type: 'x', entities: ['sensor.a'], ...extra }) as CardConfig; + + it('fills in defaults and says when any mark is on', () => { + expect(extremesOptions(cfg({}))).toEqual({ min: false, max: false, average: 'none' }); + expect(extremesOn(extremesOptions(cfg({})))).toBe(false); + expect(extremesOn(extremesOptions(cfg({ extremes: { average: 'figure' } })))).toBe(true); + expect(seriesHasExtremes({ entity: 'a' })).toBe(true); + expect(seriesHasExtremes({ entity: 'a', extremes: false })).toBe(false); + }); + + it('rejects bad values with readable errors', () => { + expect(() => validateExtremes(cfg({ extremes: true }))).toThrow(/"extremes" must have/); + expect(() => validateExtremes(cfg({ extremes: { min: 'yes' } }))).toThrow(/"extremes.min" must be true or false/); + expect(() => validateExtremes(cfg({ extremes: { average: 'median' } }))).toThrow( + /"extremes.average" must be one of: none, line, figure/, + ); + expect(() => validateExtremes(cfg({ entities: [{ entity: 'sensor.a', extremes: 'no' }] }))).toThrow( + /"extremes" of sensor.a/, + ); + expect(() => validateExtremes(cfg({ extremes: { min: true, max: false, average: 'line' } }))).not.toThrow(); + }); +}); + +/* ------------------------------------------------------------------ card ------------------------------------------------------------------ */ + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const history = { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '20', lu: sec(10 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '21', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +function hassWith() { + const hass = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.in', 'sensor.out'], + ...config, + } as CardConfig); + card.hass = hassWith(); + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; +const texts = (card: Card, sel: string) => qa(card, sel).map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('line chart with extremes', () => { + it('draws nothing extra by default', async () => { + const card = await mount({}); + expect(qa(card, '.extremes-layer')).toHaveLength(0); + expect(qa(card, '.average-line')).toHaveLength(0); + }); + + it('marks each series high and low with its value and time, and draws a labelled average line', async () => { + const card = await mount({ extremes: { min: true, max: true, average: 'line' } } as Partial); + expect(qa(card, '.extreme-dot')).toHaveLength(4); + const labels = texts(card, '.extreme-label'); + expect(labels.filter((l) => l.startsWith('High'))).toHaveLength(2); + expect(labels.some((l) => /^High 21\.0 °C · .+/.test(l))).toBe(true); + expect(labels.some((l) => /^Low 19\.0 °C · .+/.test(l))).toBe(true); + expect(labels.some((l) => /^Low 5\.0 °C/.test(l))).toBe(true); + // Inside: 19 for 10 h, 20 for 2 h, unavailable for 6 h, 21 for 2 h. + expect(labels).toContain('Avg 19.4 °C'); + const lines = qa(card, '.average-line'); + expect(lines).toHaveLength(2); + expect(lines[0].getAttribute('stroke')).toContain('--primary-color'); + const dot = qa(card, '.extreme-dot')[0] as HTMLElement; + expect(dot.getAttribute('style')).toMatch(/left:[\d.]+%;top:[\d.]+%/); + }); + + it('shows only the marks asked for, and skips a series switched off', async () => { + const card = await mount({ + extremes: { max: true, average: 'figure' }, + entities: ['sensor.in', { entity: 'sensor.out', extremes: false }], + } as Partial); + expect(texts(card, '.extreme-label')).toEqual([expect.stringMatching(/^High 21\.0 °C/)]); + expect(qa(card, '.average-line')).toHaveLength(0); + expect(texts(card, '.extreme-figure')).toEqual(['Avg 19.4 °C']); + // More than one series on the card: each figure gets its colour swatch. + expect(qa(card, '.extreme-figure .swatch')).toHaveLength(1); + }); + + it('averages grouped readings plainly and names their day', async () => { + const card = await mount({ + entities: ['sensor.out'], + group_by: 'hour', + aggregate: 'max', + extremes: { min: true, average: 'line' }, + } as Partial); + const labels = texts(card, '.extreme-label'); + expect(labels.some((l) => /^Low 5\.0 °C · /.test(l))).toBe(true); + expect(labels.some((l) => l.startsWith('Avg '))).toBe(true); + }); + + it('keeps labels clear of a header laid over the plot', async () => { + const card = await mount({ header: 'over', extremes: { max: true } } as Partial); + expect(qa(card, '.extreme-label')).toHaveLength(2); + }); + + it('rejects a bad extremes option', () => { + const card = document.createElement('chartdeck-card') as Card; + expect(() => + card.setConfig({ + type: 'x', + style: 'line', + entities: ['sensor.in'], + extremes: { average: 'mode' }, + } as CardConfig), + ).toThrow(/extremes.average/); + }); +}); + +describe('extremes editor', () => { + async function editor(config: Partial) { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.in'], ...config } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + return { ed, events, raw }; + } + + it('sets the high, low and average marks in the Chart section', async () => { + const { ed, events, raw } = await editor({}); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + const names = JSON.stringify(form.schema); + for (const n of ['extremes_max', 'extremes_min', 'extremes_average']) expect(names).toContain(n); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), extremes_max: true, extremes_average: 'line' } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, extremes: { max: true, average: 'line' } }); + }); + + it('removes the marks when they go back to off', async () => { + const { ed, events, raw } = await editor({ extremes: { max: true } } as Partial); + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((form.data as Record).extremes_max).toBe(true); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), extremes_max: false } } }), + ); + const { extremes, ...rest } = raw as CardConfig & { extremes?: unknown }; + expect(extremes).toEqual({ max: true }); + expect(events.at(-1)).toEqual(rest); + }); + + it('offers each series its own switch only while the card marks something', async () => { + const fieldNames = (ed: HTMLElement) => + ( + (ed.shadowRoot!.querySelector('cd-entities-editor') as Stub).chartFields as { fields: { name: string }[] } + ).fields.map((f) => f.name); + const off = await editor({}); + expect(fieldNames(off.ed)).not.toContain('extremes'); + const on = await editor({ extremes: { min: true } } as Partial); + expect(fieldNames(on.ed)).toEqual(['series_color', 'curve', 'line_width', 'dash', 'on_top', 'dots', 'extremes']); + const schema = ( + (on.ed.shadowRoot!.querySelector('cd-entities-editor') as Stub).chartFields as { schema: unknown[] } + ).schema; + expect(JSON.stringify(schema)).toContain('"name":"extremes"'); + }); +}); diff --git a/test/line.test.ts b/test/line.test.ts index 3dccdce..aedcf25 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -273,6 +273,9 @@ describe('line chart editor', () => { labels_overlap: false, line_width: 2.5, dots: false, + extremes_max: false, + extremes_min: false, + extremes_average: 'none', x_axis: true, y_axis: true, axis_labels: 'outside', From d763c97a0c86f63f9ffb7ab25f65e5f1426030db Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:05:08 +0000 Subject: [PATCH 34/41] feat: draw a history chart's values on a logarithmic scale `y_scale: log` puts the value axis on powers of ten, for readings that span several orders of magnitude: light from a dim room to full sun, network traffic with spikes. It is in the Chart section of the editor as "Value scale" (Even steps / Logarithmic), with the other axis options, so every history chart drawn through the shared axes gets it. Labels go at each power of ten. Within a decade or two they go at 1, 2 and 5 (or every step for a narrow span); across many decades every other or every third power of ten is labelled, counting from the bottom. The steps between labels are drawn as fainter gridlines. Ends that are not fixed move out to the next labelled step; y_min and y_max are kept exactly. Small ticks get the decimals they need (0.01) and large ones read short (10K, 1M). Zero and negative readings have no place on a log axis, so they are drawn as gaps in the line (statistics min and max too) and the axis starts from the smallest reading above zero. The tooltip still reads their real value, with no dot. A chart with nothing above zero says so instead of drawing an empty axis. A y_min or y_max at or below zero with a log scale is refused with a readable error rather than ignored. The line chart's band colours (color_by: bands) now place their stops through the value scale, so they change colour where the value is drawn on either scale. Closes #58 --- src/chart/axes.ts | 29 ++++- src/chart/grid.ts | 24 +++- src/chart/log-scale.ts | 140 ++++++++++++++++++++ src/editor/chart-editors.ts | 7 +- src/styles/line.ts | 27 +++- test/axes.test.ts | 11 +- test/line.test.ts | 1 + test/log-scale.test.ts | 248 ++++++++++++++++++++++++++++++++++++ 8 files changed, 473 insertions(+), 14 deletions(-) create mode 100644 src/chart/log-scale.ts create mode 100644 test/log-scale.test.ts diff --git a/src/chart/axes.ts b/src/chart/axes.ts index 3aba541..4cb5d6f 100644 --- a/src/chart/axes.ts +++ b/src/chart/axes.ts @@ -16,13 +16,25 @@ export interface AxisOptions { /** Fixed ends of the value axis; absent means fit the readings. */ min?: number; max?: number; + /** Even steps, or powers of ten (`y_scale: log`). */ + scale: YScale; } +export const Y_SCALES = ['linear', 'log'] as const; +export type YScale = (typeof Y_SCALES)[number]; + export const AXIS_LABELS = ['outside', 'inside'] as const; export type AxisLabels = (typeof AXIS_LABELS)[number]; /** A chart without axis keys: labels in a margin, lines across but not down. */ -export const DEFAULT_AXES: AxisOptions = { x: true, y: true, labels: 'outside', horizontal: true, vertical: false }; +export const DEFAULT_AXES: AxisOptions = { + x: true, + y: true, + labels: 'outside', + horizontal: true, + vertical: false, + scale: 'linear', +}; type AxisRaw = CardConfig & { x_axis?: unknown; @@ -31,6 +43,7 @@ type AxisRaw = CardConfig & { gridlines?: unknown; y_min?: unknown; y_max?: unknown; + y_scale?: unknown; }; const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); @@ -59,6 +72,15 @@ export function validateAxisOptions(config: CardConfig): void { if (isNumber(raw.y_min) && isNumber(raw.y_max) && raw.y_min >= raw.y_max) { throw new Error('"y_min" must be below "y_max"'); } + if (raw.y_scale !== undefined && !Y_SCALES.includes(raw.y_scale as YScale)) { + throw new Error(`"y_scale" must be one of: ${Y_SCALES.join(', ')}`); + } + if (raw.y_scale === 'log') { + // A log axis has no zero: its ends must be above it. + for (const k of ['y_min', 'y_max'] as const) { + if (isNumber(raw[k]) && raw[k] <= 0) throw new Error(`"${k}" must be above 0 on a log scale`); + } + } } /** The card's axis options with defaults filled in. Call after `validateAxisOptions`. */ @@ -73,6 +95,7 @@ export function axisOptions(config: CardConfig): AxisOptions { vertical: grid.vertical ?? DEFAULT_AXES.vertical, min: isNumber(raw.y_min) ? raw.y_min : undefined, max: isNumber(raw.y_max) ? raw.y_max : undefined, + scale: (raw.y_scale as YScale | undefined) ?? DEFAULT_AXES.scale, }; } @@ -97,6 +120,10 @@ export interface ValueAxis { domain: [number, number]; ticks: number[]; decimals: number; + /** Log axes: unlabelled steps between the labels, for fainter gridlines. */ + minor?: number[]; + /** Log axes: the decimals each tick needs, in step with `ticks`. */ + tickDecimals?: number[]; } /** diff --git a/src/chart/grid.ts b/src/chart/grid.ts index 29d1400..9760076 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -8,6 +8,7 @@ import type { AxisOptions, TimeUnit, ValueAxis } from './axes'; import type { Hover } from './tooltip'; import { VIEW_H, VIEW_W, plot } from './frame'; import { linear } from './scale'; +import { logAxis, logScale, logTickText } from './log-scale'; /** Gaps between value labels on a full-height plot: about one per 40 px. */ const VALUE_GAPS = 4; @@ -88,7 +89,8 @@ export function plotAxes( gaps = VALUE_GAPS, ): PlotAxes { const options = axisOptions(ctx.raw); - const value = valueAxis(data, options, gaps); + const log = options.scale === 'log'; + const value = log ? logAxis(data, options, gaps) : valueAxis(data, options, gaps); // Count labels across the plot itself, not the card: the value labels take part of the width. const count = Math.max(2, Math.min(8, Math.floor((width ?? plotWidth(ctx)) / TIME_LABEL_PX))); const { unit, ticks } = timeTicks(range, count, zoneOffset(timeZoneOf(ctx.hass))); @@ -100,7 +102,7 @@ export function plotAxes( range, scales: { x: linear([range.start, range.end], [0, VIEW_W]), - y: linear(value.domain, [VIEW_H, 0]), + y: (log ? logScale : linear)(value.domain, [VIEW_H, 0]), domain: value.domain, }, }; @@ -110,6 +112,7 @@ export function plotAxes( function gridLines(axes: PlotAxes): SVGTemplateResult { const { options, scales } = axes; return svg` + ${options.horizontal ? (axes.value.minor ?? []).map((v) => svg``) : nothing} ${options.horizontal ? axes.value.ticks.map((v) => svg``) : nothing} ${options.vertical ? axes.times.map((t) => svg``) : nothing} `; @@ -117,14 +120,22 @@ function gridLines(axes: PlotAxes): SVGTemplateResult { const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; +/** The text of the i-th value label: log axes give each tick its own decimals and shorten big ones. */ +function tickText(value: ValueAxis, i: number, ctx: ChartContext): string { + const v = value.ticks[i]; + return value.tickDecimals + ? logTickText(v, value.tickDecimals[i], ctx.hass) + : formatNumber(v, value.decimals, ctx.hass); +} + function valueLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, value } = axes; - return value.ticks.map((v) => { + return value.ticks.map((v, i) => { const top = scales.y(v) / VIEW_H; // Inside the plot the top label hangs below its line, so it doesn't leave the chart. const end = top < 0.08 ? 'top' : top > 0.92 ? 'bottom' : ''; return html`${format ? format(v) : formatNumber(v, value.decimals, ctx.hass)}${format ? format(v) : tickText(value, i, ctx)}`; }); } @@ -224,7 +235,7 @@ export function chartArea({ const sizer = y ? html`` : nothing; @@ -276,6 +287,9 @@ export const gridStyles = css` stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); stroke-width: 1; } + .plot .grid line.minor { + opacity: 0.45; + } .axis-label { position: absolute; font-size: 11px; diff --git a/src/chart/log-scale.ts b/src/chart/log-scale.ts new file mode 100644 index 0000000..6d677e3 --- /dev/null +++ b/src/chart/log-scale.ts @@ -0,0 +1,140 @@ +import { formatNumber } from '../format'; +import type { Point } from '../history/types'; +import type { HomeAssistant } from '../types'; +import type { ValueAxis } from './axes'; + +/** + * A logarithmic value axis. Only readings above zero have a place on it: zero and negatives are + * left out of the drawing (a gap in the line), though the tooltip still reads them out. + */ + +/** Which steps inside each power of ten carry a label, finest first. */ +const MANTISSAS = [[1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 5], [1]]; +/** With more decades than labels, label every n-th power of ten. */ +const DECADE_STEPS = [2, 3, 5, 10, 20, 50, 100]; + +/** Clean up floating-point fuzz (2 × 0.1 → 0.2, 10 ** -3 → 0.001). */ +const tidy = (v: number) => Number(v.toPrecision(12)); +const pow = (e: number) => tidy(10 ** e); +const decadeOf = (v: number) => Math.floor(Math.log10(v) + 1e-9); + +/** Readings at or below zero as gaps (min and max too), for drawing on a log axis. */ +export function positivePoints(points: Point[]): Point[] { + return points.map((p) => { + const bad = (v: number | null | undefined) => typeof v === 'number' && v <= 0; + if (!bad(p.v) && !bad(p.min) && !bad(p.max)) return p; + return { + ...p, + v: bad(p.v) ? null : p.v, + ...(p.min !== undefined ? { min: bad(p.min) ? null : p.min } : {}), + ...(p.max !== undefined ? { max: bad(p.max) ? null : p.max } : {}), + }; + }); +} + +/** The values `m × 10ⁿ` between lo and hi: m from `mantissas`, n every `every` decades counting from lo's. */ +function steps(lo: number, hi: number, mantissas: number[], every = 1): number[] { + const out: number[] = []; + const first = decadeOf(lo); + for (let e = first; e <= decadeOf(hi) + every; e += every) { + for (const m of mantissas) { + const v = tidy(m * 10 ** e); + if (v >= lo * (1 - 1e-9) && v <= hi * (1 + 1e-9)) out.push(v); + } + } + return out; +} + +/** The largest step at or below `v` (`down`), or the smallest at or above it. */ +function snap(v: number, mantissas: number[], down: boolean): number { + const e = decadeOf(v); + const around = steps(pow(e - 2), pow(e + 2), mantissas); + const fit = down ? around.filter((s) => s <= v * (1 + 1e-9)) : around.filter((s) => s >= v * (1 - 1e-9)); + return down ? fit[fit.length - 1] : fit[0]; +} + +export interface LogAxis extends ValueAxis { + /** Unlabelled steps between the labels, for fainter gridlines. */ + minor: number[]; + /** Decimals each tick needs (0.01 → 2, 20 → 0), in step with `ticks`. */ + tickDecimals: number[]; +} + +/** + * The log axis for positive readings spanning `data`. Ends that aren't fixed move out to the + * nearest labelled step. Labels go at every 1–9, else 1, 2 and 5, else each power of ten, else + * every few powers of ten: the finest that gives at most `count + 1` labels. A fixed end at or + * below zero, or no readings, falls back to sensible room (the caller validates fixed ends). + */ +export function logAxis( + data: [number, number] | undefined, + fixed: { min?: number; max?: number }, + count: number, +): LogAxis { + let [lo, hi] = data ?? [1, 100]; + if (fixed.min !== undefined && fixed.min > 0) lo = fixed.min; + if (fixed.max !== undefined && fixed.max > 0) hi = fixed.max; + if (!(hi > 0)) hi = lo > 0 ? lo * 10 : 100; + if (!(lo > 0)) lo = hi / 1000; + const fixMin = fixed.min !== undefined && fixed.min === lo; + const fixMax = fixed.max !== undefined && fixed.max === hi; + if (hi <= lo) { + // Flat readings, or all past a fixed end: a power of ten's room. + if (fixMin) hi = lo * 10; + else if (fixMax) lo = hi / 10; + else { + lo = lo / Math.sqrt(10); + hi = hi * Math.sqrt(10); + } + } + const budget = Math.max(2, count + 1); + const choices: { mantissas: number[]; every: number }[] = [ + ...MANTISSAS.map((mantissas) => ({ mantissas, every: 1 })), + ...DECADE_STEPS.map((every) => ({ mantissas: [1], every })), + ]; + let chosen = choices[choices.length - 1]; + let domain: [number, number] = [lo, hi]; + let ticks: number[] = []; + for (const choice of choices) { + // Ends go to the next labelled step; when labels skip powers of ten, the next power of ten. + const a = fixMin ? lo : snap(lo, choice.mantissas, true); + const b = fixMax ? hi : snap(hi, choice.mantissas, false); + const t = steps(a, b, choice.mantissas, choice.every); + chosen = choice; + domain = [a, b]; + ticks = t; + if (t.length <= budget) break; + } + const minorSet = chosen.every > 1 ? [1] : MANTISSAS[0]; + const minor = + chosen.mantissas.length === 9 + ? [] + : steps(domain[0], domain[1], minorSet) + .filter((v) => !ticks.includes(v)) + .slice(0, 200); + const tickDecimals = ticks.map((v) => Math.max(0, -decadeOf(v))); + return { domain, ticks, decimals: Math.max(0, ...tickDecimals), minor, tickDecimals }; +} + +/** Map positive values onto `range` by their logarithm; zero and below have no place (NaN). */ +export function logScale(domain: [number, number], range: [number, number]): (v: number) => number { + const l0 = Math.log10(domain[0]); + const l1 = Math.log10(domain[1]); + const [r0, r1] = range; + const k = l1 === l0 ? 0 : (r1 - r0) / (l1 - l0); + return (v) => (v > 0 ? r0 + (Math.log10(v) - l0) * k : NaN); +} + +/** Above this, a log tick reads short: 10k, 1M. */ +const COMPACT_FROM = 10_000; + +/** A log axis label: as many decimals as the step needs, and big numbers in short form. */ +export function logTickText(v: number, decimals: number, hass?: HomeAssistant): string { + if (Math.abs(v) < COMPACT_FROM) return formatNumber(v, decimals, hass); + const language = hass?.locale?.language ?? hass?.language ?? 'en'; + try { + return new Intl.NumberFormat(language, { notation: 'compact', maximumFractionDigits: 1 }).format(v); + } catch { + return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(v); + } +} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index d057737..d3ec968 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -1,6 +1,6 @@ import { html } from 'lit'; import type { TemplateResult } from 'lit'; -import { AXIS_LABELS, DEFAULT_AXES } from '../chart/axes'; +import { AXIS_LABELS, DEFAULT_AXES, Y_SCALES } from '../chart/axes'; import { HEADER_PLACES } from '../chart/layout'; import { CURVES } from '../chart/paths'; import { LABEL_POSITIONS, LABEL_SHOW } from '../chart/data-labels'; @@ -140,6 +140,9 @@ registerStrings('en', { editor_grid_vertical: 'Lines down', editor_y_min: 'Lowest value shown', editor_y_max: 'Highest value shown', + editor_y_scale: 'Value scale', + editor_y_scale_linear: 'Even steps', + editor_y_scale_log: 'Logarithmic (powers of ten)', editor_tooltip: 'Show values on hover or touch', editor_line_width: 'Line width (px)', editor_dots: 'Dot at each reading', @@ -179,6 +182,7 @@ export const AXES_FIELDS: FieldBinding[] = [ { name: 'grid_vertical', path: ['gridlines', 'vertical'], default: DEFAULT_AXES.vertical }, { name: 'y_min', path: ['y_min'] }, { name: 'y_max', path: ['y_max'] }, + { name: 'y_scale', path: ['y_scale'], default: DEFAULT_AXES.scale }, { name: 'tooltip', path: ['tooltip'], default: true }, ]; @@ -195,6 +199,7 @@ export function axesSchema(hass?: HomeAssistant) { ], }, { name: 'axis_labels', selector: choice(hass, 'axis_labels', AXIS_LABELS) }, + { name: 'y_scale', selector: choice(hass, 'y_scale', Y_SCALES) }, { type: 'grid', name: '', diff --git a/src/styles/line.ts b/src/styles/line.ts index 9f63188..e957cde 100644 --- a/src/styles/line.ts +++ b/src/styles/line.ts @@ -62,6 +62,7 @@ import { import { dataLabelStyles, dataLabels, labelOptions, validateLabelOptions } from '../chart/data-labels'; import { stopSync, syncChart, validateSyncOptions } from '../chart/sync'; import { multiplesGrid, multiplesOptions, multiplesStyles, validateMultiples } from '../chart/multiples'; +import { positivePoints } from '../chart/log-scale'; import { lineEditor } from '../editor/chart-editors'; import { entitySeriesFields, entitySeriesSchema } from '../editor/schemas'; import { localize, registerStrings } from '../localize'; @@ -80,6 +81,7 @@ registerStrings('en', { chart_error: "Couldn't load history: {error}", chart_no_data: 'No readings in this period.', chart_aria: '{style} chart of {names}', + chart_log_nothing: 'Nothing above zero to draw on a log scale.', }); /** @@ -87,8 +89,11 @@ registerStrings('en', { * changes colour where it crosses a threshold. */ function bandGradient(id: string, bands: ResolvedBand[], scales: PlotScales): SVGTemplateResult { - const [lo, hi] = scales.domain; - const stop = (v: number) => Math.min(1, Math.max(0, (v - lo) / (hi - lo))); + // Through the scale, so a log axis puts each stop where the value is drawn (zero and below: the bottom). + const stop = (v: number) => { + const at = (VIEW_H - scales.y(v)) / VIEW_H; + return Number.isFinite(at) ? Math.min(1, Math.max(0, at)) : 0; + }; const stops: SVGTemplateResult[] = []; bands.forEach((band, i) => { const from = Number.isFinite(band.from) ? stop(band.from) : 0; @@ -319,9 +324,19 @@ export const line: ChartStyleModule = { const shown = zoom.zoomed ? frame.series.map((s) => ({ ...s, points: visiblePoints(s.points, view) })) : frame.series; + // A log axis has no place for zero or below: those readings are gaps in the drawing (the tooltip still reads them). + const log = ctx.raw.y_scale === 'log'; + const drawn = log ? shown.map((s) => ({ ...s, points: positivePoints(s.points) })) : shown; // Threshold lines widen the value axis, so a target off the readings' range still shows. - const thresholds = annotationValues(ctx.raw, ctx.hass).map((v) => ({ t: view.start, v })); - const axes = plotAxes(ctx, view, extent([...shown.map((s) => s.points), thresholds])); + const thresholds = annotationValues(ctx.raw, ctx.hass) + .filter((v) => !log || v > 0) + .map((v) => ({ t: view.start, v })); + const data = extent([...drawn.map((s) => s.points), thresholds]); + if (log && !data) { + const empty = html`
${localize(ctx.hass, 'chart_log_nothing')}
`; + return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, empty); + } + const axes = plotAxes(ctx, view, data); const { scales } = axes; const tooltip = tooltipEnabled(ctx.raw) && !zoomBusy(ctx.host); const byBands = ctx.raw.color_by === 'bands'; @@ -329,7 +344,7 @@ export const line: ChartStyleModule = { ? svg`${shown.map((s) => bandGradient(`cd-bands-${s.index}`, s.entity.bands, scales))}` : nothing; // Listed order, last on top, with any series marked "on top" drawn after the rest. - const ordered = drawOrder(shown, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); + const ordered = drawOrder(drawn, (s) => seriesStyle(s.entity.config, ctx.raw).onTop); const lines = svg`${ordered.map((s) => seriesPath(s, scales, byBands ? `url(#cd-bands-${s.index})` : s.color, frame.range, ctx.raw), )}`; @@ -369,7 +384,7 @@ export const line: ChartStyleModule = { label, defs, overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} - ${labels ? pointLabels(frame, shown, view, scales, labels, layout.compact, ctx) : nothing} + ${labels ? pointLabels(frame, drawn, view, scales, labels, layout.compact, ctx) : nothing} ${zoomLayer(ctx, zoom)} ${notes.overlay} ${marks.overlay} ${tooltip ? tooltipLayer(ctx, shown, scales, own ?? shared, plotWidth(ctx)) : nothing}`, hover, diff --git a/test/axes.test.ts b/test/axes.test.ts index 8303b07..0b7533e 100644 --- a/test/axes.test.ts +++ b/test/axes.test.ts @@ -162,7 +162,16 @@ describe('axis options', () => { gridlines: { vertical: true }, y_min: 0, } as CardConfig), - ).toEqual({ x: false, y: true, labels: 'inside', horizontal: true, vertical: true, min: 0, max: undefined }); + ).toEqual({ + x: false, + y: true, + labels: 'inside', + horizontal: true, + vertical: true, + min: 0, + max: undefined, + scale: 'linear', + }); }); it('explains a bad option', () => { diff --git a/test/line.test.ts b/test/line.test.ts index aedcf25..59e7c51 100644 --- a/test/line.test.ts +++ b/test/line.test.ts @@ -281,6 +281,7 @@ describe('line chart editor', () => { axis_labels: 'outside', grid_horizontal: true, grid_vertical: false, + y_scale: 'linear', tooltip: true, zoom: false, overview: false, diff --git a/test/log-scale.test.ts b/test/log-scale.test.ts new file mode 100644 index 0000000..c69a537 --- /dev/null +++ b/test/log-scale.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { axisOptions, validateAxisOptions } from '../src/chart/axes'; +import { logAxis, logScale, logTickText, positivePoints } from '../src/chart/log-scale'; +import type { CardConfig, HomeAssistant } from '../src/types'; +import { makeHass } from './helpers/hass'; + +class Stub extends HTMLElement { + [key: string]: unknown; +} +for (const tag of ['ha-form', 'ha-expansion-panel', 'ha-selector', 'ha-entity-picker', 'ha-icon-picker']) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +describe('log axis maths', () => { + it('labels each power of ten when a few decades are shown, with fainter steps between', () => { + const a = logAxis([1, 1000], {}, 4); + expect(a.domain).toEqual([1, 1000]); + expect(a.ticks).toEqual([1, 10, 100, 1000]); + expect(a.minor).toContain(2); + expect(a.minor).toContain(900); + expect(a.minor).toHaveLength(24); + }); + + it('moves loose ends out to the next labelled step', () => { + expect(logAxis([12, 870], {}, 4)).toMatchObject({ domain: [10, 1000], ticks: [10, 100, 1000] }); + }); + + it('labels 1, 2 and 5 within a decade, or every step for a narrow span', () => { + expect(logAxis([0.5, 3], {}, 4)).toMatchObject({ + domain: [0.5, 5], + ticks: [0.5, 1, 2, 5], + tickDecimals: [1, 0, 0, 0], + }); + const narrow = logAxis([18, 24], {}, 4); + expect(narrow.ticks).toEqual([10, 20, 30]); + expect(narrow.minor).toEqual([]); + }); + + it('skips powers of ten when there are more decades than labels, counting from the bottom', () => { + const a = logAxis([0.003, 2e6], {}, 4); + expect(a.domain).toEqual([0.001, 1e7]); + expect(a.ticks).toEqual([0.001, 1, 1000, 1e6]); + expect(a.minor).toEqual([0.01, 0.1, 10, 100, 10000, 100000, 1e7]); + expect(a.decimals).toBe(3); + }); + + it('keeps fixed ends exactly', () => { + expect(logAxis([3, 40], { min: 1 }, 4)).toMatchObject({ domain: [1, 100], ticks: [1, 10, 100] }); + expect(logAxis([3, 40], { max: 500 }, 4).domain).toEqual([1, 500]); + }); + + it('gives flat readings, readings past a fixed end, and no readings some room', () => { + expect(logAxis([5, 5], {}, 4).domain).toEqual([1, 20]); + expect(logAxis([5, 8], { min: 50 }, 4).domain).toEqual([50, 500]); + expect(logAxis([500, 800], { max: 50 }, 4).domain).toEqual([5, 50]); + expect(logAxis(undefined, {}, 4).domain).toEqual([1, 100]); + // A caller that didn't drop zero: the bottom becomes three decades under the top. + expect(logAxis([0, 1000], {}, 4).domain).toEqual([1, 1000]); + expect(logAxis([-5, -1], {}, 4).domain).toEqual([0.1, 100]); + }); + + it('maps by logarithm and has no place for zero or below', () => { + const y = logScale([1, 1000], [200, 0]); + expect(y(1)).toBe(200); + expect(y(1000)).toBe(0); + expect(y(10)).toBeCloseTo(133.33, 1); + expect(y(0)).toBeNaN(); + expect(y(-3)).toBeNaN(); + expect(logScale([5, 5], [200, 0])(5)).toBe(200); + }); + + it('turns readings at or below zero into gaps, min and max too', () => { + const pts = positivePoints([ + { t: 1, v: 5 }, + { t: 2, v: 0 }, + { t: 3, v: -2, min: -4, max: 3 }, + { t: 4, v: null }, + ]); + expect(pts).toEqual([ + { t: 1, v: 5 }, + { t: 2, v: null }, + { t: 3, v: null, min: null, max: 3 }, + { t: 4, v: null }, + ]); + }); + + it('writes small ticks with the decimals they need and big ones short', () => { + const hass = makeHass({}); + expect(logTickText(0.01, 2, hass)).toBe('0.01'); + expect(logTickText(1000, 0, hass)).toBe('1,000'); + expect(logTickText(1e6, 0, hass)).toBe('1M'); + expect(logTickText(20000, 0, { ...hass, locale: { language: 'xx-nope' } } as HomeAssistant)).toBe('20K'); + }); +}); + +describe('log scale option', () => { + const base = { type: 'x', entities: [] } as unknown as CardConfig; + const check = (extra: Record) => () => validateAxisOptions({ ...base, ...extra } as CardConfig); + + it('defaults to linear and reads log', () => { + expect(axisOptions(base).scale).toBe('linear'); + expect(axisOptions({ ...base, y_scale: 'log' } as CardConfig).scale).toBe('log'); + }); + + it('explains a bad scale, and ends at or below zero on a log scale', () => { + expect(check({ y_scale: 'logarithmic' })).toThrow('"y_scale" must be one of: linear, log'); + expect(check({ y_scale: 'log', y_min: 0 })).toThrow('"y_min" must be above 0 on a log scale'); + expect(check({ y_scale: 'log', y_max: -1 })).toThrow('"y_max" must be above 0 on a log scale'); + expect(check({ y_scale: 'log', y_min: 0.1, y_max: 1000 })).not.toThrow(); + expect(check({ y_min: 0 })).not.toThrow(); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +const now = Date.now(); +const H = 3_600_000; +const sec = (msAgo: number) => (now - msAgo) / 1000; + +async function mount(config: Partial, history: Record) { + const hass = makeHass({ + 'sensor.lux': ['800', { friendly_name: 'Light', unit_of_measurement: 'lx' }], + 'sensor.net': ['0', { friendly_name: 'Traffic', unit_of_measurement: 'B/s' }], + }); + hass.callWS = vi.fn(async () => history) as HomeAssistant['callWS']; + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'line', + entities: ['sensor.lux', 'sensor.net'], + y_scale: 'log', + ...config, + } as CardConfig); + card.hass = hass; + document.body.appendChild(card); + await card.updateComplete; + await new Promise((r) => setTimeout(r, 0)); + await card.updateComplete; + return card; +} + +const qa = (card: Card, sel: string) => [...card.shadowRoot!.querySelectorAll(sel)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('line chart on a log scale', () => { + const history = { + 'sensor.lux': [ + { s: '2', lu: sec(20 * H) }, + { s: '40', lu: sec(12 * H) }, + { s: '0', lu: sec(10 * H) }, + { s: '30000', lu: sec(6 * H) }, + { s: '800', lu: sec(2 * H) }, + ], + 'sensor.net': [ + { s: '-5', lu: sec(20 * H) }, + { s: '0', lu: sec(4 * H) }, + ], + }; + + it('labels powers of ten, breaks the line at zero, and leaves the all-zero series undrawn', async () => { + const card = await mount({ labels: true }, history); + const labels = qa(card, '.y-axis .axis-label').map((e) => e.textContent!.trim()); + // Five decades is more than the labels fit: every other power of ten, the rest as faint lines. + expect(labels).toEqual(['1', '100', '10K']); + expect(qa(card, '.grid line.minor').length).toBeGreaterThan(0); + const [lux, net] = qa(card, 'path.line'); + // Two runs: before the zero, and after it. + expect(lux.getAttribute('d')!.match(/M/g)).toHaveLength(2); + expect(net.getAttribute('d')).toBe(''); + const points = qa(card, '.point-label').map((e) => e.textContent); + expect(points).toEqual(['2.0', '40.0', '30,000.0', '800.0']); + expect(points).not.toContain('0.0'); + }); + + it('puts band stops where the values are drawn', async () => { + const card = await mount( + { + color_by: 'bands', + y_min: 1, + y_max: 100, + severity: [ + { from: 0, color: 'blue' }, + { from: 10, color: 'red' }, + ], + }, + history, + ); + const offsets = qa(card, '#cd-bands-0 stop').map((s) => Number(s.getAttribute('offset'))); + expect(offsets).toEqual([0, 0.5, 0.5, 1]); + }); + + it('reads a zero out in the tooltip without a dot for it', async () => { + const card = await mount({}, history); + const area = card.shadowRoot!.querySelector('.plot-area')!; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 160 }) as DOMRect; + // 10 h ago: the light read 0 then, and the traffic's nearest reading is its 0 from 4 h ago. + const ev = new MouseEvent('pointermove', { bubbles: true, clientX: (14 / 24) * 1000 }); + Object.defineProperty(ev, 'pointerType', { value: 'mouse' }); + area.dispatchEvent(ev); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toEqual(['Light 0.0 lx', 'Traffic 0.0 B/s']); + expect(qa(card, '.hover-dot')).toHaveLength(0); + }); + + it('says so when nothing is above zero', async () => { + const card = await mount({ entities: ['sensor.net'] }, history); + expect(card.shadowRoot!.querySelector('.chart-message')!.textContent).toContain('Nothing above zero'); + }); + + it('turns down a log scale with a bottom at zero', () => { + const card = document.createElement('chartdeck-card') as Card; + expect(() => + card.setConfig({ type: 'x', style: 'line', entities: ['sensor.lux'], y_scale: 'log', y_min: 0 } as CardConfig), + ).toThrow('"y_min" must be above 0 on a log scale'); + }); +}); + +describe('log scale in the editor', () => { + it('offers the value scale in the Chart section and writes y_scale', async () => { + const ed = document.createElement('chartdeck-card-editor') as HTMLElement & { + setConfig(c: CardConfig): void; + hass?: unknown; + updateComplete: Promise; + }; + const events: CardConfig[] = []; + ed.addEventListener('config-changed', (e) => events.push((e as CustomEvent).detail.config)); + ed.hass = makeHass({}); + const raw = { type: 'custom:chartdeck-card', style: 'line', entities: ['sensor.lux'] } as CardConfig; + ed.setConfig(raw); + document.body.appendChild(ed); + await ed.updateComplete; + const form = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect((form.data as Record).y_scale).toBe('linear'); + const field = JSON.stringify(form.schema); + expect(field).toContain('"name":"y_scale"'); + expect(field).toContain('Logarithmic (powers of ten)'); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), y_scale: 'log' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, y_scale: 'log' }); + }); +}); From 6ccdf12ba0984b6a94753c9cf25d20b1c5480ec0 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 15:09:35 +0000 Subject: [PATCH 35/41] fix: only the line chart offers a logarithmic value scale The log scale was added to the axis options every history chart shares, so columns, stacked areas and the climate chart offered it too, though their bars and fills stand on zero. It is now in the line chart's Chart section only, and other charts draw on even steps whatever y_scale says. Also brings the timeline and wind rose tests up to date with the merged history readers. --- src/chart/grid.ts | 3 ++- src/editor/chart-editors.ts | 5 +++-- test/timeline.test.ts | 3 ++- test/wind-rose.test.ts | 1 + 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/chart/grid.ts b/src/chart/grid.ts index 9760076..6e1d1a6 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -89,7 +89,8 @@ export function plotAxes( gaps = VALUE_GAPS, ): PlotAxes { const options = axisOptions(ctx.raw); - const log = options.scale === 'log'; + // Only the line chart draws on a log scale: columns and stacked areas stand on zero. + const log = options.scale === 'log' && ctx.raw.style === 'line'; const value = log ? logAxis(data, options, gaps) : valueAxis(data, options, gaps); // Count labels across the plot itself, not the card: the value labels take part of the width. const count = Math.max(2, Math.min(8, Math.floor((width ?? plotWidth(ctx)) / TIME_LABEL_PX))); diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index d3ec968..23e75cd 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -182,7 +182,6 @@ export const AXES_FIELDS: FieldBinding[] = [ { name: 'grid_vertical', path: ['gridlines', 'vertical'], default: DEFAULT_AXES.vertical }, { name: 'y_min', path: ['y_min'] }, { name: 'y_max', path: ['y_max'] }, - { name: 'y_scale', path: ['y_scale'], default: DEFAULT_AXES.scale }, { name: 'tooltip', path: ['tooltip'], default: true }, ]; @@ -199,7 +198,6 @@ export function axesSchema(hass?: HomeAssistant) { ], }, { name: 'axis_labels', selector: choice(hass, 'axis_labels', AXIS_LABELS) }, - { name: 'y_scale', selector: choice(hass, 'y_scale', Y_SCALES) }, { type: 'grid', name: '', @@ -364,6 +362,7 @@ const LINE_FIELDS: FieldBinding[] = [ { name: 'line_width', path: ['line_width'], default: DEFAULT_LINE_WIDTH }, { name: 'dots', path: ['dots'], default: false }, ...AXES_FIELDS, + { name: 'y_scale', path: ['y_scale'], default: DEFAULT_AXES.scale }, ...ZOOM_FIELDS, ...ANNOTATION_FIELDS, ...EXTREMES_FIELDS, @@ -396,6 +395,8 @@ export function lineEditor(ctx: ChartEditorContext): TemplateResult { }, ...labelSchema(ctx.hass, ctx.config), ...axesSchema(ctx.hass), + // Only the line chart draws on a log scale: columns and stacked areas stand on zero. + { name: 'y_scale', selector: choice(ctx.hass, 'y_scale', Y_SCALES) }, ...ZOOM_SCHEMA, ...ANNOTATION_SCHEMA, ...extremesSchema(ctx.hass), diff --git a/test/timeline.test.ts b/test/timeline.test.ts index 5fb417a..876b3c0 100644 --- a/test/timeline.test.ts +++ b/test/timeline.test.ts @@ -163,7 +163,8 @@ describe('loading states as words', () => { ]); expect(out.get('sensor.t')).toEqual([{ t: 1000, v: 21.5 }]); expect(key({ entity: 'switch.a', text: true })).toBe('switch.a|text'); - expect(parseHistory([{ s: 'on', lu: 1 }])).toEqual([{ t: 1000, v: null }]); + // A number series reads on/off as 1/0 (see toReading), a text series keeps the words. + expect(parseHistory([{ s: 'on', lu: 1 }])).toEqual([{ t: 1000, v: 1 }]); }); it('appends live states as words', async () => { diff --git a/test/wind-rose.test.ts b/test/wind-rose.test.ts index e98b12e..a95ef4b 100644 --- a/test/wind-rose.test.ts +++ b/test/wind-rose.test.ts @@ -74,6 +74,7 @@ describe('wind direction parsing', () => { { s: 'calm', lu: 2 }, ], undefined, + false, parseBearing, ), ).toEqual([ From 0aa569d443d4bcb556cf1e2683a891a848e1826c Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 15:13:23 +0000 Subject: [PATCH 36/41] fix: editor labels no longer change depending on which charts loaded first Editor strings are global, and several charts registered the same key with different text, so whichever loaded last labelled the others: the treemap's Group by read 'Group readings by', the trend card's Legend and No background options renamed the heatmap's and the slope chart's, the day clock's centre choices changed the pie's, and direction and reading options read differently from chart to chart. Fields that mean different things now have their own labels (chartForm takes label keys), and the rest share one text. Also removes the comparison charts' copy of the editor helpers and series colours. --- src/chart/category-plot.ts | 2 +- src/chart/colors.ts | 5 +-- src/chart/compare-form.ts | 57 ---------------------------------- src/editor/chart-editors.ts | 4 ++- src/editor/day-clock-editor.ts | 8 ++--- src/editor/group-fields.ts | 5 ++- src/editor/trend-editor.ts | 9 ++++-- src/styles/box-plot.ts | 6 ++-- src/styles/bullet.ts | 2 +- src/styles/dumbbell.ts | 4 +-- src/styles/grouped-bars.ts | 2 +- src/styles/lollipop.ts | 2 +- src/styles/radar.ts | 4 +-- src/styles/sunburst.ts | 7 +++-- src/styles/treemap.ts | 4 +-- src/styles/violin.ts | 2 +- src/styles/waterfall.ts | 4 +-- 17 files changed, 40 insertions(+), 87 deletions(-) delete mode 100644 src/chart/compare-form.ts diff --git a/src/chart/category-plot.ts b/src/chart/category-plot.ts index 9b53946..b872fa1 100644 --- a/src/chart/category-plot.ts +++ b/src/chart/category-plot.ts @@ -79,7 +79,7 @@ const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.is registerStrings('en', { editor_orientation: 'Direction', editor_orientation_horizontal: 'Across', - editor_orientation_vertical: 'Up', + editor_orientation_vertical: 'Upright', editor_value_axis: 'Value labels', editor_value_grid: 'Lines at each value label', editor_value_min: 'Lowest value shown', diff --git a/src/chart/colors.ts b/src/chart/colors.ts index 7b0fc93..199bc56 100644 --- a/src/chart/colors.ts +++ b/src/chart/colors.ts @@ -1,7 +1,8 @@ import { baseColor } from '../bands'; +import { SERIES_COLORS } from './frame'; -/** Series colours in order, for entities and groups without their own `color`. */ -export const SERIES_COLORS = ['primary', 'amber', 'green', 'purple', 'red', '#14b8a6', '#ec4899', 'grey']; +/** Series colours in order, for entities and groups without their own `color` (shared with the history charts). */ +export { SERIES_COLORS }; /** The n-th series colour (wrapping round), or `own` when set, ready for CSS. */ export function seriesColor(index: number, own?: unknown): string { diff --git a/src/chart/compare-form.ts b/src/chart/compare-form.ts deleted file mode 100644 index 9c17968..0000000 --- a/src/chart/compare-form.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { html } from 'lit'; -import type { TemplateResult } from 'lit'; -import { applyFormData, formData } from '../editor/schemas'; -import type { FieldBinding } from '../editor/schemas'; -import { languageOf, localize } from '../localize'; -import type { ChartEditorContext } from '../styles/index'; -import type { HomeAssistant } from '../types'; - -/* - * Editor helpers for the comparison charts. They match `choice`, `cachedSchema` and `chartForm` - * in the line chart's `src/editor/chart-editors.ts`, which these branches don't have yet; once both - * are on main, imports can move there and this file can go. - */ - -/** A dropdown of fixed values, labelled from `editor__`. */ -export function choice(hass: HomeAssistant | undefined, prefix: string, values: readonly string[]) { - return { - select: { - mode: 'dropdown', - options: values.map((value) => ({ value, label: localize(hass, `editor_${prefix}_${value}`) })), - }, - }; -} - -const cache = new Map(); - -/** Build a schema once per language (and per id), so ha-form isn't handed a new one each render. */ -export function cachedSchema(id: string, hass: HomeAssistant | undefined, build: () => unknown[]): unknown[] { - const k = `${id}|${languageOf(hass)}`; - let schema = cache.get(k); - if (!schema) { - schema = build(); - cache.set(k, schema); - } - return schema; -} - -/** An ha-form bound to card-config paths; a value set back to its default removes the key. */ -export function chartForm( - ctx: ChartEditorContext, - section: string, - schema: unknown[], - fields: FieldBinding[], -): TemplateResult { - return html` localize(ctx.hass, `editor_${s.name}`)} - @value-changed=${(ev: CustomEvent) => { - ev.stopPropagation(); - const next = applyFormData(ctx.config, fields, (ev.detail?.value ?? {}) as Record); - if (next !== ctx.config) ctx.commit(next); - }} - >`; -} diff --git a/src/editor/chart-editors.ts b/src/editor/chart-editors.ts index 23e75cd..c18a968 100644 --- a/src/editor/chart-editors.ts +++ b/src/editor/chart-editors.ts @@ -339,13 +339,15 @@ export function chartForm( section: string, schema: unknown[], fields: FieldBinding[], + /** Label keys for fields whose name means something else on another chart (`editor_` otherwise). */ + labels: Record = {}, ): TemplateResult { return html` localize(ctx.hass, `editor_${s.name}`)} + .computeLabel=${(s: { name: string }) => localize(ctx.hass, labels[s.name] ?? `editor_${s.name}`)} @value-changed=${(ev: CustomEvent) => { ev.stopPropagation(); const next = applyFormData(ctx.config, fields, (ev.detail?.value ?? {}) as Record); diff --git a/src/editor/day-clock-editor.ts b/src/editor/day-clock-editor.ts index 92bb543..aa1944d 100644 --- a/src/editor/day-clock-editor.ts +++ b/src/editor/day-clock-editor.ts @@ -10,9 +10,9 @@ import type { FieldBinding } from './schemas'; registerStrings('en', { editor_days: 'Days (more than 1 shows the average day)', editor_center: 'In the middle', - editor_center_total: 'Time on', - editor_center_now: 'The time now', - editor_center_none: 'Nothing', + editor_day_clock_center_total: 'Time on', + editor_day_clock_center_now: 'The time now', + editor_day_clock_center_none: 'Nothing', editor_bucket: 'One bar for every', editor_bucket_15: '15 minutes', editor_bucket_30: '30 minutes', @@ -43,7 +43,7 @@ function schema(hass?: HomeAssistant) { ], }, { name: 'period_navigate', selector: { boolean: {} } }, - { name: 'center', selector: choice(hass, 'center', ['total', 'now', 'none']) }, + { name: 'center', selector: choice(hass, 'day_clock_center', ['total', 'now', 'none']) }, { type: 'grid', name: '', diff --git a/src/editor/group-fields.ts b/src/editor/group-fields.ts index 0d3ae44..48138cb 100644 --- a/src/editor/group-fields.ts +++ b/src/editor/group-fields.ts @@ -6,7 +6,7 @@ import { entityOptionsForm } from './entity-options'; import type { FieldBinding } from './schemas'; registerStrings('en', { - editor_group_by: 'Group by', + editor_hierarchy_group_by: 'Group by', editor_group_by_floor: 'Floor', editor_group_by_area: 'Area', editor_group_by_device: 'Device', @@ -47,3 +47,6 @@ export function withGroupList(ctx: ChartEditorContext): ChartEditorContext { export function entityGroupsForm(ctx: ChartEditorContext) { return entityOptionsForm(ctx, 'entity_groups', 'group', { text: {} }); } + +/** Label keys for chartForm: here `group_by` groups by place, not by time as on the history charts. */ +export const HIERARCHY_LABELS = { group_by: 'editor_hierarchy_group_by' }; diff --git a/src/editor/trend-editor.ts b/src/editor/trend-editor.ts index d428150..37ea8ac 100644 --- a/src/editor/trend-editor.ts +++ b/src/editor/trend-editor.ts @@ -36,7 +36,7 @@ registerStrings('en', { editor_color_transition_hard: 'Change at each band', editor_extrema: 'Show lowest and highest', editor_average: 'Show average', - editor_legend: 'Legend (two or more entities)', + editor_trend_legend: 'Legend (two or more entities)', editor_trend_y_min: 'Lowest value (~ for soft)', editor_trend_y_max: 'Highest value (~ for soft)', editor_min_range: 'Smallest range shown', @@ -62,7 +62,7 @@ registerStrings('en', { editor_color_name: 'Colour the name', editor_color_value: 'Colour the value', editor_trend_color_icon: 'Colour the icon', - editor_group: 'No background (inside another card)', + editor_trend_group: 'No background (inside another card)', editor_icon_image: 'Image instead of the icon (URL)', editor_trend_tooltip: 'Show the value under the pointer', editor_y_axis_side: 'Axis', @@ -180,7 +180,10 @@ export function trendEditor(ctx: ChartEditorContext): TemplateResult { config: { ...ctx.config, ...stringBounds(ctx.config) }, commit: (config: CardConfig) => ctx.commit(numericBounds(config)), }; - return chartForm(form, 'chart', schema, TREND_FIELDS); + return chartForm(form, 'chart', schema, TREND_FIELDS, { + legend: 'editor_trend_legend', + group: 'editor_trend_group', + }); } /** Numbers shown in the bound text fields as text. */ diff --git a/src/styles/box-plot.ts b/src/styles/box-plot.ts index a71975c..d34a518 100644 --- a/src/styles/box-plot.ts +++ b/src/styles/box-plot.ts @@ -72,13 +72,13 @@ registerStrings('en', { box_mean: 'Average', box_count: 'Readings', box_outliers: 'Outliers', - editor_orientation: 'Boxes', + editor_orientation: 'Direction', editor_orientation_vertical: 'Upright', - editor_orientation_horizontal: 'On their side', + editor_orientation_horizontal: 'Across', editor_whiskers: 'Whiskers reach', editor_whiskers_tukey: '1.5 × box height (outliers as dots)', editor_whiskers_range: 'The lowest and highest', - editor_readings: 'Show each reading', + editor_readings: 'Show each reading as a dot', editor_box_color: 'Box colour', }); diff --git a/src/styles/bullet.ts b/src/styles/bullet.ts index 17f932a..73c0265 100644 --- a/src/styles/bullet.ts +++ b/src/styles/bullet.ts @@ -1,5 +1,5 @@ import { css, html, nothing, svg } from 'lit'; -import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; import { entityMark, markStyles, rangeFill, rangeSegments, targetText } from '../chart/marks'; import { localize, registerStrings } from '../localize'; import type { ResolvedEntity } from '../resolve'; diff --git a/src/styles/dumbbell.ts b/src/styles/dumbbell.ts index 8012629..ec86ad0 100644 --- a/src/styles/dumbbell.ts +++ b/src/styles/dumbbell.ts @@ -27,8 +27,8 @@ registerStrings('en', { editor_ends_target: 'Target and now', editor_show_now: 'Mark the current value', editor_orientation: 'Direction', - editor_orientation_horizontal: 'Rows', - editor_orientation_vertical: 'Columns', + editor_orientation_horizontal: 'Across', + editor_orientation_vertical: 'Upright', dumbbell_low: 'Low', dumbbell_high: 'High', dumbbell_now: 'Now', diff --git a/src/styles/grouped-bars.ts b/src/styles/grouped-bars.ts index 981d531..c23d3f2 100644 --- a/src/styles/grouped-bars.ts +++ b/src/styles/grouped-bars.ts @@ -2,7 +2,7 @@ import { css, html, nothing, svg } from 'lit'; import { repeat } from 'lit/directives/repeat.js'; import { baseColor, onFillText } from '../bands'; import { extentOf, withZero } from '../chart/compare'; -import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber, withUnit } from '../format'; import { localize, registerStrings } from '../localize'; diff --git a/src/styles/lollipop.ts b/src/styles/lollipop.ts index 11737dd..3d140cd 100644 --- a/src/styles/lollipop.ts +++ b/src/styles/lollipop.ts @@ -1,5 +1,5 @@ import { css, html, nothing, svg } from 'lit'; -import { cachedSchema, chartForm, choice } from '../chart/compare-form'; +import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; import { entityMark, markStyles } from '../chart/marks'; import { localize, registerStrings } from '../localize'; import type { ResolvedEntity } from '../resolve'; diff --git a/src/styles/radar.ts b/src/styles/radar.ts index 87b6f97..9d51d64 100644 --- a/src/styles/radar.ts +++ b/src/styles/radar.ts @@ -5,7 +5,7 @@ import { groupPath, parseGroupBy, splitGroup, validateGroups } from '../chart/hi import { axisAngle, labelAnchor, polar, polygonPoints } from '../chart/polar'; import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; import { entityOptionsForm } from '../editor/entity-options'; -import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; import type { FieldBinding } from '../editor/schemas'; import { localize, registerStrings } from '../localize'; import type { ResolvedEntity } from '../resolve'; @@ -192,7 +192,7 @@ export function radarEditor(ctx: ChartEditorContext): TemplateResult { { name: 'radar_fill', selector: { boolean: {} } }, groupBySchema(ctx.hass), ]); - return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)} + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)} ${entityOptionsForm(ctx, 'radar_axis', 'axis', { text: {} })}`; } diff --git a/src/styles/sunburst.ts b/src/styles/sunburst.ts index 4c272c2..5b91ce3 100644 --- a/src/styles/sunburst.ts +++ b/src/styles/sunburst.ts @@ -7,7 +7,7 @@ import type { Span, TreeNode } from '../chart/hierarchy'; import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '../chart/drill'; import { sectorPath } from '../chart/polar'; import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; -import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber, withUnit } from '../format'; import { localize, registerStrings } from '../localize'; @@ -148,7 +148,8 @@ export function sunburstEditor(ctx: ChartEditorContext): TemplateResult { groupBySchema(ctx.hass), { name: 'drill_down', selector: { boolean: {} } }, ]); - return html`${chartForm(withGroupList(ctx), 'chart', schema, SUNBURST_FIELDS)} ${entityGroupsForm(ctx)}`; + return html`${chartForm(withGroupList(ctx), 'chart', schema, SUNBURST_FIELDS, HIERARCHY_LABELS)} + ${entityGroupsForm(ctx)}`; } export function icicleEditor(ctx: ChartEditorContext): TemplateResult { @@ -157,7 +158,7 @@ export function icicleEditor(ctx: ChartEditorContext): TemplateResult { groupBySchema(ctx.hass), { name: 'drill_down', selector: { boolean: {} } }, ]); - return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)}`; } function validate(raw: CardConfig, shapes: boolean): void { diff --git a/src/styles/treemap.ts b/src/styles/treemap.ts index b99ed03..f5fc519 100644 --- a/src/styles/treemap.ts +++ b/src/styles/treemap.ts @@ -8,7 +8,7 @@ import { drillBar, drillStyles, drillTarget, drillView, validateDrill } from '.. import { inset, squarify } from '../chart/treemap-layout'; import type { Rect } from '../chart/treemap-layout'; import { cachedSchema, chartForm, choice } from '../editor/chart-editors'; -import { GROUP_FIELDS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; +import { GROUP_FIELDS, HIERARCHY_LABELS, entityGroupsForm, groupBySchema, withGroupList } from '../editor/group-fields'; import type { FieldBinding } from '../editor/schemas'; import { formatNumber, withUnit } from '../format'; import { localize, registerStrings } from '../localize'; @@ -215,7 +215,7 @@ export function treemapEditor(ctx: ChartEditorContext): TemplateResult { groupBySchema(ctx.hass), { name: 'drill_down', selector: { boolean: {} } }, ]); - return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS)} ${entityGroupsForm(ctx)}`; + return html`${chartForm(withGroupList(ctx), 'chart', schema, FIELDS, HIERARCHY_LABELS)} ${entityGroupsForm(ctx)}`; } /** The tree the card's entities make, with groups from `group_by` and their own `group`. */ diff --git a/src/styles/violin.ts b/src/styles/violin.ts index 8d8cdc3..1d4df19 100644 --- a/src/styles/violin.ts +++ b/src/styles/violin.ts @@ -74,7 +74,7 @@ registerStrings('en', { editor_shape_violin: 'Violin', editor_shape_raincloud: 'Raincloud (half violin, box and readings)', editor_box: 'Box with the median and middle half', - editor_readings: 'Every reading as a dot', + editor_readings: 'Show each reading as a dot', editor_bandwidth: 'Smoothing (in the entity’s unit, empty for automatic)', editor_split_by: 'One shape per', editor_split_by_none: 'Entity', diff --git a/src/styles/waterfall.ts b/src/styles/waterfall.ts index 39eba99..e24658c 100644 --- a/src/styles/waterfall.ts +++ b/src/styles/waterfall.ts @@ -23,8 +23,8 @@ registerStrings('en', { editor_steps_change: 'How much it changed in the period', editor_steps_current: 'Its current value', editor_orientation: 'Direction', - editor_orientation_vertical: 'Columns', - editor_orientation_horizontal: 'Rows', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'Across', editor_start_from_first: "Start from the first entity's reading at the start of the period", editor_total: 'Show the total at the end', editor_waterfall_invert: 'Counts down (a rise in these is a drop in the total)', From d486d9f2d0069283eff61d79d377d17b7b3dea8e Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 15:15:32 +0000 Subject: [PATCH 37/41] chore: rebuild dist --- dist/chartdeck-card.js | 5486 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 5022 insertions(+), 464 deletions(-) diff --git a/dist/chartdeck-card.js b/dist/chartdeck-card.js index 287771c..91f7eb4 100644 --- a/dist/chartdeck-card.js +++ b/dist/chartdeck-card.js @@ -3,88 +3,88 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const e=globalThis,t=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,n=Symbol(),o=new WeakMap;let a=class{constructor(e,t,o){if(this._$cssResult$=!0,o!==n)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const n=this.t;if(t&&void 0===e){const t=void 0!==n&&1===n.length;t&&(e=o.get(n)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),t&&o.set(n,e))}return e}toString(){return this.cssText}};const r=(e,...t)=>{const o=1===e.length?e[0]:t.reduce((t,n,o)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(n)+e[o+1],e[0]);return new a(o,e,n)},i=t?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const n of e.cssRules)t+=n.cssText;return(e=>new a("string"==typeof e?e:e+"",void 0,n))(t)})(e):e,{is:s,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:d,getOwnPropertySymbols:h,getPrototypeOf:u}=Object,m=globalThis,p=m.trustedTypes,f=p?p.emptyScript:"",g=m.reactiveElementPolyfillSupport,y=(e,t)=>e,v={toAttribute(e,t){switch(t){case Boolean:e=e?f:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let n=e;switch(t){case Boolean:n=null!==e;break;case Number:n=null===e?null:Number(e);break;case Object:case Array:try{n=JSON.parse(e)}catch(e){n=null}}return n}},_=(e,t)=>!s(e,t),b={attribute:!0,type:String,converter:v,reflect:!1,useDefault:!1,hasChanged:_}; +const e=globalThis,t=e.ShadowRoot&&(void 0===e.ShadyCSS||e.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,o=Symbol(),a=new WeakMap;let n=class{constructor(e,t,a){if(this._$cssResult$=!0,a!==o)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=e,this.t=t}get styleSheet(){let e=this.o;const o=this.t;if(t&&void 0===e){const t=void 0!==o&&1===o.length;t&&(e=a.get(o)),void 0===e&&((this.o=e=new CSSStyleSheet).replaceSync(this.cssText),t&&a.set(o,e))}return e}toString(){return this.cssText}};const r=(e,...t)=>{const a=1===e.length?e[0]:t.reduce((t,o,a)=>t+(e=>{if(!0===e._$cssResult$)return e.cssText;if("number"==typeof e)return e;throw Error("Value passed to 'css' function must be a 'css' function result: "+e+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(o)+e[a+1],e[0]);return new n(a,e,o)},i=t?e=>e:e=>e instanceof CSSStyleSheet?(e=>{let t="";for(const o of e.cssRules)t+=o.cssText;return(e=>new n("string"==typeof e?e:e+"",void 0,o))(t)})(e):e,{is:s,defineProperty:l,getOwnPropertyDescriptor:c,getOwnPropertyNames:d,getOwnPropertySymbols:u,getPrototypeOf:h}=Object,p=globalThis,m=p.trustedTypes,f=m?m.emptyScript:"",g=p.reactiveElementPolyfillSupport,v=(e,t)=>e,y={toAttribute(e,t){switch(t){case Boolean:e=e?f:null;break;case Object:case Array:e=null==e?e:JSON.stringify(e)}return e},fromAttribute(e,t){let o=e;switch(t){case Boolean:o=null!==e;break;case Number:o=null===e?null:Number(e);break;case Object:case Array:try{o=JSON.parse(e)}catch(e){o=null}}return o}},b=(e,t)=>!s(e,t),_={attribute:!0,type:String,converter:y,reflect:!1,useDefault:!1,hasChanged:b}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),m.litPropertyMetadata??=new WeakMap;let x=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=b){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const n=Symbol(),o=this.getPropertyDescriptor(e,n,t);void 0!==o&&l(this.prototype,e,o)}}static getPropertyDescriptor(e,t,n){const{get:o,set:a}=c(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:o,set(t){const r=o?.call(this);a?.call(this,t),this.requestUpdate(e,r,n)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??b}static _$Ei(){if(this.hasOwnProperty(y("elementProperties")))return;const e=u(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(y("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(y("properties"))){const e=this.properties,t=[...d(e),...h(e)];for(const n of t)this.createProperty(n,e[n])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,n]of t)this.elementProperties.set(e,n)}this._$Eh=new Map;for(const[t,n]of this.elementProperties){const e=this._$Eu(t,n);void 0!==e&&this._$Eh.set(e,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const n=new Set(e.flat(1/0).reverse());for(const e of n)t.unshift(i(e))}else void 0!==e&&t.push(i(e));return t}static _$Eu(e,t){const n=t.attribute;return!1===n?void 0:"string"==typeof n?n:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const n of t.keys())this.hasOwnProperty(n)&&(e.set(n,this[n]),delete this[n]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const n=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((n,o)=>{if(t)n.adoptedStyleSheets=o.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of o){const o=document.createElement("style"),a=e.litNonce;void 0!==a&&o.setAttribute("nonce",a),o.textContent=t.cssText,n.appendChild(o)}})(n,this.constructor.elementStyles),n}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,n){this._$AK(e,n)}_$ET(e,t){const n=this.constructor.elementProperties.get(e),o=this.constructor._$Eu(e,n);if(void 0!==o&&!0===n.reflect){const a=(void 0!==n.converter?.toAttribute?n.converter:v).toAttribute(t,n.type);this._$Em=e,null==a?this.removeAttribute(o):this.setAttribute(o,a),this._$Em=null}}_$AK(e,t){const n=this.constructor,o=n._$Eh.get(e);if(void 0!==o&&this._$Em!==o){const e=n.getPropertyOptions(o),a="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:v;this._$Em=o;const r=a.fromAttribute(t,e.type);this[o]=r??this._$Ej?.get(o)??r,this._$Em=null}}requestUpdate(e,t,n,o=!1,a){if(void 0!==e){const r=this.constructor;if(!1===o&&(a=this[e]),n??=r.getPropertyOptions(e),!((n.hasChanged??_)(a,t)||n.useDefault&&n.reflect&&a===this._$Ej?.get(e)&&!this.hasAttribute(r._$Eu(e,n))))return;this.C(e,t,n)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:n,reflect:o,wrapped:a},r){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,r??t??this[e]),!0!==a||void 0!==r)||(this._$AL.has(e)||(this.hasUpdated||n||(t=void 0),this._$AL.set(e,t)),!0===o&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,n]of e){const{wrapped:e}=n,o=this[t];!0!==e||this._$AL.has(t)||void 0===o||this.C(t,void 0,n,o)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};x.elementStyles=[],x.shadowRootOptions={mode:"open"},x[y("elementProperties")]=new Map,x[y("finalized")]=new Map,g?.({ReactiveElement:x}),(m.reactiveElementVersions??=[]).push("2.1.2"); + */Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let x=class extends HTMLElement{static addInitializer(e){this._$Ei(),(this.l??=[]).push(e)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(e,t=_){if(t.state&&(t.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(e)&&((t=Object.create(t)).wrapped=!0),this.elementProperties.set(e,t),!t.noAccessor){const o=Symbol(),a=this.getPropertyDescriptor(e,o,t);void 0!==a&&l(this.prototype,e,a)}}static getPropertyDescriptor(e,t,o){const{get:a,set:n}=c(this.prototype,e)??{get(){return this[t]},set(e){this[t]=e}};return{get:a,set(t){const r=a?.call(this);n?.call(this,t),this.requestUpdate(e,r,o)},configurable:!0,enumerable:!0}}static getPropertyOptions(e){return this.elementProperties.get(e)??_}static _$Ei(){if(this.hasOwnProperty(v("elementProperties")))return;const e=h(this);e.finalize(),void 0!==e.l&&(this.l=[...e.l]),this.elementProperties=new Map(e.elementProperties)}static finalize(){if(this.hasOwnProperty(v("finalized")))return;if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(v("properties"))){const e=this.properties,t=[...d(e),...u(e)];for(const o of t)this.createProperty(o,e[o])}const e=this[Symbol.metadata];if(null!==e){const t=litPropertyMetadata.get(e);if(void 0!==t)for(const[e,o]of t)this.elementProperties.set(e,o)}this._$Eh=new Map;for(const[t,o]of this.elementProperties){const e=this._$Eu(t,o);void 0!==e&&this._$Eh.set(e,t)}this.elementStyles=this.finalizeStyles(this.styles)}static finalizeStyles(e){const t=[];if(Array.isArray(e)){const o=new Set(e.flat(1/0).reverse());for(const e of o)t.unshift(i(e))}else void 0!==e&&t.push(i(e));return t}static _$Eu(e,t){const o=t.attribute;return!1===o?void 0:"string"==typeof o?o:"string"==typeof e?e.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){this._$ES=new Promise(e=>this.enableUpdating=e),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(e=>e(this))}addController(e){(this._$EO??=new Set).add(e),void 0!==this.renderRoot&&this.isConnected&&e.hostConnected?.()}removeController(e){this._$EO?.delete(e)}_$E_(){const e=new Map,t=this.constructor.elementProperties;for(const o of t.keys())this.hasOwnProperty(o)&&(e.set(o,this[o]),delete this[o]);e.size>0&&(this._$Ep=e)}createRenderRoot(){const o=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return((o,a)=>{if(t)o.adoptedStyleSheets=a.map(e=>e instanceof CSSStyleSheet?e:e.styleSheet);else for(const t of a){const a=document.createElement("style"),n=e.litNonce;void 0!==n&&a.setAttribute("nonce",n),a.textContent=t.cssText,o.appendChild(a)}})(o,this.constructor.elementStyles),o}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(!0),this._$EO?.forEach(e=>e.hostConnected?.())}enableUpdating(e){}disconnectedCallback(){this._$EO?.forEach(e=>e.hostDisconnected?.())}attributeChangedCallback(e,t,o){this._$AK(e,o)}_$ET(e,t){const o=this.constructor.elementProperties.get(e),a=this.constructor._$Eu(e,o);if(void 0!==a&&!0===o.reflect){const n=(void 0!==o.converter?.toAttribute?o.converter:y).toAttribute(t,o.type);this._$Em=e,null==n?this.removeAttribute(a):this.setAttribute(a,n),this._$Em=null}}_$AK(e,t){const o=this.constructor,a=o._$Eh.get(e);if(void 0!==a&&this._$Em!==a){const e=o.getPropertyOptions(a),n="function"==typeof e.converter?{fromAttribute:e.converter}:void 0!==e.converter?.fromAttribute?e.converter:y;this._$Em=a;const r=n.fromAttribute(t,e.type);this[a]=r??this._$Ej?.get(a)??r,this._$Em=null}}requestUpdate(e,t,o,a=!1,n){if(void 0!==e){const r=this.constructor;if(!1===a&&(n=this[e]),o??=r.getPropertyOptions(e),!((o.hasChanged??b)(n,t)||o.useDefault&&o.reflect&&n===this._$Ej?.get(e)&&!this.hasAttribute(r._$Eu(e,o))))return;this.C(e,t,o)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(e,t,{useDefault:o,reflect:a,wrapped:n},r){o&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,r??t??this[e]),!0!==n||void 0!==r)||(this._$AL.has(e)||(this.hasUpdated||o||(t=void 0),this._$AL.set(e,t)),!0===a&&this._$Em!==e&&(this._$Eq??=new Set).add(e))}async _$EP(){this.isUpdatePending=!0;try{await this._$ES}catch(e){Promise.reject(e)}const e=this.scheduleUpdate();return null!=e&&await e,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[e,t]of this._$Ep)this[e]=t;this._$Ep=void 0}const e=this.constructor.elementProperties;if(e.size>0)for(const[t,o]of e){const{wrapped:e}=o,a=this[t];!0!==e||this._$AL.has(t)||void 0===a||this.C(t,void 0,o,a)}}let e=!1;const t=this._$AL;try{e=this.shouldUpdate(t),e?(this.willUpdate(t),this._$EO?.forEach(e=>e.hostUpdate?.()),this.update(t)):this._$EM()}catch(t){throw e=!1,this._$EM(),t}e&&this._$AE(t)}willUpdate(e){}_$AE(e){this._$EO?.forEach(e=>e.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(e)),this.updated(e)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(e){return!0}update(e){this._$Eq&&=this._$Eq.forEach(e=>this._$ET(e,this[e])),this._$EM()}updated(e){}firstUpdated(e){}};x.elementStyles=[],x.shadowRootOptions={mode:"open"},x[v("elementProperties")]=new Map,x[v("finalized")]=new Map,g?.({ReactiveElement:x}),(p.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const $=globalThis,w=e=>e,k=$.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,A="$lit$",S=`lit$${Math.random().toFixed(9).slice(2)}$`,E="?"+S,C=`<${E}>`,z=document,L=()=>z.createComment(""),T=e=>null===e||"object"!=typeof e&&"function"!=typeof e,j=Array.isArray,O="[ \t\n\f\r]",N=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,U=/-->/g,F=/>/g,P=RegExp(`>|${O}(?:([^\\s"'>=/]+)(${O}*=${O}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),D=/'/g,H=/"/g,B=/^(?:script|style|textarea|title)$/i,I=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),R=I(1),V=I(2),q=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),Z=new WeakMap,Y=z.createTreeWalker(z,129);function G(e,t){if(!j(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==M?M.createHTML(t):t}class X{constructor({strings:e,_$litType$:t},n){let o;this.parts=[];let a=0,r=0;const i=e.length-1,s=this.parts,[l,c]=((e,t)=>{const n=e.length-1,o=[];let a,r=2===t?"":3===t?"":"",i=N;for(let s=0;s"===l[0]?(i=a??N,c=-1):void 0===l[1]?c=-2:(c=i.lastIndex-l[2].length,n=l[1],i=void 0===l[3]?P:'"'===l[3]?H:D):i===H||i===D?i=P:i===U||i===F?i=N:(i=P,a=void 0);const h=i===P&&e[s+1].startsWith("/>")?" ":"";r+=i===N?t+C:c>=0?(o.push(n),t.slice(0,c)+A+t.slice(c)+S+h):t+S+(-2===c?s:h)}return[G(e,r+(e[n]||"")+(2===t?"":3===t?"":"")),o]})(e,t);if(this.el=X.createElement(l,n),Y.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(o=Y.nextNode())&&s.length0){o.textContent=k?k.emptyScript:"";for(let n=0;nj(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==W&&T(this._$AH)?this._$AA.nextSibling.data=e:this.T(z.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:n}=e,o="number"==typeof n?this._$AC(e):(void 0===n.el&&(n.el=X.createElement(G(n.h,n.h[0]),this.options)),n);if(this._$AH?._$AD===o)this._$AH.p(t);else{const e=new K(o,this),n=e.u(this.options);e.p(t),this.T(n),this._$AH=e}}_$AC(e){let t=Z.get(e.strings);return void 0===t&&Z.set(e.strings,t=new X(e)),t}k(e){j(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let n,o=0;for(const a of e)o===t.length?t.push(n=new Q(this.O(L()),this.O(L()),this,this.options)):n=t[o],n._$AI(a),o++;o2||""!==n[0]||""!==n[1]?(this._$AH=Array(n.length-1).fill(new String),this.strings=n):this._$AH=W}_$AI(e,t=this,n,o){const a=this.strings;let r=!1;if(void 0===a)e=J(this,e,t,0),r=!T(e)||e!==this._$AH&&e!==q,r&&(this._$AH=e);else{const o=e;let i,s;for(e=a[0],i=0;ie,k=w.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,z="$lit$",A=`lit$${Math.random().toFixed(9).slice(2)}$`,S="?"+A,E=`<${S}>`,C=document,j=()=>C.createComment(""),N=e=>null===e||"object"!=typeof e&&"function"!=typeof e,L=Array.isArray,T="[ \t\n\f\r]",O=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,F=/-->/g,P=/>/g,D=RegExp(`>|${T}(?:([^\\s"'>=/]+)(${T}*=${T}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),U=/'/g,H=/"/g,I=/^(?:script|style|textarea|title)$/i,B=e=>(t,...o)=>({_$litType$:e,strings:t,values:o}),R=B(1),Z=B(2),q=Symbol.for("lit-noChange"),W=Symbol.for("lit-nothing"),V=new WeakMap,Y=C.createTreeWalker(C,129);function G(e,t){if(!L(e)||!e.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==M?M.createHTML(t):t}class X{constructor({strings:e,_$litType$:t},o){let a;this.parts=[];let n=0,r=0;const i=e.length-1,s=this.parts,[l,c]=((e,t)=>{const o=e.length-1,a=[];let n,r=2===t?"":3===t?"":"",i=O;for(let s=0;s"===l[0]?(i=n??O,c=-1):void 0===l[1]?c=-2:(c=i.lastIndex-l[2].length,o=l[1],i=void 0===l[3]?D:'"'===l[3]?H:U):i===H||i===U?i=D:i===F||i===P?i=O:(i=D,n=void 0);const u=i===D&&e[s+1].startsWith("/>")?" ":"";r+=i===O?t+E:c>=0?(a.push(o),t.slice(0,c)+z+t.slice(c)+A+u):t+A+(-2===c?s:u)}return[G(e,r+(e[o]||"")+(2===t?"":3===t?"":"")),a]})(e,t);if(this.el=X.createElement(l,o),Y.currentNode=this.el.content,2===t||3===t){const e=this.el.content.firstChild;e.replaceWith(...e.childNodes)}for(;null!==(a=Y.nextNode())&&s.length0){a.textContent=k?k.emptyScript:"";for(let o=0;oL(e)||"function"==typeof e?.[Symbol.iterator])(e)?this.k(e):this._(e)}O(e){return this._$AA.parentNode.insertBefore(e,this._$AB)}T(e){this._$AH!==e&&(this._$AR(),this._$AH=this.O(e))}_(e){this._$AH!==W&&N(this._$AH)?this._$AA.nextSibling.data=e:this.T(C.createTextNode(e)),this._$AH=e}$(e){const{values:t,_$litType$:o}=e,a="number"==typeof o?this._$AC(e):(void 0===o.el&&(o.el=X.createElement(G(o.h,o.h[0]),this.options)),o);if(this._$AH?._$AD===a)this._$AH.p(t);else{const e=new K(a,this),o=e.u(this.options);e.p(t),this.T(o),this._$AH=e}}_$AC(e){let t=V.get(e.strings);return void 0===t&&V.set(e.strings,t=new X(e)),t}k(e){L(this._$AH)||(this._$AH=[],this._$AR());const t=this._$AH;let o,a=0;for(const n of e)a===t.length?t.push(o=new Q(this.O(j()),this.O(j()),this,this.options)):o=t[a],o._$AI(n),a++;a2||""!==o[0]||""!==o[1]?(this._$AH=Array(o.length-1).fill(new String),this.strings=o):this._$AH=W}_$AI(e,t=this,o,a){const n=this.strings;let r=!1;if(void 0===n)e=J(this,e,t,0),r=!N(e)||e!==this._$AH&&e!==q,r&&(this._$AH=e);else{const a=e;let i,s;for(e=n[0],i=0;i{const o=n?.renderBefore??t;let a=o._$litPart$;if(void 0===a){const e=n?.renderBefore??null;o._$litPart$=a=new Q(t.insertBefore(L(),e),e,void 0,n??{})}return a._$AI(e),a})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return q}};le._$litElement$=!0,le.finalized=!0,se.litElementHydrateSupport?.({LitElement:le});const ce=se.litElementPolyfillSupport;ce?.({LitElement:le}),(se.litElementVersions??=[]).push("4.2.2"); + */let le=class extends x{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){const e=super.createRenderRoot();return this.renderOptions.renderBefore??=e.firstChild,e}update(e){const t=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(e),this._$Do=((e,t,o)=>{const a=o?.renderBefore??t;let n=a._$litPart$;if(void 0===n){const e=o?.renderBefore??null;a._$litPart$=n=new Q(t.insertBefore(j(),e),e,void 0,o??{})}return n._$AI(e),n})(t,this.renderRoot,this.renderOptions)}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(!0)}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(!1)}render(){return q}};le._$litElement$=!0,le.finalized=!0,se.litElementHydrateSupport?.({LitElement:le});const ce=se.litElementPolyfillSupport;ce?.({LitElement:le}),(se.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const de=1,he=2,ue=3,me=4,pe=e=>(...t)=>({_$litDirective$:e,values:t});let fe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,n){this._$Ct=e,this._$AM=t,this._$Ci=n}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}}; +const de=1,ue=2,he=3,pe=4,me=e=>(...t)=>({_$litDirective$:e,values:t});let fe=class{constructor(e){}get _$AU(){return this._$AM._$AU}_$AT(e,t,o){this._$Ct=e,this._$AM=t,this._$Ci=o}_$AS(e,t){return this.update(e,t)}update(e,t){return this.render(...t)}}; /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */const{I:ge}=re,ye=e=>e,ve=()=>document.createComment(""),_e=(e,t,n)=>{const o=e._$AA.parentNode,a=void 0===t?e._$AB:t._$AA;if(void 0===n){const t=o.insertBefore(ve(),a),r=o.insertBefore(ve(),a);n=new ge(t,r,e,e.options)}else{const t=n._$AB.nextSibling,r=n._$AM,i=r!==e;if(i){let t;n._$AQ?.(e),n._$AM=e,void 0!==n._$AP&&(t=e._$AU)!==r._$AU&&n._$AP(t)}if(t!==a||i){let e=n._$AA;for(;e!==t;){const t=ye(e).nextSibling;ye(o).insertBefore(e,a),e=t}}}return n},be=(e,t,n=e)=>(e._$AI(t,n),e),xe={},$e=(e,t=xe)=>e._$AH=t,we=e=>{e._$AR(),e._$AA.remove()},ke=(e,t,n)=>{const o=new Map;for(let a=t;a<=n;a++)o.set(e[a],a);return o},Me=pe(class extends fe{constructor(e){if(super(e),e.type!==he)throw Error("repeat() can only be used in text expressions")}dt(e,t,n){let o;void 0===n?n=t:void 0!==t&&(o=t);const a=[],r=[];let i=0;for(const s of e)a[i]=o?o(s,i):i,r[i]=n(s,i),i++;return{values:r,keys:a}}render(e,t,n){return this.dt(e,t,n).values}update(e,[t,n,o]){const a=(e=>e._$AH)(e),{values:r,keys:i}=this.dt(t,n,o);if(!Array.isArray(a))return this.ut=i,r;const s=this.ut??=[],l=[];let c,d,h=0,u=a.length-1,m=0,p=r.length-1;for(;h<=u&&m<=p;)if(null===a[h])h++;else if(null===a[u])u--;else if(s[h]===i[m])l[m]=be(a[h],r[m]),h++,m++;else if(s[u]===i[p])l[p]=be(a[u],r[p]),u--,p--;else if(s[h]===i[p])l[p]=be(a[h],r[p]),_e(e,l[p+1],a[h]),h++,p--;else if(s[u]===i[m])l[m]=be(a[u],r[m]),_e(e,a[h],a[u]),u--,m++;else if(void 0===c&&(c=ke(i,m,p),d=ke(s,h,u)),c.has(s[h]))if(c.has(s[u])){const t=d.get(i[m]),n=void 0!==t?a[t]:null;if(null===n){const t=_e(e,a[h]);be(t,r[m]),l[m]=t}else l[m]=be(n,r[m]),_e(e,a[h],n),a[t]=null;m++}else we(a[u]),u--;else we(a[h]),h++;for(;m<=p;){const t=_e(e,l[p+1]);be(t,r[m]),l[m++]=t}for(;h<=u;){const e=a[h++];null!==e&&we(e)}return this.ut=i,$e(e,l),q}}),Ae=e=>!!e&&"none"!==e.action; + */const{I:ge}=re,ve=e=>e,ye=()=>document.createComment(""),be=(e,t,o)=>{const a=e._$AA.parentNode,n=void 0===t?e._$AB:t._$AA;if(void 0===o){const t=a.insertBefore(ye(),n),r=a.insertBefore(ye(),n);o=new ge(t,r,e,e.options)}else{const t=o._$AB.nextSibling,r=o._$AM,i=r!==e;if(i){let t;o._$AQ?.(e),o._$AM=e,void 0!==o._$AP&&(t=e._$AU)!==r._$AU&&o._$AP(t)}if(t!==n||i){let e=o._$AA;for(;e!==t;){const t=ve(e).nextSibling;ve(a).insertBefore(e,n),e=t}}}return o},_e=(e,t,o=e)=>(e._$AI(t,o),e),xe={},we=(e,t=xe)=>e._$AH=t,$e=e=>{e._$AR(),e._$AA.remove()},ke=(e,t,o)=>{const a=new Map;for(let n=t;n<=o;n++)a.set(e[n],n);return a},Me=me(class extends fe{constructor(e){if(super(e),e.type!==ue)throw Error("repeat() can only be used in text expressions")}dt(e,t,o){let a;void 0===o?o=t:void 0!==t&&(a=t);const n=[],r=[];let i=0;for(const s of e)n[i]=a?a(s,i):i,r[i]=o(s,i),i++;return{values:r,keys:n}}render(e,t,o){return this.dt(e,t,o).values}update(e,[t,o,a]){const n=(e=>e._$AH)(e),{values:r,keys:i}=this.dt(t,o,a);if(!Array.isArray(n))return this.ut=i,r;const s=this.ut??=[],l=[];let c,d,u=0,h=n.length-1,p=0,m=r.length-1;for(;u<=h&&p<=m;)if(null===n[u])u++;else if(null===n[h])h--;else if(s[u]===i[p])l[p]=_e(n[u],r[p]),u++,p++;else if(s[h]===i[m])l[m]=_e(n[h],r[m]),h--,m--;else if(s[u]===i[m])l[m]=_e(n[u],r[m]),be(e,l[m+1],n[u]),u++,m--;else if(s[h]===i[p])l[p]=_e(n[h],r[p]),be(e,n[u],n[h]),h--,p++;else if(void 0===c&&(c=ke(i,p,m),d=ke(s,u,h)),c.has(s[u]))if(c.has(s[h])){const t=d.get(i[p]),o=void 0!==t?n[t]:null;if(null===o){const t=be(e,n[u]);_e(t,r[p]),l[p]=t}else l[p]=_e(o,r[p]),be(e,n[u],o),n[t]=null;p++}else $e(n[h]),h--;else $e(n[u]),u++;for(;p<=m;){const t=be(e,l[m+1]);_e(t,r[p]),l[p++]=t}for(;u<=h;){const e=n[u++];null!==e&&$e(e)}return this.ut=i,we(e,l),q}}),ze=e=>!!e&&"none"!==e.action; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Se(e){return Ae(e.tap_action)||Ae(e.hold_action)||Ae(e.double_tap_action)}function Ee(e,t){let n,o,a=0,r=0,i=!1,s=!1;const l=n=>{const o=t(),a="tap"===n?o.tap_action:"hold"===n?o.hold_action:o.double_tap_action;Ae(a)&&e.dispatchEvent(new CustomEvent("hass-action",{bubbles:!0,composed:!0,detail:{config:o,action:n}}))},c=()=>{void 0!==n&&clearTimeout(n),n=void 0},d=e=>{const o=e;o.button>0||(i=!0,s=!1,a=o.clientX,r=o.clientY,c(),Ae(t().hold_action)&&(n=setTimeout(()=>{n=void 0,s=!0,l("hold")},500)))},h=e=>{if(!i)return;const t=e;Math.hypot(t.clientX-a,t.clientY-r)>10&&(c(),i=!1)},u=()=>{c(),i=!1},m=()=>{const e=i;c(),i=!1,e&&!s?Ae(t().double_tap_action)?void 0!==o?(clearTimeout(o),o=void 0,l("double_tap")):o=setTimeout(()=>{o=void 0,l("tap")},250):l("tap"):s=!1},p=e=>{const t=e;t.repeat||"Enter"!==t.key&&" "!==t.key||(" "===t.key&&t.preventDefault(),l("tap"))},f=e=>{Ae(t().hold_action)&&e.preventDefault()};return e.addEventListener("pointerdown",d),e.addEventListener("pointermove",h),e.addEventListener("pointerup",m),e.addEventListener("pointercancel",u),e.addEventListener("pointerleave",u),e.addEventListener("keydown",p),e.addEventListener("contextmenu",f),()=>{c(),void 0!==o&&clearTimeout(o),o=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",h),e.removeEventListener("pointerup",m),e.removeEventListener("pointercancel",u),e.removeEventListener("pointerleave",u),e.removeEventListener("keydown",p),e.removeEventListener("contextmenu",f)}}const Ce={primary:{variable:"--primary-color",fallback:"#2f6fe4"},green:{variable:"--success-color",fallback:"#1fae7a"},amber:{variable:"--warning-color",fallback:"#f59e0b"},red:{variable:"--error-color",fallback:"#ef3b5a"},blue:{variable:"--info-color",fallback:"#2f6fe4"},purple:{variable:"--purple-color",fallback:"#8b6cff"},grey:{variable:"--grey-color",fallback:"#8e919a"}};function ze(e){if(Object.prototype.hasOwnProperty.call(Ce,e)){const{variable:t,fallback:n}=Ce[e];return`var(${t}, ${n})`}return e}const Le=new Set(["amber"]);function Te(e){if(Object.prototype.hasOwnProperty.call(Ce,e))return Le.has(e)?"dark":"light";const t=function(e){const t=e.trim().toLowerCase(),n=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(t);if(n){const e=n[1].length<=4?[...n[1]].map(e=>e+e).join(""):n[1],[t,o,a]=[0,2,4].map(t=>parseInt(e.slice(t,t+2),16));return[t,o,a,8===e.length?parseInt(e.slice(6,8),16)/255:1]}const o=/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(t);if(o){const[e,t,n]=[o[1],o[2],o[3]].map(e=>Math.min(255,Number(e))),a=void 0===o[4]?1:Number(o[4])/(o[5]?100:1);return[e,t,n,Math.min(1,a)]}}(e);return t?t[3]<.5?"theme":function([e,t,n]){const o=e=>{const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4};return.2126*o(e)+.7152*o(t)+.0722*o(n)}([t[0],t[1],t[2]])>.4?"dark":"light":"light"}function je(e,t){let n=t[0];for(const o of t){if(!(o.from<=e))break;n=o}return n}function Oe(e,t,n){const o=n?` ${n}`:"",a=[];return e.forEach((n,r)=>{const i=e[r+1],s=ze(n.color);n.label?a.push({color:s,text:n.label}):Number.isFinite(n.from)?i?a.push({color:s,text:`${t(n.from)}–${t(i.from)}${o}`}):a.push({color:s,text:`${t(n.from)}${o}+`}):i&&a.push({color:s,text:`< ${t(i.from)}${o}`})}),a}const Ne={battery:[{from:0,color:"red",label:"Low"},{from:20,color:"amber"},{from:50,color:"green"}],usage:[{from:0,color:"green"},{from:60,color:"amber"},{from:85,color:"red",label:"High"}],temperature:[{from:-50,color:"blue",label:"Cold"},{from:16,color:"green"},{from:24,color:"amber"},{from:28,color:"red",label:"Hot"}],none:void 0},Ue=["label","area","floor","device_class","domain","entity"];function Fe(e,t){if(null==e)return;if("object"!=typeof e||Array.isArray(e))throw new Error(`"${t}" must list what to match, such as "area" or "device_class"`);const n="include"===t?Ue.filter(e=>"entity"!==e):Ue,o={};for(const[a,r]of Object.entries(e)){if(!n.includes(a))throw new Error(`"${t}" can't match on "${a}". Use: ${n.join(", ")}`);const e=(Array.isArray(r)?r:[r]).filter(e=>null!=e&&""!==e);if(e.some(e=>"string"!=typeof e))throw new Error(`"${t}.${a}" must be text or a list of text`);e.length&&(o[a]=e)}return Object.keys(o).length?o:void 0}function Pe(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function De(e,t){const n=e.entity_id,o=t.entities?.[n],a=o?.device_id?t.devices?.[o.device_id]:void 0,r=o?.area_id??a?.area_id??void 0,i=e.attributes?.device_class;return{label:[...o?.labels??[],...a?.labels??[]],area:r,floor:r?t.areas?.[r]?.floor_id??void 0:void 0,device_class:"string"==typeof i?i:void 0,domain:n.slice(0,n.indexOf(".")),entity:n}}function He(e,t,n){const o=e[t],a=n[t];return Array.isArray(a)?a.some(e=>o.includes(e)):void 0!==a&&o.includes(a)}function Be(e,t){return Object.keys(e).every(n=>He(e,n,t))}function Ie(e,t){return!!e&&Object.keys(e).some(n=>He(e,n,t))}const Re=new WeakMap;const Ve=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle","histogram","scatter","bubble","heatmap","box"],qe=[{from:-1/0,color:"primary"}],We=["none","ascending","descending"];function Ze(e){if("number"==typeof e)return Number.isFinite(e)?e:void 0;if("string"==typeof e&&""!==e.trim()){const t=Number(e);return Number.isFinite(t)?t:void 0}}function Ye(e,t){if(null==e||""===e)return;const n=Ze(e);if(void 0!==n)return n;if("string"==typeof e)return e;throw new Error(`"${t}" must be a number or an entity id`)}function Ge(e,t,n,o=1/0){if(null==e||""===e)return;const a=Ze(e);if(void 0===a||!Number.isInteger(a)||ao){throw new Error(`"${t}" must be a whole number ${o===1/0?`of at least ${n}`:`from ${n} to ${o}`}`)}return a}function Xe(e){if(null==e)return;if(!Array.isArray(e))throw new Error('"severity" must be a list of bands');if(0===e.length)return;const t=e.map((e,t)=>{let n;try{n=Ye(e?.from,"from")}catch{n=void 0}if(void 0===n)throw new Error(`Severity band ${t+1}: "from" must be a number or an entity id`);if("string"!=typeof e.color||!e.color)throw new Error(`Severity band ${t+1}: "color" is required`);return{...e,from:n}});return t.every(e=>"number"==typeof e.from)?t.sort((e,t)=>e.from-t.from):t}function Je(e,t,n){if(null==e)return{action:t};if("object"!=typeof e||"string"!=typeof e.action)throw new Error(`"${n}" must have an "action"`);return e}function Ke(e,t){return void 0!==e?e:t}function Qe(e,t,n){const o="string"==typeof e?{entity:e}:e;if(!o||"object"!=typeof o||"string"!=typeof o.entity||!o.entity)throw new Error(`Entity ${n+1} needs an "entity" id`);if(void 0!==o.attribute&&null!==o.attribute&&"string"!=typeof o.attribute)throw new Error(`Entity ${n+1}: "attribute" must be an attribute name`);const a=Ke(Ye(o.min,"min"),Ye(t.min,"min"))??0,r=Ke(Ye(o.max,"max"),Ye(t.max,"max"))??100;if("number"==typeof a&&"number"==typeof r&&a>=r)throw new Error(`"max" (${r}) must be greater than "min" (${a})`);const i={...o,entity:o.entity,min:a,max:r,severity:Xe(o.severity)??Xe(t.severity)??qe,tap_action:Je(Ke(o.tap_action,t.tap_action),"more-info","tap_action"),hold_action:Je(Ke(o.hold_action,t.hold_action),"none","hold_action"),double_tap_action:Je(Ke(o.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=Ke(o.name,t.name),l=Ke(o.icon,t.icon),c=Ke(o.unit,t.unit),d=Ke(Ye(o.target,"target"),Ye(t.target,"target")),h=Ke(Ge(o.decimals,"decimals",0,20),Ge(t.decimals,"decimals",0,20));return void 0!==s&&(i.name=s),void 0!==l&&(i.icon=l),void 0!==c&&(i.unit=c),void 0!==d&&(i.target=d),void 0!==h&&(i.decimals=h),o.attribute||delete i.attribute,i} + */function Ae(e){return ze(e.tap_action)||ze(e.hold_action)||ze(e.double_tap_action)}function Se(e,t){let o,a,n=0,r=0,i=!1,s=!1;const l=o=>{const a=t(),n="tap"===o?a.tap_action:"hold"===o?a.hold_action:a.double_tap_action;ze(n)&&e.dispatchEvent(new CustomEvent("hass-action",{bubbles:!0,composed:!0,detail:{config:a,action:o}}))},c=()=>{void 0!==o&&clearTimeout(o),o=void 0},d=e=>{const a=e;a.button>0||(i=!0,s=!1,n=a.clientX,r=a.clientY,c(),ze(t().hold_action)&&(o=setTimeout(()=>{o=void 0,s=!0,l("hold")},500)))},u=e=>{if(!i)return;const t=e;Math.hypot(t.clientX-n,t.clientY-r)>10&&(c(),i=!1)},h=()=>{c(),i=!1},p=()=>{const e=i;c(),i=!1,e&&!s?ze(t().double_tap_action)?void 0!==a?(clearTimeout(a),a=void 0,l("double_tap")):a=setTimeout(()=>{a=void 0,l("tap")},250):l("tap"):s=!1},m=e=>{const t=e;t.repeat||"Enter"!==t.key&&" "!==t.key||(" "===t.key&&t.preventDefault(),l("tap"))},f=e=>{ze(t().hold_action)&&e.preventDefault()};return e.addEventListener("pointerdown",d),e.addEventListener("pointermove",u),e.addEventListener("pointerup",p),e.addEventListener("pointercancel",h),e.addEventListener("pointerleave",h),e.addEventListener("keydown",m),e.addEventListener("contextmenu",f),()=>{c(),void 0!==a&&clearTimeout(a),a=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",u),e.removeEventListener("pointerup",p),e.removeEventListener("pointercancel",h),e.removeEventListener("pointerleave",h),e.removeEventListener("keydown",m),e.removeEventListener("contextmenu",f)}}const Ee={primary:{variable:"--primary-color",fallback:"#2f6fe4"},green:{variable:"--success-color",fallback:"#1fae7a"},amber:{variable:"--warning-color",fallback:"#f59e0b"},red:{variable:"--error-color",fallback:"#ef3b5a"},blue:{variable:"--info-color",fallback:"#2f6fe4"},purple:{variable:"--purple-color",fallback:"#8b6cff"},grey:{variable:"--grey-color",fallback:"#8e919a"}};function Ce(e){if(Object.prototype.hasOwnProperty.call(Ee,e)){const{variable:t,fallback:o}=Ee[e];return`var(${t}, ${o})`}return e}function je(e){const t=Ce(e);return{start:t,end:`color-mix(in srgb, ${t} 80%, #fff)`}}const Ne=new Set(["amber"]);function Le(e){if(Object.prototype.hasOwnProperty.call(Ee,e))return Ne.has(e)?"dark":"light";const t=function(e){const t=e.trim().toLowerCase(),o=/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(t);if(o){const e=o[1].length<=4?[...o[1]].map(e=>e+e).join(""):o[1],[t,a,n]=[0,2,4].map(t=>parseInt(e.slice(t,t+2),16));return[t,a,n,8===e.length?parseInt(e.slice(6,8),16)/255:1]}const a=/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+)(%?)\s*)?\)$/.exec(t);if(a){const[e,t,o]=[a[1],a[2],a[3]].map(e=>Math.min(255,Number(e))),n=void 0===a[4]?1:Number(a[4])/(a[5]?100:1);return[e,t,o,Math.min(1,n)]}}(e);return t?t[3]<.5?"theme":function([e,t,o]){const a=e=>{const t=e/255;return t<=.03928?t/12.92:((t+.055)/1.055)**2.4};return.2126*a(e)+.7152*a(t)+.0722*a(o)}([t[0],t[1],t[2]])>.4?"dark":"light":"light"}function Te(e,t){let o=t[0];for(const a of t){if(!(a.from<=e))break;o=a}return o}function Oe(e,t,o){const a=o?` ${o}`:"",n=[];return e.forEach((o,r)=>{const i=e[r+1],s=Ce(o.color);o.label?n.push({color:s,text:o.label}):Number.isFinite(o.from)?i?n.push({color:s,text:`${t(o.from)}–${t(i.from)}${a}`}):n.push({color:s,text:`${t(o.from)}${a}+`}):i&&n.push({color:s,text:`< ${t(i.from)}${a}`})}),n}const Fe={battery:[{from:0,color:"red",label:"Low"},{from:20,color:"amber"},{from:50,color:"green"}],usage:[{from:0,color:"green"},{from:60,color:"amber"},{from:85,color:"red",label:"High"}],temperature:[{from:-50,color:"blue",label:"Cold"},{from:16,color:"green"},{from:24,color:"amber"},{from:28,color:"red",label:"Hot"}],none:void 0},Pe=["label","area","floor","device_class","domain","entity"];function De(e,t){if(null==e)return;if("object"!=typeof e||Array.isArray(e))throw new Error(`"${t}" must list what to match, such as "area" or "device_class"`);const o="include"===t?Pe.filter(e=>"entity"!==e):Pe,a={};for(const[n,r]of Object.entries(e)){if(!o.includes(n))throw new Error(`"${t}" can't match on "${n}". Use: ${o.join(", ")}`);const e=(Array.isArray(r)?r:[r]).filter(e=>null!=e&&""!==e);if(e.some(e=>"string"!=typeof e))throw new Error(`"${t}.${n}" must be text or a list of text`);e.length&&(a[n]=e)}return Object.keys(a).length?a:void 0}function Ue(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function He(e,t){const o=e.entity_id,a=t.entities?.[o],n=a?.device_id?t.devices?.[a.device_id]:void 0,r=a?.area_id??n?.area_id??void 0,i=e.attributes?.device_class;return{label:[...a?.labels??[],...n?.labels??[]],area:r,floor:r?t.areas?.[r]?.floor_id??void 0:void 0,device_class:"string"==typeof i?i:void 0,domain:o.slice(0,o.indexOf(".")),entity:o}}function Ie(e,t,o){const a=e[t],n=o[t];return Array.isArray(n)?n.some(e=>a.includes(e)):void 0!==n&&a.includes(n)}function Be(e,t){return Object.keys(e).every(o=>Ie(e,o,t))}function Re(e,t){return!!e&&Object.keys(e).some(o=>Ie(e,o,t))}const Ze=new WeakMap;const qe=e=>(Math.abs(e)<.005?0:e).toFixed(2);function We(e,t,o,a){const n=a*Math.PI/180;return[e+o*Math.sin(n),t-o*Math.cos(n)]}function Ve(e,t,o,a,n){const r=String(Number(o.toFixed(2))),i=n-a,[s,l]=We(e,t,o,a);if(i>=359.99){const[n,i]=We(e,t,o,a+180);return`M${qe(s)} ${qe(l)} A${r} ${r} 0 1 1 ${qe(n)} ${qe(i)} A${r} ${r} 0 1 1 ${qe(s)} ${qe(l)}`}const[c,d]=We(e,t,o,n),u=i>180?1:0;return`M${qe(s)} ${qe(l)} A${r} ${r} 0 ${u} 1 ${qe(c)} ${qe(d)}`}const Ye=["value","band","name","text","none"],Ge={ring:[-180,180],arc:[-135,135],needle:[-90,90]};function Xe(e,t){if(null==e||""===e)return;const o="string"==typeof e&&""!==e.trim()?Number(e):e;if("number"!=typeof o||!Number.isFinite(o)||o<-360||o>360)throw new Error(`"${t}" must be a number of degrees from -360 to 360`);return o}function Je(e,t){return[t?.startAngle??e[0],t?.endAngle??e[1]]}function Ke(e){return void 0!==e?.startAngle||void 0!==e?.endAngle}function Qe([e,t],o){return e+(t-e)*o}function et(...e){return{x0:Math.min(...e.map(e=>e.x0)),y0:Math.min(...e.map(e=>e.y0)),x1:Math.max(...e.map(e=>e.x1)),y1:Math.max(...e.map(e=>e.y1))}}function tt(e,t,o,a,n,r){const i=[a,n];for(let h=Math.ceil(a/90);90*h<=n;h++)i.push(90*h);const s=i.map(a=>We(e,t,o,a)),l=Math.min(...s.map(e=>e[0])),c=Math.min(...s.map(e=>e[1])),d=Math.max(...s.map(e=>e[0])),u=Math.max(...s.map(e=>e[1]));return{x0:l-r,y0:c-r,x1:d+r,y1:u+r}}function ot(e,t=5){if(!Number.isFinite(e)||e<=0)return 1;const o=e/t,a=10**Math.floor(Math.log10(o)),n=o/a;return(n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*a}function at(e,t){const o=e=>{const t=String(Number(e.toPrecision(12)));return t.includes("e")?0:t.split(".")[1]?.length??0};return Math.min(3,Math.max(o(t),o(e)))}function nt(e){const t=e*Math.PI/180,o=Math.sin(t),a=Math.cos(t);return{anchor:o>.3?"start":o<-.3?"end":"middle",dy:a>.3?0:a<-.3?.75:.35}}function rt(e,t){switch(t?.center??"value"){case"band":return e.band.label?{kind:"text",text:e.band.label,band:!0}:{kind:"value"};case"name":return{kind:"text",text:e.name,band:!1};case"text":return t.centerText?{kind:"text",text:t.centerText,band:!1}:{kind:"none"};case"none":return{kind:"none"};default:return{kind:"value"}}}const it=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle","histogram","scatter","bubble","heatmap","box","funnel","radar","polar_area","treemap","sunburst","icicle","grouped_bars","bullet","lollipop","waterfall","slope","dumbbell","range_area","sparkline","streamgraph","candlestick","violin","beeswarm","sankey","timeline","climate","wind_rose","day_clock","trend"],st=[{from:-1/0,color:"primary"}],lt=["none","ascending","descending"];function ct(e){if("number"==typeof e)return Number.isFinite(e)?e:void 0;if("string"==typeof e&&""!==e.trim()){const t=Number(e);return Number.isFinite(t)?t:void 0}}function dt(e,t){if(null==e||""===e)return;const o=ct(e);if(void 0!==o)return o;if("string"==typeof e)return e;throw new Error(`"${t}" must be a number or an entity id`)}function ut(e,t,o,a=1/0){if(null==e||""===e)return;const n=ct(e);if(void 0===n||!Number.isInteger(n)||na){throw new Error(`"${t}" must be a whole number ${a===1/0?`of at least ${o}`:`from ${o} to ${a}`}`)}return n}function ht(e){if(null==e)return;if(!Array.isArray(e))throw new Error('"severity" must be a list of bands');if(0===e.length)return;const t=e.map((e,t)=>{let o;try{o=dt(e?.from,"from")}catch{o=void 0}if(void 0===o)throw new Error(`Severity band ${t+1}: "from" must be a number or an entity id`);if("string"!=typeof e.color||!e.color)throw new Error(`Severity band ${t+1}: "color" is required`);return{...e,from:o}});return t.every(e=>"number"==typeof e.from)?t.sort((e,t)=>e.from-t.from):t}function pt(e,t,o){if(null==e)return{action:t};if("object"!=typeof e||"string"!=typeof e.action)throw new Error(`"${o}" must have an "action"`);return e}function mt(e,t){return void 0!==e?e:t}function ft(e,t,o){const a="string"==typeof e?{entity:e}:e;if(!a||"object"!=typeof a||"string"!=typeof a.entity||!a.entity)throw new Error(`Entity ${o+1} needs an "entity" id`);if(void 0!==a.attribute&&null!==a.attribute&&"string"!=typeof a.attribute)throw new Error(`Entity ${o+1}: "attribute" must be an attribute name`);const n=mt(dt(a.min,"min"),dt(t.min,"min"))??0,r=mt(dt(a.max,"max"),dt(t.max,"max"))??100;if("number"==typeof n&&"number"==typeof r&&n>=r)throw new Error(`"max" (${r}) must be greater than "min" (${n})`);const i={...a,entity:a.entity,min:n,max:r,severity:ht(a.severity)??ht(t.severity)??st,tap_action:pt(mt(a.tap_action,t.tap_action),"more-info","tap_action"),hold_action:pt(mt(a.hold_action,t.hold_action),"none","hold_action"),double_tap_action:pt(mt(a.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=mt(a.name,t.name),l=mt(a.icon,t.icon),c=mt(a.unit,t.unit),d=mt(dt(a.target,"target"),dt(t.target,"target")),u=mt(ut(a.decimals,"decimals",0,20),ut(t.decimals,"decimals",0,20));return void 0!==s&&(i.name=s),void 0!==l&&(i.icon=l),void 0!==c&&(i.unit=c),void 0!==d&&(i.target=d),void 0!==u&&(i.decimals=u),a.attribute||delete i.attribute,Ge[t.style??"bar"]&&(i.gauge=function(e,t=e.style??"bar"){const o=Xe(e.start_angle,"start_angle"),a=Xe(e.end_angle,"end_angle"),n=Ge[t];if(n&&(void 0!==o||void 0!==a)){const[e,t]=[o??n[0],a??n[1]];if(!(t>e))throw new Error(`"end_angle" (${t}) must be greater than "start_angle" (${e})`);if(t-e>360)throw new Error('"end_angle" can be at most 360 degrees after "start_angle"')}const r=e.center??"value";if(!Ye.includes(r))throw new Error(`"center" must be one of: ${Ye.join(", ")}`);if(void 0!==e.center_text&&null!==e.center_text&&"string"!=typeof e.center_text)throw new Error('"center_text" must be text');if(void 0!==e.tick_labels&&"boolean"!=typeof e.tick_labels)throw new Error('"tick_labels" must be true or false');const i={center:r,centerText:e.center_text??"",tickLabels:!0===e.tick_labels};return void 0!==o&&(i.startAngle=o),void 0!==a&&(i.endAngle=a),i}(t)),i} /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de&&e.type!==me)throw Error("The `live` directive is not allowed on child or event bindings");if(!(e=>void 0===e.strings)(e))throw Error("`live` bindings can only contain a single expression")}render(e){return e}update(e,[t]){if(t===q||t===W)return t;const n=e.element,o=e.name;if(e.type===ue){if(t===n[o])return q}else if(e.type===me){if(!!t===n.hasAttribute(o))return q}else if(e.type===de&&n.getAttribute(o)===t+"")return q;return $e(e),t}}),tt={en:{entity_not_found:"Entity not found",unavailable:"Unavailable",no_entities:"Add an entity in the editor to get started.",no_matches:"No entities match right now.",none_available:"None of these entities are available.",editor_section_entities:"Entities",editor_section_style:"Style",editor_section_chart:"Chart",editor_group_bars:"Bars",editor_group_gauges:"Gauges",editor_group_history:"History charts",editor_group_totals:"Parts of a total",editor_group_compare:"Comparisons",editor_group_distribution:"Distributions",editor_period_last:"Show the last",editor_period_unit:"Unit",editor_period_unit_hour:"Hours",editor_period_unit_day:"Days",editor_period_unit_week:"Weeks",editor_period_unit_month:"Months",editor_source:"Readings from",editor_source_history:"History (every change)",editor_source_statistics:"Long-term statistics",editor_curve:"Line shape",editor_curve_smooth:"Smooth",editor_curve_straight:"Straight",editor_curve_step:"Steps",editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_labels:"Show values on the points",editor_series_color:"Line colour",editor_dash:"Dashed",editor_section_limits:"Limits",editor_section_colours:"Colours",editor_section_display:"Display",editor_section_actions:"Actions",editor_title:"Title",editor_style_bar:"Bar",editor_style_thin:"Thin",editor_style_segmented:"Segmented",editor_style_tank:"Tank",editor_style_ring:"Ring",editor_style_needle:"Needle",editor_style_arc:"Arc",editor_show_name:"Show name",editor_show_value:"Show value",editor_show_icon:"Show icon",editor_show_target:"Show target",editor_show_legend:"Show legend",editor_columns:"Columns",editor_sort:"Sort",editor_sort_none:"As listed",editor_sort_ascending:"Lowest first",editor_sort_descending:"Highest first",editor_decimals:"Decimals",editor_unit:"Unit",editor_animate:"Animate",editor_hide_unavailable:"Hide unavailable entities",editor_limit:"Show at most",editor_include_heading:"Also show every entity that matches",editor_include_help:"New devices appear without editing the card. Pick several of one kind to match any of them; different kinds must all match.",editor_exclude_heading:"Leave out",editor_exclude_help:"Matched entities are left out if they match any of these.",editor_rule_device_class:"Device class",editor_rule_area:"Areas",editor_rule_floor:"Floors",editor_rule_label:"Labels",editor_rule_domain:"Domains",editor_rule_entity:"Entities",editor_tap_action:"Tap action",editor_hold_action:"Hold action",editor_double_tap_action:"Double tap action",editor_attribute:"Attribute (instead of the state)",editor_name:"Name",editor_icon:"Icon",editor_min:"Minimum",editor_max:"Maximum",editor_target:"Target",editor_none:"None",editor_card_setting:"card setting",editor_limit_default:"Default ({value})",editor_limit_number:"Number",editor_limit_entity:"Entity",editor_preset:"Preset",editor_preset_battery:"Battery (low is bad)",editor_preset_usage:"Usage (high is bad)",editor_preset_temperature:"Temperature",editor_preset_none:"None",editor_preset_custom:"Custom",editor_band_from:"From",editor_band_from_entity:"Start from an entity",editor_band_from_number:"Start from a number",editor_band_from_entity_label:"Starts at this entity",editor_band_color:"Colour",editor_band_custom_color:"CSS colour",editor_band_label:"Label",editor_band_icon:"Icon",editor_color_icon:"Colour icons to match their band",editor_color_primary:"Primary",editor_color_green:"Green",editor_color_amber:"Amber",editor_color_red:"Red",editor_color_blue:"Blue",editor_color_purple:"Purple",editor_color_grey:"Grey",editor_color_custom:"Custom…",editor_add_band:"Add band",editor_remove_band:"Remove band",editor_add_entity:"Add entity",editor_move_up:"Move up",editor_move_down:"Move down",editor_remove_entity:"Remove entity",editor_show_options:"Show options",editor_hide_options:"Hide options",editor_not_found:"{entity} (not found)",editor_severity:"Colours"}};function nt(e,t){tt[e]={...tt[e],...t}}function ot(e){return(e?.locale?.language??e?.language??"en").split("-")[0]}function at(e,t,n){const o=ot(e),a=tt[o]?.[t]??tt.en[t]??t;return n?a.replace(/\{(\w+)\}/g,(e,t)=>t in n?String(n[t]):e):a}function rt(e){return null==e||""===e}function it(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function st(e,t,n){const[o,...a]=t,r=function(e,t){return Array.isArray(e)?[...e]:it(e)?{...e}:"number"==typeof t?[]:{}}(e,o),i=r,s=a.length?st(i[o],a,n):n,l=a.length>0&&it(s)&&0===Object.keys(s).length;return rt(s)?Array.isArray(r)&&"number"==typeof o?r.splice(o,1):delete i[o]:l&&it(r)?delete i[o]:i[o]=s,r}function lt(e,t,n){return 0===t.length||rt(n)&&void 0===function(e,t){let n=e;for(const o of t){if("object"!=typeof n||null===n)return;n=n[o]}return n}(e,t)?e:st(e,t,n)}function ct(e,t){const n=e;return{width:n.line_width??t.line_width??2.5,dots:n.dots??!0===t.dots,onTop:!0===n.on_top}}function dt(e,t){return[...e.filter(e=>!t(e)),...e.filter(t)]}function ht(e){return e.map(([e,t])=>`M${Math.round(100*e)/100},${Math.round(100*t)/100}h0`).join("")}function ut(e){return Math.round(10*(e+3.5))/10}function mt(e,t){if(void 0!==e&&("number"!=typeof e||!Number.isFinite(e)||e<.5||e>12))throw new Error(`${t} must be a number from 0.5 to 12`)}function pt(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`${t} must be true or false`)}function ft(e){mt(e.line_width,'"line_width"'),pt(e.dots,'"dots"');for(const t of e.entities??[])"object"==typeof t&&(mt(t.line_width,`"line_width" of ${t.entity}`),pt(t.dots,`"dots" of ${t.entity}`),pt(t.on_top,`"on_top" of ${t.entity}`))}const gt=[{name:"title",path:["title"]}],yt=[{name:"show_name",path:["show","name"],default:!0},{name:"show_value",path:["show","value"],default:!0},{name:"show_icon",path:["show","icon"],default:!0},{name:"show_target",path:["show","target"],default:!0},{name:"show_legend",path:["show","legend"],default:!1},{name:"animate",path:["animate"],default:!0},{name:"hide_unavailable",path:["hide_unavailable"],default:!1}],vt=[{name:"color_icon",path:["color_icon"],default:!1}],_t=[{name:"columns",path:["columns"]},{name:"sort",path:["sort"],default:"none"},{name:"limit",path:["limit"]},{name:"decimals",path:["decimals"]},{name:"unit",path:["unit"]}],bt=[{name:"tap_action",path:["tap_action"],default:{action:"more-info"}},{name:"hold_action",path:["hold_action"],default:{action:"none"}},{name:"double_tap_action",path:["double_tap_action"],default:{action:"none"}}],xt=[{name:"name",path:["name"]},{name:"unit",path:["unit"]},{name:"decimals",path:["decimals"]}],$t=[{name:"attribute",path:["attribute"]}],wt=[{name:"series_color",path:["color"]},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"on_top",path:["on_top"],default:!1}],kt={number:{min:.5,max:12,step:.5,mode:"box",unit_of_measurement:"px"}},Mt=new Map;function At(e){let t=Mt.get(e);return t||(t=[...wt,{name:"dots",path:["dots"],default:e}],Mt.set(e,t)),t}const St=bt.map(({name:e,path:t})=>({name:e,path:t})),Et=[{name:"title",selector:{text:{}}}],Ct=bt.map(({name:e})=>({name:e,selector:{ui_action:{}}})),zt=[{type:"grid",name:"",schema:[{name:"name",selector:{text:{}}},{name:"unit",selector:{text:{}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}}]}],Lt=new Map;let Tt;function jt(e){const t=ot(e);if(Tt?.language!==t){const n=t=>at(e,t);Tt={language:t,schema:[{type:"grid",name:"",schema:[{name:"series_color",selector:{text:{}}},{name:"curve",selector:{select:{mode:"dropdown",options:["smooth","straight","step"].map(e=>({value:e,label:n(`editor_curve_${e}`)}))}}}]},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dash",selector:{boolean:{}}},{name:"dots",selector:{boolean:{}}},{name:"on_top",selector:{boolean:{}}}]}]}}return Tt.schema}function Ot(e){return[{type:"grid",name:"",schema:[{name:"columns",selector:{number:{min:1,max:6,mode:"box"}}},{name:"sort",selector:{select:{mode:"dropdown",options:["none","ascending","descending"].map(t=>({value:t,label:at(e,`editor_sort_${t}`)}))}}},{name:"limit",selector:{number:{min:1,mode:"box"}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}},{name:"unit",selector:{text:{}}}]}]}const Nt={include:["device_class","area","floor","label","domain"],exclude:["entity","device_class","area","floor","label","domain"]};function Ut(e,t){const n=e=>({select:{multiple:!0,custom_value:!0,mode:"dropdown",options:e}});switch(e){case"device_class":return n(t.deviceClasses);case"domain":return n(t.domains);case"entity":return{entity:{multiple:!0}};default:return{[e]:{multiple:!0}}}}const Ft=new Map;function Pt(e,t){const n=function(e){const t=new Set,n=new Set;for(const o of Object.values(e?.states??{})){const e=o.attributes?.device_class;"string"==typeof e&&e&&t.add(e),n.add(o.entity_id.slice(0,o.entity_id.indexOf(".")))}return{deviceClasses:[...t].sort(),domains:[...n].sort()}}(t),o=`${n.deviceClasses.join()}|${n.domains.join()}`,a=Ft.get(e);if(a?.key===o)return a.schema;const r=Nt[e].map(t=>({name:`${e}_${t}`,selector:Ut(t,n)}));return Ft.set(e,{key:o,schema:r}),r}let Dt;function Ht(e,t){let n=e;for(const o of t){if("object"!=typeof n||null===n)return;n=n[o]}return n}function Bt(e,t){if(e===t)return!0;if("object"!=typeof e||"object"!=typeof t||null===e||null===t)return!1;if(Array.isArray(e)!==Array.isArray(t))return!1;const n=Object.keys(e),o=Object.keys(t);return n.length===o.length&&n.every(n=>Bt(e[n],t[n]))}function It(e,t){const n={};for(const o of t){const t=Ht(e,o.path)??o.default;void 0!==t&&(n[o.name]=t)}return n}function Rt(e,t,n){const o=It(e,t);let a=e;for(const r of t){const e=n[r.name];Bt(o[r.name],e)||(a=lt(a,r.path,void 0!==r.default&&Bt(e,r.default)?void 0:e))}return a}const Vt="var(--divider-color, rgba(127, 127, 127, 0.3))",qt="var(--primary-color, #03a9f4)",Wt="var(--secondary-text-color, #727272)";function Zt(e,t,n,o){const a=(o-90)*Math.PI/180;return[(e+n*Math.cos(a)).toFixed(2),(t+n*Math.sin(a)).toFixed(2)]}function Yt(e,t,n,o){return Zt(e,t,n,o).join(" ")}function Gt(e,t,n,o,a){const r=a-o>180?1:0;return`M ${Yt(e,t,n,o)} A ${n} ${n} 0 ${r} 1 ${Yt(e,t,n,a)}`}function Xt(e){return R``}function Jt(e){let t=1/0,n=-1/0;for(const o of e)for(const e of o)for(const o of[e.v,e.min,e.max])"number"==typeof o&&(on&&(n=o));return t===1/0?void 0:[t,n]}function Kt(e,t){const[n,o]=e,[a,r]=t,i=o===n?0:(r-a)/(o-n);return e=>a+(e-n)*i}const Qt=["outside","inside"],en=!0,tn=!0,nn="outside",on=!0,an=!1,rn=e=>"number"==typeof e&&Number.isFinite(e);function sn(e){const t=e;for(const n of["x_axis","y_axis"])if(void 0!==t[n]&&"boolean"!=typeof t[n])throw new Error(`"${n}" must be true or false`);if(void 0!==t.axis_labels&&!Qt.includes(t.axis_labels))throw new Error(`"axis_labels" must be one of: ${Qt.join(", ")}`);if(void 0!==t.gridlines){const e=t.gridlines;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error('"gridlines" must have "horizontal" and "vertical" (true or false)');for(const t of["horizontal","vertical"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"gridlines.${t}" must be true or false`)}for(const n of["y_min","y_max"])if(void 0!==t[n]&&!rn(t[n]))throw new Error(`"${n}" must be a number`);if(rn(t.y_min)&&rn(t.y_max)&&t.y_min>=t.y_max)throw new Error('"y_min" must be below "y_max"')}function ln(e){const t=e,n=t.gridlines??{};return{x:t.x_axis??en,y:t.y_axis??tn,labels:t.axis_labels??nn,horizontal:n.horizontal??on,vertical:n.vertical??an,min:rn(t.y_min)?t.y_min:void 0,max:rn(t.y_max)?t.y_max:void 0}}function cn(e,t){if(!(e>0&&Number.isFinite(e)))return 1;const n=e/Math.max(1,t),o=10**Math.floor(Math.log10(n));for(const a of[1,2,5])if(a*o>=n*(1-1e-9))return a*o;return 10*o}function dn(e){return Math.max(0,-Math.floor(Math.log10(e)+1e-9))}const hn=e=>Number(e.toPrecision(12));function un(e,t,n){let[o,a]=function([e,t],n=.08){if(e===t)return[e-1,t+1];const o=(t-e)*n;return[e-o,t+o]}(e??[0,1]);void 0!==t.min&&(o=t.min),void 0!==t.max&&(a=t.max),a<=o&&(void 0!==t.min?a=o+Math.max(1,.1*Math.abs(o)):o=a-Math.max(1,.1*Math.abs(a)));const r=cn(a-o,n);void 0===t.min&&(o=hn(Math.floor(o/r+1e-9)*r)),void 0===t.max&&(a=hn(Math.ceil(a/r-1e-9)*r));const i=[];for(let s=Math.ceil(o/r-1e-9);s*r<=a+1e-9*r;s++)i.push(hn(s*r));return{domain:[o,a],ticks:i,decimals:dn(r)}}const mn=6e4,pn=36e5,fn=864e5,gn=[...[1,5,10,15,30].map(e=>({unit:"minute",n:e,ms:e*mn})),...[1,2,3,6,12].map(e=>({unit:"hour",n:e,ms:e*pn})),...[1,2,7].map(e=>({unit:"day",n:e,ms:e*fn})),...[1,2,3,6].map(e=>({unit:"month",n:e,ms:30.44*e*fn})),...[1,2,5,10,20,50,100].map(e=>({unit:"year",n:e,ms:365.25*e*fn}))];const yn=e=>-new Date(e).getTimezoneOffset()*mn;function vn(e){if(!e)return yn;let t;try{t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",second:"numeric"})}catch{return yn}return e=>{const n={};for(const o of t.formatToParts(e))n[o.type]=Number(o.value);return Date.UTC(n.year,n.month-1,n.day,n.hour%24,n.minute,n.second)-1e3*Math.floor(e/1e3)}}function _n(e,t){return e-t(e-t(e))}function bn(e,t,n,o){const a=[],r=e.end+t(e.end);for(let i=n,s=0;i<=r&&s<1e3;i=o(i),s++){const n=_n(i,t);n>=e.start&&n<=e.end&&n!==a[a.length-1]&&a.push(n)}return a}function xn(e,t,n=yn){const{unit:o,n:a}=function(e,t){return gn.find(n=>e/n.ms<=Math.max(1,t))??gn[gn.length-1]}(e.end-e.start,t),r=e.start+n(e.start);if("minute"===o||"hour"===o||"day"===o&&7!==a){const t=a*("minute"===o?mn:"hour"===o?pn:fn);return{unit:o,ticks:bn(e,n,Math.ceil(r/t)*t,e=>e+t)}}if("day"===o){const t=Math.ceil(r/fn);return{unit:o,ticks:bn(e,n,(t+((4-t)%7+7)%7)*fn,e=>e+7*fn)}}const i=new Date(r);if("month"===o){let t=i.getUTCFullYear(),s=i.getUTCMonth();Date.UTC(t,s){const t=new Date(e);return Date.UTC(t.getUTCFullYear(),t.getUTCMonth()+a)};return{unit:o,ticks:bn(e,n,Date.UTC(t,s),l)}}let s=i.getUTCFullYear();return Date.UTC(s,0)Date.UTC(new Date(e).getUTCFullYear()+a,0))}}function $n(e){return"server"===e?.locale?.time_zone?e.config?.time_zone:void 0}function wn(e){const t=e?.locale?.time_format;return"12"===t||"24"!==t&&void 0}const kn=new Map;function Mn(e,t,n){const o=`${e}|${t}|${JSON.stringify(n)}`;let a=kn.get(o);if(!a){try{a=new Intl.DateTimeFormat(e,{...n,timeZone:t})}catch{a=new Intl.DateTimeFormat(void 0,n)}kn.set(o,a)}return a}function An(e,t,n=!1){return Mn(t?.locale?.language??"en",$n(t),{hour:"numeric",minute:"2-digit",...n?{second:"2-digit"}:{},hour12:wn(t)}).format(e)}function Sn(e,t){return Mn(t?.locale?.language??"en",$n(t),{weekday:"short",day:"numeric",month:"short",hour:"numeric",minute:"2-digit",hour12:wn(t)}).format(e)}function En(e,t,n,o,a){const r=o?.locale?.language??"en",i=$n(o),s=e+(a??vn(i))(e),l=new Date(s),c=s%fn===0;switch(t){case"minute":case"hour":return c?Mn(r,i,{weekday:"short"}).format(e):An(e,o);case"day":return n<=7*fn?Mn(r,i,{weekday:"short"}).format(e):Mn(r,i,{day:"numeric",month:"short"}).format(e);case"month":return Mn(r,i,0===l.getUTCMonth()?{month:"short",year:"numeric"}:{month:"short"}).format(e);default:return Mn(r,i,{year:"numeric"}).format(e)}}function Cn(e,t){if(e.length<=t)return e;const n=[];let o=[];for(const r of e)null===r.v?(o.length&&n.push(o),n.push([r]),o=[]):o.push(r);o.length&&n.push(o);const a=e.length;return n.flatMap(e=>null===e[0].v?e:function(e,t){if(e.length<=t||t<3)return e;const n=[e[0]],o=(e.length-2)/(t-2);let a=0;for(let r=0;rm&&(m=t,u=n)}n.push(e[u]),a=u}return n.push(e[e.length-1]),n}(e,Math.max(3,Math.round(e.length/a*t))))}const zn=36e5,Ln=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"],Tn=["monday","tuesday","wednesday","thursday","friday","saturday","sunday"],jn=new Map;function On(e){let t=jn.get(e);return t||(t=new Intl.DateTimeFormat("en-US",{timeZone:e,hourCycle:"h23",year:"numeric",month:"numeric",day:"numeric",hour:"numeric",minute:"numeric",second:"numeric",weekday:"short"}),jn.set(e,t)),t}function Nn(e,t){const n={};for(const o of On(t).formatToParts(new Date(e)))n[o.type]=o.value;return{year:Number(n.year),month:Number(n.month),day:Number(n.day),hour:Number(n.hour)%24,minute:Number(n.minute),second:Number(n.second),weekday:Ln.indexOf(n.weekday)+1}}function Un(e,t){const n=Nn(e,t);return Date.UTC(n.year,n.month-1,n.day,n.hour,n.minute,n.second)-1e3*Math.floor(e/1e3)}function Fn(e,t,n,o=1,a=0,r=0,i=0){const s=Date.UTC(t,n-1,o,a,r,i),l=new Date(s),c=Un(s-864e5,e),d=Un(s+864e5,e),h=[s-c,s-d].filter(t=>{const n=Nn(t,e);return n.year===l.getUTCFullYear()&&n.month===l.getUTCMonth()+1&&n.day===l.getUTCDate()&&n.hour===l.getUTCHours()&&n.minute===l.getUTCMinutes()&&n.second===l.getUTCSeconds()});return h.length?Math.min(...h):function(e,t,n,o){for(;t-e>1e3;){const a=1e3*Math.floor((e+t)/2e3);Un(a,o)===n?e=a:t=a}return t}(s-d,s-c,c,e)}function Pn(e,t,n){const o=new Date(Date.UTC(e,t-1,n));return{year:o.getUTCFullYear(),month:o.getUTCMonth()+1,day:o.getUTCDate()}}function Dn(e,t,n){const o=Nn(t,n.timeZone);switch(e){case"hour":return t-t%1e3-1e3*(60*o.minute+o.second);case"day":return Fn(n.timeZone,o.year,o.month,o.day);case"week":{const e=(o.weekday-n.firstDay+7)%7,t=Pn(o.year,o.month,o.day-e);return Fn(n.timeZone,t.year,t.month,t.day)}case"month":return Fn(n.timeZone,o.year,o.month,1);case"year":return Fn(n.timeZone,o.year,1,1)}}function Hn(e,t,n,o){if("hour"===e)return t+n*zn;const a=Nn(t,o.timeZone);let{year:r,month:i,day:s}=a;if("day"===e)({year:r,month:i,day:s}=Pn(r,i,s+n));else if("week"===e)({year:r,month:i,day:s}=Pn(r,i,s+7*n));else{const t=Pn(r,i+("year"===e?12*n:n),1),o=Pn(t.year,t.month+1,0).day;({year:r,month:i}=t),s=Math.min(s,o)}return Fn(o.timeZone,r,i,s,a.hour,a.minute,a.second)}function Bn(e,t,n,o,a=1e4){const r=[Dn(e,t,o)];let i=1;for(;r[r.length-1]r[r.length-1]?t:r[r.length-1]+zn),i++}return 1===r.length&&r.push(Hn(e,r[0],1,o)),r}function In(e){try{const t=new Intl.Locale(e),n=t.getWeekInfo?.().firstDay??t.weekInfo?.firstDay;return"number"==typeof n&&n>=1&&n<=7?n:1}catch{return 1}}function Rn(e){const t=e?.locale,n=e?.config?.time_zone,o="server"===t?.time_zone&&function(e){if("string"!=typeof e||""===e)return!1;try{return On(e),!0}catch{return!1}}(n)?n:(new Intl.DateTimeFormat).resolvedOptions().timeZone||"UTC",a=Tn.indexOf(String(t?.first_weekday??"language"))+1;return{timeZone:o,firstDay:a>0?a:In(t?.language??e?.language??"en")}}const Vn=["hour","day","week","month"],qn=["day","week","month","year"],Wn=["now","end"],Zn={last:24,unit:"hour",offset:0,navigate:!1,until:"now"},Yn={hour:36e5,day:864e5,week:6048e5};function Gn(e,t,n){const o="string"==typeof e&&""!==e.trim()?Number(e):e;if("number"!=typeof o||!Number.isInteger(o)||o"number"==typeof e&&Number.isFinite(e)?e:null;function io(e,t){return(e??[]).map(e=>{const n="number"==typeof e.start?e.start:Date.parse(e.start);let o;return o="change"===t?ro(e.change):"state"===t?ro(e.state)??ro(e.mean):"min"===t||"max"===t?ro(e[t])??ro(e.mean)??ro(e.state):ro(e.mean)??ro(e.state),{t:n,v:o,min:e.min??null,max:e.max??null}})}function so(e){const t=(e.end-e.start)/864e5;return t<=2?"5minute":t<=35?"hour":t<=400?"day":"month"}function lo(e){return{period:so(e),types:["mean","min","max","state"],value:"mean"}}function co(e){return e.attribute?`${e.entity}|${e.attribute}`:e.entity}const ho=["hour","day","week","month"],uo=["mean","sum","min","max","first","last","change"];function mo(e){if(void 0!==e.aggregate&&!uo.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${uo.join(", ")}`);if(void 0!==e.group_by){if(!ho.includes(e.group_by))throw new Error(`"group_by" must be one of: ${ho.join(", ")}`);return{unit:e.group_by,aggregate:e.aggregate??"mean"}}}function po(e,t,n,o,a=!1){const r="change"===n?function(e){let t=null;return e.map(e=>{if(null===e.v)return null;if(null===t)return t=e.v,0;if(e.v>=t){const n=e.v-t;return t=e.v,n}return t-e.v>.1*Math.abs(t)?(t=e.v,Math.max(0,e.v)):0})}(e):[],i=[];let s=0;for(let l=0;l+1c&&m.push({v:p.v,dt:g-c,reading:!1,carried:!0,up:0});fnull!==e.v);if(0===y.length)continue;const v=y.map(e=>e.v);switch(a&&(u.min=Math.min(...v),u.max=Math.max(...v)),n){case"mean":{const e=y.reduce((e,t)=>e+t.dt,0);u.v=e>0?y.reduce((e,t)=>e+t.v*t.dt,0)/e:v.reduce((e,t)=>e+t,0)/v.length;break}case"min":u.v=Math.min(...v);break;case"max":u.v=Math.max(...v);break;case"first":u.v=v[0];break;case"last":u.v=v[v.length-1];break;case"sum":{const e=y.filter(e=>e.reading&&!e.carried);u.v=e.length?e.reduce((e,t)=>e+t.v,0):null;break}case"change":u.v=y.reduce((e,t)=>e+(t.reading&&!t.carried?t.up??0:0),0)}}return i}const fo={"5minute":3e5,hour:36e5,day:9e7,week:6912e5,month:27648e5},go={mean:{types:["mean"],value:"mean"},min:{types:["min"],value:"min"},max:{types:["max"],value:"max"},first:{types:["state","mean"],value:"state"},last:{types:["state","mean"],value:"state"},sum:{types:["change"],value:"change"},change:{types:["change"],value:"change"}};const yo={"5minute":3e5,hour:36e5,day:864e5,week:864e5,month:864e5};class vo{constructor(e){this.onChange=e,this.shape="",this.loadedFrom=1/0,this.loadedTo=-1/0,this.status="loading",this.data=new Map,this.seen=new Map,this.generation=0}get(e,t,n,o,a={}){const r=a.live??!0,i="statistics"===o?a.statistics??lo(n):void 0,s=`${o}|${t.map(co).join(",")}|${i?JSON.stringify(i):""}`,l=void 0!==this.pausedAt&&Date.now()-this.pausedAt>bo,c=void 0!==this.pausedAt&&"statistics"===o&&r;this.pausedAt=void 0;const d=Math.max(6e4,(n.end-n.start)/100),h=n.startthis.loadedTo+d);(s!==this.shape||h||l&&r||c)&&(this.shape=s,this.load(e,t,n,r,i)),"ready"===this.status&&"history"===o&&this.loadedTo===1/0&&this.appendLive(e,t);const u=new Map;for(const[m,p]of this.data)u.set(m,_o(p,n));return{status:this.status,data:u,error:this.error}}pause(){clearTimeout(this.timer),this.timer=void 0,this.pausedAt=Date.now()}dispose(){clearTimeout(this.timer),this.timer=void 0,this.shape="",this.loadedFrom=1/0,this.loadedTo=-1/0,this.generation++}load(e,t,n,o,a){const r=++this.generation;this.status="loading",this.loadedFrom=n.start,this.loadedTo=o?1/0:n.end,clearTimeout(this.timer);const i=a?async function(e,t,n,o=lo(n)){const a=new Map;if(!e.callWS||0===t.length)return a;const r=[...new Set(t.map(e=>e.entity))],i=await e.callWS({type:"recorder/statistics_during_period",start_time:new Date(n.start).toISOString(),end_time:new Date(n.end).toISOString(),statistic_ids:r,period:o.period,types:o.types});for(const s of t)a.set(co(s),io(i?.[s.entity],o.value));return a}(e,t,n,a):async function(e,t,n){const o=new Map;if(!e.callWS||0===t.length)return o;const a={type:"history/history_during_period",start_time:new Date(n.start).toISOString(),end_time:new Date(n.end).toISOString(),significant_changes_only:!1},r=[...new Set(t.filter(e=>!e.attribute).map(e=>e.entity))],i=[...new Set(t.filter(e=>e.attribute).map(e=>e.entity))],s=[];return r.length&&s.push(e.callWS({...a,entity_ids:r,minimal_response:!0,no_attributes:!0}).then(e=>{for(const t of r)o.set(co({entity:t}),ao(e?.[t]))})),i.length&&s.push(e.callWS({...a,entity_ids:i,no_attributes:!1}).then(e=>{for(const n of t.filter(e=>e.attribute))o.set(co(n),ao(e?.[n.entity],n.attribute))})),await Promise.all(s),o}(e,t,n);i.then(n=>{if(r===this.generation){this.data=n,this.status="ready",this.error=void 0,this.seen.clear();for(const n of t)this.seen.set(co(n),e.states[n.entity]);a&&o&&(this.timer=setTimeout(()=>{this.shape="",this.onChange()},yo[a.period])),this.onChange()}},e=>{r===this.generation&&(this.status="error",this.error=e instanceof Error?e.message:String(e?.message??e),this.onChange())})}appendLive(e,t){for(const n of t){const t=co(n),o=e.states[n.entity];if(!o||this.seen.get(t)===o)continue;this.seen.set(t,o);const a=Date.parse(o.last_updated??o.last_changed??"")||Date.now(),r=this.data.get(t)??[],i=r[r.length-1];i&&a<=i.t||(r.push({t:a,v:oo(n.attribute?o.attributes?.[n.attribute]:o.state)}),this.data.set(t,r))}}}function _o(e,t){let n=0;for(;ne.t<=t.end),a=e[n-1];return a?[{...a,t:t.start,carried:!0},...o]:o}const bo=6e4,xo=new WeakMap;function $o(e,t){let n=xo.get(e);return n||(n=new vo(t),xo.set(e,n)),n}const wo=["smooth","straight","step"],ko=e=>(Math.round(100*e)/100).toString();function Mo(e){return e.map(([e,t],n)=>`${n?"L":"M"}${ko(e)},${ko(t)}`).join("")}function Ao(e,t){return 0===e.length?"":1===e.length?`M${ko(e[0][0])},${ko(e[0][1])}h0`:"smooth"===t?function(e){const t=e.length;if(t<3)return Mo(e);const n=[],o=[];for(let i=0;i9){const r=3/Math.sqrt(n);a[i]=r*e*o[i],a[i+1]=r*t*o[i]}}let r=`M${ko(e[0][0])},${ko(e[0][1])}`;for(let i=0;i"object"==typeof e?e.curve:void 0)];for(const n of t)if(void 0!==n&&!wo.includes(n))throw new Error(`"curve" must be one of: ${wo.join(", ")}`)}function jo(e,t=Date.now(),n={}){const o=Jn(e.raw.period),a=e.raw.source??"history",r=Rn(e.hass),i=Qn(o,t,r,o.navigate?to(e.host,o):0),s=mo(e.raw),l={start:s?Dn(s.unit,i.start,r):i.start,end:i.end},c=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),d=$o(e.host,e.requestUpdate),h=s&&"statistics"===a?function(e,t,n,o=!1){const a=t.timeZone===n&&("week"!==e.unit||1===t.firstDay),r="hour"!==e.unit&&a?e.unit:"hour",{types:i,value:s}=go[e.aggregate];return{period:r,types:o?[...new Set([...i,"min","max"])]:i,value:s}}(s,r,e.hass.config?.time_zone,!0===n.range):void 0,u=h?{start:l.start-fo[h.period],end:l.end}:l,m=d.get(e.hass,c,u,a,{live:i.live,statistics:h}),p=s?Bn(s.unit,l.start,l.end,r):[],f=e.raw.curve??"smooth",g=Math.min(l.end,t),y=e.items.map((e,t)=>{const o=e.config;let r=m.data.get(co(c[t]))??[];const i=r[r.length-1];return s?r="statistics"===a?function(e,t,n,o=1/0,a=!1){const r=[];let i=0;for(let s=0;s+1e+t,0)/h.length;else if("sum"===n||"change"===n)p=h.reduce((e,t)=>e+t,0);else if("min"===n)p=Math.min(...h);else if("max"===n)p=Math.max(...h);else if("last"===n)p=h[h.length-1];else{const t=e.slice(0,i).reverse().find(e=>null!==e.v),n=e.slice(i,d).find(e=>e.carried&&null!==e.v);p=(t??n)?.v??h[0]}const f={t:l,end:c,v:p};a&&null!==p&&(f.min=Math.min(...u),f.max=Math.max(...m)),r.push(f)}return r}(r,p,s.aggregate,g,!0===n.range):po(r,p,n.aggregate?.(e)??s.aggregate,g,!0===n.range):"history"===a&&i&&null!==i.v&&i.t1===e?t:n;function No(e,t,n,o,a){const r=(e,t)=>at(a,e,t);if(e.calendar&&n<2)return r({day:["period_today","period_yesterday"],week:["period_this_week","period_last_week"],month:["period_this_month","period_last_month"],year:["period_this_year","period_last_year"]}[e.calendar][n]);if(!e.calendar&&0===n){const t=e.last;return r({hour:Oo(t,"period_last_hour","period_last_hours"),day:Oo(t,"period_last_day","period_last_days"),week:Oo(t,"period_last_week_rolling","period_last_weeks"),month:Oo(t,"period_last_month_rolling","period_last_months")}[e.unit],{n:t})}const i={timeZone:o.timeZone},s=Nn(t.start,o.timeZone).year===Nn(Date.now(),o.timeZone).year;let l;"year"===e.calendar?i.year="numeric":"month"===e.calendar?Object.assign(i,{month:"long",year:"numeric"}):(Object.assign(i,{day:"numeric",month:"short"}),s||(i.year="numeric"),"day"===e.calendar&&(i.weekday="short"),e.calendar||"hour"!==e.unit&&"day"!==e.unit||Object.assign(i,{hour:"numeric",minute:"2-digit"}));try{l=new Intl.DateTimeFormat(a?.locale?.language??a?.language??"en",i)}catch{l=new Intl.DateTimeFormat("en",i)}const c=e.calendar?t.end-1:t.end;return"day"===e.calendar||"month"===e.calendar||"year"===e.calendar?l.format(t.start):l.formatRange(t.start,c)}function Uo(e,t,n=Date.now()){const{period:o}=e;if(!o.navigate)return W;const a=to(t.host,o),r=e=>n=>{n.stopPropagation(),no(t.host,o,e),t.requestUpdate()},i=No(o,e.range,e.back,e.zone,t.hass);let s="";if(0!==a){const a=Qn(o,n,e.zone);s=No(o,a,a.back,e.zone,t.hass)}const l=e=>R``;return R`
- ${i} - ${0!==a?R``:W} -
`}const Fo=r` +
`}const yn=r` .period-nav { display: flex; align-items: center; @@ -142,16 +142,31 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); white-space: nowrap; } -`;function Po(e){!function(e){xo.get(e)?.pause()}(e)}function Do(e,t){const{show:n}=t.config;return n.name||n.value?R`
+`;function bn(e){!function(e){tn.get(e)?.pause()}(e)}function _n(e,t){const{show:o}=t.config;return o.name||o.value?R`
${e.series.map(e=>R`
- ${n.name?R`${e.entity.name}`:W} - ${n.value?R`${e.entity.display}`:W} + ${o.name?R`${e.entity.name}`:W} + ${o.value?R`${e.entity.display}`:W}
`)} -
`:W}function Ho(e,t){return"loading"===e.status?R`
${at(t.hass,"chart_loading")}
`:"error"===e.status?R`
- ${at(t.hass,"chart_error",{error:e.error??""})} -
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${at(t.hass,"chart_no_data")}
`:void 0}const Bo=r` - ${Fo} +
`:W}function xn(e,t){return"loading"===e.status?R`
${_t(t.hass,"chart_loading")}
`:"error"===e.status?R`
+ ${_t(t.hass,"chart_error",{error:e.error??""})} +
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${_t(t.hass,"chart_no_data")}
`:void 0}function wn(e,t,o=W,a=W){return R` + + + + + ${o} + + ${a} + ${e} + `}const $n=r` + ${yn} .series-header { display: flex; flex-wrap: wrap; @@ -239,50 +254,37 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); white-space: nowrap; } -`;function Io(e,t,n){const{locale:o,grouping:a}=function(e){const t=e?.locale?.language??"en";switch(e?.locale?.number_format){case"system":return{locale:void 0,grouping:!0};case"comma_decimal":return{locale:"en-US",grouping:!0};case"decimal_comma":return{locale:"de",grouping:!0};case"space_comma":return{locale:"fr",grouping:!0};case"none":return{locale:"en-US",grouping:!1};default:return{locale:t,grouping:!0}}}(n),r=new Intl.NumberFormat(o,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:a});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Ro(e,t){return t?`${e} ${t}`:e}const Vo=new WeakMap;function qo(e){let t=Vo.get(e.host);if(!t){const n={width:e.host.clientWidth||400};t=n,Vo.set(e.host,n),"undefined"!=typeof ResizeObserver&&(n.observer=new ResizeObserver(t=>{const o=Math.round(t[t.length-1].contentRect.width);Math.round(o/70)!==Math.round(n.width/70)&&(n.width=o,e.requestUpdate())}),n.observer.observe(e.host))}return t.width}function Wo(e){Vo.get(e)?.observer?.disconnect(),Vo.delete(e)}function Zo(e){const t=ln(e.raw);return Math.max(0,qo(e)-32-(t.y&&"outside"===t.labels?40:0))}function Yo(e,t,n){const o=ln(e.raw),a=un(n,o,4),r=Math.max(2,Math.min(8,Math.floor(Zo(e)/70))),{unit:i,ticks:s}=xn(t,r,vn($n(e.hass)));return{options:o,value:a,times:s,unit:i,range:t,scales:{x:Kt([t.start,t.end],[0,Eo]),y:Kt(a.domain,[Co,0]),domain:a.domain}}}const Go=e=>Math.round(1e4*e)/100+"%";function Xo({axes:e,ctx:t,content:n,label:o,defs:a=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c}){const{options:d}=e,h="outside"===d.labels,u=d.y&&e.value.ticks.length>0,m=d.x&&e.times.length>0,p=u?function(e,t,n){const{scales:o,value:a}=e;return a.ticks.map(e=>{const r=o.y(e)/Co;return R`.92?"bottom":""}" style="top:${Go(r)}" - >${n?n(e):Io(e,a.decimals,t.hass)}`})}(e,t,c):[],f=u?R`
${p}
`:W,g=m?R`
${function(e,t,n){const{scales:o,range:a}=e,r=vn($n(t.hass));return e.times.map(i=>{const s=o.x(i)/Eo;return R`.95?"end":""}" style="left:${Go(s)}" - >${n?n(i):e.label?e.label(i):En(i,e.unit,a.end-a.start,t.hass,r)}`})}(e,t,l)}
`:W,y=u?R``:W,v=s&&d.y&&s.value.ticks.length>0?s:void 0,_=v?R``:W,b=v?R`
${h?_:W}${function(e,t){return e.value.ticks.map(n=>{const o=e.y(n)/Co;return R`.92?"bottom":""}" style="top:${Go(o)}" - >${Io(n,e.value.decimals,t.hass)}`})}(v,t)}
`:W;return R`
- ${h&&u?R`
${y}${p}
`:W} +`;function kn(e,t,o){const{locale:a,grouping:n}=function(e){const t=e?.locale?.language??"en";switch(e?.locale?.number_format){case"system":return{locale:void 0,grouping:!0};case"comma_decimal":return{locale:"en-US",grouping:!0};case"decimal_comma":return{locale:"de",grouping:!0};case"space_comma":return{locale:"fr",grouping:!0};case"none":return{locale:"en-US",grouping:!1};default:return{locale:t,grouping:!0}}}(o),r=new Intl.NumberFormat(a,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:n});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Mn(e,t){return t?`${e} ${t}`:e}const zn=[[1,2,3,4,5,6,7,8,9],[1,2,5],[1]],An=[2,3,5,10,20,50,100],Sn=e=>Number(e.toPrecision(12)),En=e=>Sn(10**e),Cn=e=>Math.floor(Math.log10(e)+1e-9);function jn(e,t,o,a=1){const n=[];for(let r=Cn(e);r<=Cn(t)+a;r+=a)for(const a of o){const o=Sn(a*10**r);o>=e*(1-1e-9)&&o<=t*(1+1e-9)&&n.push(o)}return n}function Nn(e,t,o){const a=Cn(e),n=jn(En(a-2),En(a+2),t),r=o?n.filter(t=>t<=e*(1+1e-9)):n.filter(t=>t>=e*(1-1e-9));return o?r[r.length-1]:r[0]}function Ln(e,t){const o=Math.log10(e[0]),a=Math.log10(e[1]),[n,r]=t,i=a===o?0:(r-n)/(a-o);return e=>e>0?n+(Math.log10(e)-o)*i:NaN}const Tn=new WeakMap;function On(e){let t=Tn.get(e.host);if(!t){const o={width:e.host.clientWidth||400};t=o,Tn.set(e.host,o),"undefined"!=typeof ResizeObserver&&(o.observer=new ResizeObserver(t=>{const a=Math.round(t[t.length-1].contentRect.width);Math.round(a/70)!==Math.round(o.width/70)&&(o.width=a,e.requestUpdate())}),o.observer.observe(e.host))}return t.width}function Fn(e){Tn.get(e)?.observer?.disconnect(),Tn.delete(e)}function Pn(e){const t=To(e.raw);return Math.max(0,On(e)-32-(t.y&&"outside"===t.labels?40:0))}function Dn(e,t,o,a,n=4){const r=To(e.raw),i="log"===r.scale&&"line"===e.raw.style,s=i?function(e,t,o){let[a,n]=e??[1,100];void 0!==t.min&&t.min>0&&(a=t.min),void 0!==t.max&&t.max>0&&(n=t.max),n>0||(n=a>0?10*a:100),a>0||(a=n/1e3);const r=void 0!==t.min&&t.min===a,i=void 0!==t.max&&t.max===n;n<=a&&(r?n=10*a:i?a=n/10:(a/=Math.sqrt(10),n*=Math.sqrt(10)));const s=Math.max(2,o+1),l=[...zn.map(e=>({mantissas:e,every:1})),...An.map(e=>({mantissas:[1],every:e}))];let c=l[l.length-1],d=[a,n],u=[];for(const f of l){const e=r?a:Nn(a,f.mantissas,!0),t=i?n:Nn(n,f.mantissas,!1),o=jn(e,t,f.mantissas,f.every);if(c=f,d=[e,t],u=o,o.length<=s)break}const h=c.every>1?[1]:zn[0],p=9===c.mantissas.length?[]:jn(d[0],d[1],h).filter(e=>!u.includes(e)).slice(0,200),m=u.map(e=>Math.max(0,-Cn(e)));return{domain:d,ticks:u,decimals:Math.max(0,...m),minor:p,tickDecimals:m}}(o,r,n):Do(o,r,n),l=Math.max(2,Math.min(8,Math.floor((a??Pn(e))/70))),{unit:c,ticks:d}=Vo(t,l,Zo(Yo(e.hass)));return{options:r,value:s,times:d,unit:c,range:t,scales:{x:$o([t.start,t.end],[0,cn]),y:(i?Ln:$o)(s.domain,[dn,0]),domain:s.domain}}}const Un=e=>Math.round(1e4*e)/100+"%";function Hn(e,t,o){const a=e.ticks[t];return e.tickDecimals?function(e,t,o){if(Math.abs(e)<1e4)return kn(e,t,o);const a=o?.locale?.language??o?.language??"en";try{return new Intl.NumberFormat(a,{notation:"compact",maximumFractionDigits:1}).format(e)}catch{return new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}}(a,e.tickDecimals[t],o.hass):kn(a,e.decimals,o.hass)}function In({axes:e,ctx:t,content:o,label:a,defs:n=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c,gestures:d,classes:u="",below:h=W}){const{options:p}=e,m="outside"===p.labels,f=p.y&&e.value.ticks.length>0,g=p.x&&e.times.length>0,v=f?function(e,t,o){const{scales:a,value:n}=e;return n.ticks.map((e,r)=>{const i=a.y(e)/dn;return R`.92?"bottom":""}" style="top:${Un(i)}" + >${o?o(e):Hn(n,r,t)}`})}(e,t,c):[],y=f?R`
${v}
`:W,b=g?R`
${function(e,t,o){const{scales:a,range:n}=e,r=Zo(Yo(t.hass));return e.times.map(i=>{const s=a.x(i)/cn;return R`.95?"end":""}" style="left:${Un(s)}" + >${o?o(i):e.label?e.label(i):ea(i,e.unit,n.end-n.start,t.hass,r)}`})}(e,t,l)}
`:W,_=f?R``:W,x=s&&p.y&&s.value.ticks.length>0?s:void 0,w=x?R``:W,$=x?R`
${m?w:W}${function(e,t){return e.value.ticks.map(o=>{const a=e.y(o)/dn;return R`.92?"bottom":""}" style="top:${Un(a)}" + >${kn(o,e.value.decimals,t.hass)}`})}(x,t)}
`:W,k=`plot-wrap axes-${p.labels} ${f?"has-y":""} ${g?"has-x":""} ${x?"has-right":""} ${h===W?"":"has-overview"}`;return R`
+ ${m&&f?R`
${_}${v}
`:W}
- ${function(e,t,n=W,o=W){return R` - - - - - ${n} - - ${o} - ${e} - `}(n,o,a,function(e){const{options:t,scales:n}=e;return V` - ${t.horizontal?e.value.ticks.map(e=>V``):W} - ${t.vertical?e.times.map(e=>V``):W} - `}(e))} ${h?W:R`${f}${b}${g}`} + ${wn(o,a,n,function(e){const{options:t,scales:o}=e;return Z` + ${t.horizontal?(e.value.minor??[]).map(e=>Z``):W} + ${t.horizontal?e.value.ticks.map(e=>Z``):W} + ${t.vertical?e.times.map(e=>Z``):W} + `}(e))} ${m?W:R`${y}${$}${b}`} ${r}
- ${h?R`${b}${g}`:W} -
`}const Jo=r` + ${m?R`${$}${b}`:W} ${h} +
`}const Bn=r` .plot-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -298,6 +300,9 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); stroke-width: 1; } + .plot .grid line.minor { + opacity: 0.45; + } .axis-label { position: absolute; font-size: 11px; @@ -392,9 +397,9 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border-radius: 2px; background: color-mix(in srgb, var(--card-background-color, #fff) 70%, transparent); } -`,Ko=["above","over","hidden"];function Qo(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Ko.includes(e.header))throw new Error(`"header" must be one of: ${Ko.join(", ")}`)}function ea(e){return{compact:!0===e.compact,header:e.header??"above"}}function ta(e,t,n){return R`
- ${"above"===e.header?t:W}${n} -
`}function na(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const oa=r` +`,Rn=["above","over","hidden"];function Zn(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Rn.includes(e.header))throw new Error(`"header" must be one of: ${Rn.join(", ")}`)}function qn(e){return{compact:!0===e.compact,header:e.header??"above"}}function Wn(e,t,o){return R`
+ ${"above"===e.header?t:W}${o} +
`}function Vn(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const Yn=r` .header-over { position: absolute; top: 2px; @@ -447,26 +452,26 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .frame.compact .axes-outside .x-axis .axis-label { top: 3px; } -`,aa=["none","normal","percent"];function ra(e){const t=e.stack;if(void 0!==t&&!aa.includes(t))throw new Error(`"stack" must be one of: ${aa.join(", ")}`)}function ia(e,t){let n=0,o=e.length;for(;n>1;e[a].t<=t?n=a+1:o=a}return 0===n?null:e[n-1].v}function sa(e,t){const n=e[0]?.length??0,o=e.map(()=>[]);for(let a=0;ae+Math.abs(t[a]??0),0);n=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[a];if(null==s)return void o[t].push(null);const l=s*n;l>=0?(o[t].push([r,r+l]),r+=l):(o[t].push([i,i+l]),i+=l)})}return o}function la(e){const t=e[0]?.length??0,n=[];for(let o=0;o=190?"left":e>.5?"pin-left":"pin-right"}function ha(e,t){if(0===e.length)return-1;let n=0,o=e.length-1;for(;n>1;e[a].t0&&t-e[n-1].t<=e[n].t-t?n-1:n}const ua=new WeakMap;function ma(e){return ua.get(e)?.t}function pa(e){const t=ua.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ua.delete(e)}function fa(e){return!1!==e.tooltip}function ga(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function ya(e,t,n){const o=function(e){let t=ua.get(e);return t||(t={},ua.set(e,t)),t}(e.host),a=t=>{t!==o.t&&(o.t=t,e.requestUpdate())},r=()=>{void 0!==o.timer&&clearTimeout(o.timer),o.timer=void 0};return{move(e){r();const o=e.currentTarget.getBoundingClientRect(),i=o.width>0?Math.min(1,Math.max(0,(e.clientX-o.left)/o.width)):0;a(function(e,t){let n;for(const o of e){const e=ha(o,t);if(e<0)continue;const a=o[e].t;(void 0===n||Math.abs(a-t){o.timer=void 0,a(void 0)},3e3):a(void 0)}}}const va=e=>Math.round(1e4*e)/100+"%";function _a(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function ba(e,t,n,o){return R`
+`,Gn=["none","normal","percent"];function Xn(e){const t=e.stack;if(void 0!==t&&!Gn.includes(t))throw new Error(`"stack" must be one of: ${Gn.join(", ")}`)}function Jn(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return 0===o?null:e[o-1].v}function Kn(e,t){const o=e[0]?.length??0,a=e.map(()=>[]);for(let n=0;ne+Math.abs(t[n]??0),0);o=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[n];if(null==s)return void a[t].push(null);const l=s*o;l>=0?(a[t].push([r,r+l]),r+=l):(a[t].push([i,i+l]),i+=l)})}return a}function Qn(e){const t=e[0]?.length??0,o=[];for(let a=0;a=190?"left":e>.5?"pin-left":"pin-right"}const or=3e3;function ar(e,t){if(0===e.length)return-1;let o=0,a=e.length-1;for(;o>1;e[n].t0&&t-e[o-1].t<=e[o].t-t?o-1:o}const nr=new WeakMap;function rr(e){return nr.get(e)?.t}function ir(e){const t=nr.get(e);void 0!==t?.timer&&clearTimeout(t.timer),nr.delete(e)}function sr(e){return!1!==e.tooltip}function lr(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function cr(e,t,o){const a=function(e){let t=nr.get(e);return t||(t={},nr.set(e,t)),t}(e.host),n=t=>{t!==a.t&&(a.t=t,e.requestUpdate())},r=()=>{void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0};return{move(e){r();const a=e.currentTarget.getBoundingClientRect(),i=a.width>0?Math.min(1,Math.max(0,(e.clientX-a.left)/a.width)):0;n(function(e,t){let o;for(const a of e){const e=ar(a,t);if(e<0)continue;const n=a[e].t;(void 0===o||Math.abs(n-t){a.timer=void 0,n(void 0)},or):n(void 0)}}}const dr=e=>Math.round(1e4*e)/100+"%";function ur(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function hr(e,t,o,a){return R`
- ${at(e.hass,"chart_total")} - ${null===t?"—":Ro(Io(t,o,e.hass),n)} -
`}function xa(e,t,n,o,a,r={}){if(void 0===o)return W;const i=n.x(o)/Eo;if(!(i>=0&&i<=1))return W;const s=da(i,a),l=t.map(e=>{const t=ha(e.points,o);return{s:e,p:t<0?void 0:e.points[t]}});return R``}function pr(e,t,o,a,n,r={}){if(void 0===a)return W;const i=o.x(a)/cn;if(!(i>=0&&i<=1))return W;const s=tr(i,n),l=t.map(e=>{const t=ar(e.points,a);return{s:e,p:t<0?void 0:e.points[t]}});return R``}(i.series[2],A,a,e.hass):W}`)}function Gs(e,t,o,a,n,r){const i="time"===a?[e.series]:"bubble"===o?t.series.slice(0,3):[t.series[0],e.series];return xs({x:e.x/cn,y:e.y/dn,plotWidth:r,title:Qo(e.t,n.hass),dot:e.color,rows:i.map((t,o)=>({color:t===e.series?e.color:void 0,name:t.entity.name,value:Vs(e.v[o],t,n.hass)}))})}const Xs=r` + ${$n} + ${Bn} + ${mr} + ${Yn} + ${ws} .plot .dots, .plot .bubble { fill: none; @@ -847,22 +1224,22 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .frame.compact .size-legend { padding-top: 2px; } -`;function Zr(e){Po(e),Wo(e),pa(e),pr(e)}const Yr=Ca.map(e=>"x_axis"===e.name?{...e,name:"xy_x_axis"}:e);const Gr=(e,t,n=1)=>({number:{min:e,max:t,step:n,mode:"box",unit_of_measurement:"px"}}),Xr={scatter:[...ka,{name:"against",path:["against"],default:"entity"},{name:"dot_size",path:["dot_size"],default:6},{name:"jitter",path:["jitter"],default:0},...sr,...Yr,...Sa],bubble:[...ka,{name:"min_size",path:["min_size"],default:6},{name:"max_size",path:["max_size"],default:32},{name:"jitter",path:["jitter"],default:0},...sr,...Yr,...Sa]};function Jr(e){return t=>{const n="scatter"===e&&"time"===t.config.against,o=Ta(`${e}|${Ma(t.config)}|${n}`,t.hass,()=>{return[...Aa(t.hass,t.config),..."scatter"===e?[{name:"against",selector:wa(t.hass,"against",zr)},{type:"grid",name:"",schema:[{name:"dot_size",selector:Gr(Tr[0],Tr[1])},{name:"jitter",selector:Gr(jr[0],jr[1])}]}]:[{type:"grid",name:"",schema:[{name:"min_size",selector:Gr(Lr[0],Lr[1])},{name:"max_size",selector:Gr(Lr[0],Lr[1])}]},{name:"jitter",selector:Gr(jr[0],jr[1])}],...n?[]:lr,...(o=t.hass,[{type:"grid",name:"",schema:[{name:"xy_x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:wa(o,"axis_labels",Qt)},{type:"grid",name:"",schema:[{name:"y_min",selector:{number:{mode:"box",step:"any"}}},{name:"y_max",selector:{number:{mode:"box",step:"any"}}}]},{name:"tooltip",selector:{boolean:{}}}]),...Ea(t.hass)];var o});return ja(t,"chart",o,Xr[e])}}const Kr=[{name:"dot_color",path:["color"]}],Qr=[{name:"dot_color",selector:{text:{}}}];function ei(){return{schema:Qr,fields:Kr}}const ti={layout:"chart",group:"distribution",height:270,styles:Wr,thumbnail:()=>V` +`;function Js(e){bn(e),Fn(e),ir(e),ys(e)}const Ks=Si.map(e=>"x_axis"===e.name?{...e,name:"xy_x_axis"}:e);const Qs=(e,t,o=1)=>({number:{min:e,max:t,step:o,mode:"box",unit_of_measurement:"px"}}),el={scatter:[...xi,{name:"against",path:["against"],default:"entity"},{name:"dot_size",path:["dot_size"],default:6},{name:"jitter",path:["jitter"],default:0},...us,...Ks,...zi],bubble:[...xi,{name:"min_size",path:["min_size"],default:6},{name:"max_size",path:["max_size"],default:32},{name:"jitter",path:["jitter"],default:0},...us,...Ks,...zi]};function tl(e){return t=>{const o="scatter"===e&&"time"===t.config.against,a=Fi(`${e}|${wi(t.config)}|${o}`,t.hass,()=>{return[...$i(t.hass,t.config),..."scatter"===e?[{name:"against",selector:_i(t.hass,"against",Ts)},{type:"grid",name:"",schema:[{name:"dot_size",selector:Qs(Fs[0],Fs[1])},{name:"jitter",selector:Qs(Ps[0],Ps[1])}]}]:[{type:"grid",name:"",schema:[{name:"min_size",selector:Qs(Os[0],Os[1])},{name:"max_size",selector:Qs(Os[0],Os[1])}]},{name:"jitter",selector:Qs(Ps[0],Ps[1])}],...o?[]:hs,...(a=t.hass,[{type:"grid",name:"",schema:[{name:"xy_x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:_i(a,"axis_labels",Mo)},{type:"grid",name:"",schema:[{name:"y_min",selector:{number:{mode:"box",step:"any"}}},{name:"y_max",selector:{number:{mode:"box",step:"any"}}}]},{name:"tooltip",selector:{boolean:{}}}]),...Ai(t.hass)];var a});return Pi(t,"chart",a,el[e])}}const ol=[{name:"dot_color",path:["color"]}],al=[{name:"dot_color",selector:{text:{}}}];function nl(){return{schema:al,fields:ol}}const rl={layout:"chart",group:"distribution",height:270,styles:Xs,thumbnail:()=>Z` - `,validate:e=>Br(e,"bubble"),editor:Jr("bubble"),entityFields:()=>ei(),disconnect:Zr,render:e=>Vr(e,"bubble")},ni=["auto","none","hour","day","week","month"],oi=["auto","mean","sum","min","max","first","last","change"],ai={last:7,unit:"day"},ri={hour:1/24,day:1,week:7,month:30};function ii(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*ri[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function si(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function li(e,t){const n={...e,period:e.period??{...ai}};return"none"===e.group_by?delete n.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ii(Jn(n.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(n.aggregate=!0!==e.range&&t.length>0&&t.every(si)?"change":"mean"),n}function ci(e,t){const n=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t),o=e.map(e=>new Map(e.map(e=>[e.t,e]))),a=new Map;for(const i of e)for(const e of i)void 0!==e.end&&a.set(e.t,e.end);const r={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return n.forEach((i,s)=>{const l=Math.min(a.get(i)??n[s+1]??t,t);l>i&&(r.times.push(i),r.ends.push(l),e.forEach((e,t)=>{const n=o[t].get(i);r.values[t].push(n?n.v:ia(e,i)),r.lows[t].push(n?.min??null),r.highs[t].push(n?.max??null)}))}),r}function di(e,t,n,o){const a=t-e,r=.8*a/o,i=o>1?.12*r:0;return[e+.2*a/2+n*r+i/2,r-i]}function hi(e,t=!0){let n=t?0:1/0,o=t?0:-1/0;for(const a of e)null!==a&&Number.isFinite(a)&&(n=Math.min(n,a),o=Math.max(o,a));return n===1/0?void 0:[n,o]}const ui={hour:66,day:52,week:52,month:60};nt("en",{editor_style_column:"Column",editor_group_by_auto:"Automatic",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_range:"Lowest to highest in each column",editor_rounded:"Rounded ends",editor_column_labels:"Show values on the columns",editor_column_color:"Colour"});const mi={hour:"hour",day:"day",week:"day",month:"month"};function pi(e,t,n,o){if("hour"===n)return`${Sn(e,o)} – ${An(t,o)}`;const a="day"===n?{weekday:"short",day:"numeric",month:"short"}:"week"===n?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let r;try{r=new Intl.DateTimeFormat(o?.locale?.language??"en",{...a,timeZone:$n(o)})}catch{r=new Intl.DateTimeFormat("en",a)}return"week"===n?r.formatRange(e,t-1):r.format(e)}const fi=(e,t)=>Math.round(e/t*1e4)/100+"%",gi=e=>Math.min(Co,Math.max(0,e));function yi(e,t,n,o,a,r,i,s){const l=gi(e.y(a)),c=gi(e.y(o));return R`
`,validate:e=>qs(e,"bubble"),editor:tl("bubble"),entityFields:()=>nl(),disconnect:Js,render:e=>Ys(e,"bubble")},il=["auto","none","hour","day","week","month"],sl=["auto","mean","sum","min","max","first","last","change"],ll={last:7,unit:"day"},cl={hour:1/24,day:1,week:7,month:30};function dl(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*cl[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function ul(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function hl(e,t){const o={...e,period:e.period??{...ll}};return"none"===e.group_by?delete o.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(o.group_by=dl($a(o.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(o.aggregate=!0!==e.range&&t.length>0&&t.every(ul)?"change":"mean"),o}function pl(e,t){const o=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t),a=e.map(e=>new Map(e.map(e=>[e.t,e]))),n=new Map;for(const i of e)for(const e of i)void 0!==e.end&&n.set(e.t,e.end);const r={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return o.forEach((i,s)=>{const l=Math.min(n.get(i)??o[s+1]??t,t);l>i&&(r.times.push(i),r.ends.push(l),e.forEach((e,t)=>{const o=a[t].get(i);r.values[t].push(o?o.v:Jn(e,i)),r.lows[t].push(o?.min??null),r.highs[t].push(o?.max??null)}))}),r}function ml(e,t,o,a){const n=t-e,r=.8*n/a,i=a>1?.12*r:0;return[e+.2*n/2+o*r+i/2,r-i]}function fl(e,t=!0){let o=t?0:1/0,a=t?0:-1/0;for(const n of e)null!==n&&Number.isFinite(n)&&(o=Math.min(o,n),a=Math.max(a,n));return o===1/0?void 0:[o,a]}const gl={hour:66,day:52,week:52,month:60};yt("en",{editor_style_column:"Column",editor_group_by_auto:"Automatic",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_range:"Lowest to highest in each column",editor_rounded:"Rounded ends",editor_column_labels:"Show values on the columns",editor_column_color:"Colour"});const vl={hour:"hour",day:"day",week:"day",month:"month"};function yl(e,t,o,a){if("hour"===o)return`${Qo(e,a)} – ${Ko(t,a)}`;const n="day"===o?{weekday:"short",day:"numeric",month:"short"}:"week"===o?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let r;try{r=new Intl.DateTimeFormat(a?.locale?.language??"en",{...n,timeZone:Yo(a)})}catch{r=new Intl.DateTimeFormat("en",n)}return"week"===o?r.formatRange(e,t-1):r.format(e)}const bl=(e,t)=>Math.round(e/t*1e4)/100+"%",_l=e=>Math.min(dn,Math.max(0,e));function xl(e,t,o,a,n,r,i,s){const l=_l(e.y(n)),c=_l(e.y(a));return R`
`}function vi(e,t,n,o,a,r){return R`
`}function wl(e,t,o,a,n,r){return R`${Io(o,a,e.hass)}`}function _i(e,t,n,o){const a=e.y(o);return a<0||a>Co?R``:R`
${kn(a,n,e.hass)}`}function $l(e,t,o,a){const n=e.y(a);return n<0||n>dn?R``:R`
`}const bi=ka.map(e=>"period_last"===e.name?{...e,default:ai.last}:"period_unit"===e.name?{...e,default:ai.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),xi=[...bi,{name:"stack",path:["stack"],default:"none"},{name:"color_by",path:["color_by"],default:"series"},{name:"range",path:["range"],default:!1},{name:"column_labels",path:["labels"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Ca,...Sa];const $i=[{name:"column_color",path:["color"]}],wi=r` + style="left:${bl(t-.1*o,cn)};width:${bl(1.2*o,cn)};top:${bl(n,dn)}" + >
`}const kl=xi.map(e=>"period_last"===e.name?{...e,default:ll.last}:"period_unit"===e.name?{...e,default:ll.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),Ml=[...kl,{name:"stack",path:["stack"],default:"none"},{name:"color_by",path:["color_by"],default:"series"},{name:"range",path:["range"],default:!1},{name:"column_labels",path:["labels"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Si,...zi];const zl=[{name:"column_color",path:["color"]}],Al=r` .columns { position: absolute; inset: 0; @@ -922,53 +1299,229 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .axes-inside .x-axis { z-index: 1; } -`,ki={layout:"chart",group:"history",height:240,styles:r` - ${Bo} - ${Jo} - ${$a} - ${oa} - ${wi} - `,thumbnail:()=>V` +`,Sl={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + ${Al} + `,thumbnail:()=>Z` - `,validate:function(e){if(void 0!==e.group_by&&!(e=>ni.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${ni.join(", ")}`);if(void 0!==e.aggregate&&!oi.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${oi.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,To(t),sn(e),ga(e),Qo(e),ra(e);for(const n of["range","rounded","labels"])if(void 0!==e[n]&&"boolean"!=typeof e[n])throw new Error(`"${n}" must be true or false`);if(void 0!==e.color_by&&"series"!==e.color_by&&"bands"!==e.color_by)throw new Error('"color_by" must be one of: series, bands')},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Ta(`column|${Ma(n)}|${t}`,e.hass,()=>[...Aa(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:wa(e.hass,"group_by",ni)},...t?[]:[{name:"aggregate",selector:wa(e.hass,"aggregate",oi)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:wa(e.hass,"stack",aa)},{name:"color_by",selector:wa(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja({...e,commit:t=>e.commit(function(e){const t=e.period;return t&&"object"==typeof t&&void 0===t.calendar?void 0!==t.last&&void 0!==t.unit?e:{...e,period:{last:ai.last,unit:ai.unit,...t}}:e}(t))},"chart",o,xi)},entityFields:e=>({schema:Ta("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:$i}),disconnect(e){Po(e),Wo(e),pa(e)},render:function(e){const t={...e,raw:li(e.raw,e.items)},n=t.raw,o=!0===n.range,a=jo(t,Date.now(),{range:o}),r=Uo(a,t),i=Do(a,t),s=r===W&&i===W?W:R`${r}${i}`,l=Ho(a,t),c=ea(n);if(l)return ta("over"===c.header?{...c,header:"above"}:c,s,l);const d=o?"none":n.stack??"none",h=a.group,u=ci(a.series.map(e=>e.points),h?1/0:a.range.end),m=h&&u.ends.length?{...a.range,end:Math.max(a.range.end,...u.ends)}:a.range,p="none"===d?void 0:sa(u.values,d),f=a.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=p?ca(p):o?hi([...u.lows.flat(),...u.highs.flat(),...u.values.flat(),...f],!1):hi([...u.values.flat(),...t.config.show.target?f:[]]);let y=n;"percent"===d?y={y_min:g[0]<0?-100:0,y_max:g[1]>0?100:0,...n}:!o&&g&&(y=function(e,t){const n={...t};return void 0===t.y_min&&e[0]>=0&&(n.y_min=0),void 0===t.y_max&&e[1]<=0&&(n.y_max=0),n}(g,n));let v=Yo({...t,raw:y},m,g);const{scales:_}=v,b=u.times.map((e,t)=>(e+u.ends[t])/2),x=new Map(b.map((e,t)=>[e,t]));if(h&&b.length){const e=($=b.length,w=Zo(t)/ui[h.unit],Math.max(1,Math.ceil($/Math.max(1,w)))),n=mi[h.unit],o="day"===h.unit&&b.length<=8?6048e5:a.range.end-a.range.start;v={...v,unit:n,times:b.filter((t,n)=>n%e===0),label:e=>En(u.times[x.get(e)],n,o,t.hass)}}var $,w;const k=function(e,t,n,o,a,r){const i=e.raw,s=t.series,l=s.length,c=!0===i.range,d="bands"===i.color_by,h=Zo(e)/Eo,u=!0===i.labels&&"percent"!==a&&n.times.length*(r?1:l)<=60,m=e.config.show.target&&"percent"!==a,p=Math.min(Math.max(0,o.domain[0]),o.domain[1]),f=(e,t)=>d?ze(je(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},y=r?la(n.values):[];return n.times.forEach((t,a)=>{const i=o.x(t),d=o.x(n.ends[a]);if(r){const[t,l]=di(i,d,0,1);let c=-1,p=-1;r.forEach((e,t)=>{const n=e[a];n&&n[1]>n[0]&&(c=t),n&&n[1]{const i=e[a];if(!i||i[0]===i[1])return;const d=n.values[r][a],h=r===c?"top":r===p?"bottom":"";g.columns.push(yi(o,t,l,Math.min(...i),Math.max(...i),f(s[r],d),h,r))});const v=y[a];if(u&&null!==v&&l*h>=24){const n=v>=0?Math.max(0,...r.map(e=>e[a]?.[1]??0)):Math.min(0,...r.map(e=>e[a]?.[1]??0)),i=Math.max(...s.map(e=>e.entity.decimals));g.labels.push(vi(e,t+l/2,o.y(n),v,i,v<0))}const _=s[0].entity.target;return void(m&&null!==_&&g.targets.push(_i(o,t,l,_)))}s.forEach((t,r)=>{const s=n.values[r][a];if(null===s)return;const[y,v]=di(i,d,r,l);if(c){const e=n.lows[r][a]??s,i=n.highs[r][a]??s;g.columns.push(yi(o,y,v,e,i,f(t,s),"both",r)),g.columns.push(R`
`,validate:function(e){if(void 0!==e.group_by&&!(e=>il.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${il.join(", ")}`);if(void 0!==e.aggregate&&!sl.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${sl.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,pn(t),Lo(e),lr(e),Zn(e),Xn(e);for(const o of["range","rounded","labels"])if(void 0!==e[o]&&"boolean"!=typeof e[o])throw new Error(`"${o}" must be true or false`);if(void 0!==e.color_by&&"series"!==e.color_by&&"bands"!==e.color_by)throw new Error('"color_by" must be one of: series, bands')},editor:function(e){const t="none"===e.config.group_by,o={...e.config,group_by:void 0},a=Fi(`column|${wi(o)}|${t}`,e.hass,()=>[...$i(e.hass,o).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:_i(e.hass,"group_by",il)},...t?[]:[{name:"aggregate",selector:_i(e.hass,"aggregate",sl)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:_i(e.hass,"stack",Gn)},{name:"color_by",selector:_i(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...Ei(e.hass),...Ai(e.hass)]);return Pi({...e,commit:t=>e.commit(function(e){const t=e.period;return t&&"object"==typeof t&&void 0===t.calendar?void 0!==t.last&&void 0!==t.unit?e:{...e,period:{last:ll.last,unit:ll.unit,...t}}:e}(t))},"chart",a,Ml)},entityFields:e=>({schema:Fi("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:zl}),disconnect(e){bn(e),Fn(e),ir(e)},render:function(e){const t={...e,raw:hl(e.raw,e.items)},o=t.raw,a=!0===o.range,n=mn(t,Date.now(),{range:a}),r=vn(n,t),i=_n(n,t),s=r===W&&i===W?W:R`${r}${i}`,l=xn(n,t),c=qn(o);if(l)return Wn("over"===c.header?{...c,header:"above"}:c,s,l);const d=a?"none":o.stack??"none",u=n.group,h=pl(n.series.map(e=>e.points),u?1/0:n.range.end),p=u&&h.ends.length?{...n.range,end:Math.max(n.range.end,...h.ends)}:n.range,m="none"===d?void 0:Kn(h.values,d),f=n.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=m?er(m):a?fl([...h.lows.flat(),...h.highs.flat(),...h.values.flat(),...f],!1):fl([...h.values.flat(),...t.config.show.target?f:[]]);let v=o;"percent"===d?v={y_min:g[0]<0?-100:0,y_max:g[1]>0?100:0,...o}:!a&&g&&(v=function(e,t){const o={...t};return void 0===t.y_min&&e[0]>=0&&(o.y_min=0),void 0===t.y_max&&e[1]<=0&&(o.y_max=0),o}(g,o));let y=Dn({...t,raw:v},p,g);const{scales:b}=y,_=h.times.map((e,t)=>(e+h.ends[t])/2),x=new Map(_.map((e,t)=>[e,t]));if(u&&_.length){const e=(w=_.length,$=Pn(t)/gl[u.unit],Math.max(1,Math.ceil(w/Math.max(1,$)))),o=vl[u.unit],a="day"===u.unit&&_.length<=8?6048e5:n.range.end-n.range.start;y={...y,unit:o,times:_.filter((t,o)=>o%e===0),label:e=>ea(h.times[x.get(e)],o,a,t.hass)}}var w,$;const k=function(e,t,o,a,n,r){const i=e.raw,s=t.series,l=s.length,c=!0===i.range,d="bands"===i.color_by,u=Pn(e)/cn,h=!0===i.labels&&"percent"!==n&&o.times.length*(r?1:l)<=60,p=e.config.show.target&&"percent"!==n,m=Math.min(Math.max(0,a.domain[0]),a.domain[1]),f=(e,t)=>d?Ce(Te(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},v=r?Qn(o.values):[];return o.times.forEach((t,n)=>{const i=a.x(t),d=a.x(o.ends[n]);if(r){const[t,l]=ml(i,d,0,1);let c=-1,m=-1;r.forEach((e,t)=>{const o=e[n];o&&o[1]>o[0]&&(c=t),o&&o[1]{const i=e[n];if(!i||i[0]===i[1])return;const d=o.values[r][n],u=r===c?"top":r===m?"bottom":"";g.columns.push(xl(a,t,l,Math.min(...i),Math.max(...i),f(s[r],d),u,r))});const y=v[n];if(h&&null!==y&&l*u>=24){const o=y>=0?Math.max(0,...r.map(e=>e[n]?.[1]??0)):Math.min(0,...r.map(e=>e[n]?.[1]??0)),i=Math.max(...s.map(e=>e.entity.decimals));g.labels.push(wl(e,t+l/2,a.y(o),y,i,y<0))}const b=s[0].entity.target;return void(p&&null!==b&&g.targets.push($l(a,t,l,b)))}s.forEach((t,r)=>{const s=o.values[r][n];if(null===s)return;const[v,y]=ml(i,d,r,l);if(c){const e=o.lows[r][n]??s,i=o.highs[r][n]??s;g.columns.push(xl(a,v,y,e,i,f(t,s),"both",r)),g.columns.push(R`
`)}else g.columns.push(yi(o,y,v,Math.min(p,s),Math.max(p,s),f(t,s),s>=p?"top":"bottom",r)),u&&v*h>=24&&g.labels.push(vi(e,y+v/2,o.y(s),s,t.entity.decimals,s({...e,points:b.map((e,n)=>({t:e,v:u.values[t][n],min:u.lows[t][n],max:u.highs[t][n]}))})),A={dotY:()=>null,heading:e=>{const n=x.get(e);return void 0===n?Sn(e,t.hass):h?pi(u.times[n],u.ends[n],h.unit,t.hass):Sn(u.times[n],t.hass)}},S=(e,n)=>Io(e,n.entity.decimals,t.hass);if(o&&(A.text=(e,t)=>{if(!t||null===t.v)return"—";const n=t.min??t.v,o=t.max??t.v;return Ro(`${S(n,e)} – ${S(o,e)}`,e.entity.unit)}),p){const e=la(u.values),n=_a(M),o=Math.max(...M.map(e=>e.entity.decimals));A.footer=a=>ba(t,e[x.get(a[0]?.p?.t??NaN)??-1]??null,n,o),"percent"===d&&(A.text=(e,t)=>{const n=t?p[e.index][x.get(t.t)??-1]:null;return t&&null!==t.v&&n?`${Ro(S(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(n[1]-n[0]))} %`:"—"})}const E=fa(n),C=E?ma(t.host):void 0,z=void 0===C?void 0:x.get(C),L=void 0===z?W:R`
`)}else g.columns.push(xl(a,v,y,Math.min(m,s),Math.max(m,s),f(t,s),s>=m?"top":"bottom",r)),h&&y*u>=24&&g.labels.push(wl(e,v+y/2,a.y(s),s,t.entity.decimals,s({...e,points:_.map((e,o)=>({t:e,v:h.values[t][o],min:h.lows[t][o],max:h.highs[t][o]}))})),z={dotY:()=>null,heading:e=>{const o=x.get(e);return void 0===o?Qo(e,t.hass):u?yl(h.times[o],h.ends[o],u.unit,t.hass):Qo(h.times[o],t.hass)}},A=(e,o)=>kn(e,o.entity.decimals,t.hass);if(a&&(z.text=(e,t)=>{if(!t||null===t.v)return"—";const o=t.min??t.v,a=t.max??t.v;return Mn(`${A(o,e)} – ${A(a,e)}`,e.entity.unit)}),m){const e=Qn(h.values),o=ur(M),a=Math.max(...M.map(e=>e.entity.decimals));z.footer=n=>hr(t,e[x.get(n[0]?.p?.t??NaN)??-1]??null,o,a),"percent"===d&&(z.text=(e,t)=>{const o=t?m[e.index][x.get(t.t)??-1]:null;return t&&null!==t.v&&o?`${Mn(A(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(o[1]-o[0]))} %`:"—"})}const S=sr(o),E=S?rr(t.host):void 0,C=void 0===E?void 0:x.get(E),j=void 0===C?W:R`
`,T=at(t.hass,"chart_aria",{style:at(t.hass,"editor_style_column"),names:a.series.map(e=>e.entity.name).join(", ")}),j=!1===n.rounded?0:4;return ta(c,s,Xo({axes:v,ctx:t,content:V``,label:T,overlay:R`
${L}${k.columns}${k.targets}
+ style="left:${bl(b.x(h.times[C]),cn)};width:${bl(b.x(h.ends[C])-b.x(h.times[C]),cn)}" + >
`,N=_t(t.hass,"chart_aria",{style:_t(t.hass,"editor_style_column"),names:n.series.map(e=>e.entity.name).join(", ")}),L=!1===o.rounded?0:4;return Wn(c,s,In({axes:y,ctx:t,content:Z``,label:N,overlay:R`
${j}${k.columns}${k.targets}
${k.labels}
- ${"over"===c.header?na(s):W} - ${E?xa(t,M,_,C,Zo(t),A):W}`,hover:E?ya(t,m,M.map(e=>e.points)):void 0}))}},Mi=["series","bands"];function Ai(e,t,n,o,a){const r=ct(e.entity.config,a),i=Cn(e.points,500),s=So(i).map(n=>Ao(n.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=r.dots?i.filter(e=>null!==e.v&&e.t!==o.start&&e.t!==o.end):[],c=l.length>0&&l.length<=200?V`e.points)):void 0}))}};yt("en",{zoom_reset:"Reset zoom",zoom_window:"Shown: {from} to {to}",zoom_window_label:"Part of the chart shown",editor_zoom:"Zoom and pan (pinch, Ctrl + scroll or drag)",editor_overview:"Overview strip below the chart"});const El={from:0,to:1};const Cl=(e,t,o)=>Math.min(o,Math.max(t,e));function jl(e,t){const o=Cl(t,0,1);let a=Cl(e.to-e.from,o,1);Number.isFinite(a)||(a=1);const n=Cl(Number.isFinite(e.from)?e.from:0,0,1-a);return{from:n,to:n+a}}function Nl(e){return e.from>1e-9||e.to<1-1e-9}function Ll(e,t,o,a){const n=e.to-e.from,r=e.from+Cl(t,0,1)*n,i=Cl(n/o,a,1),s=r-Cl(t,0,1)*i;return jl({from:s,to:s+i},a)}function Tl(e,t){const o=e.to-e.from,a=Cl(e.from+t,0,1-o);return{from:a,to:a+o}}function Ol(e,t,o,a){const n=e.to-e.from,r=e.from+Cl(Math.min(t,o),0,1)*n,i=e.from+Cl(Math.max(t,o),0,1)*n;if(i-r>=a)return{from:r,to:i};const s=(r+i)/2;return jl({from:s-a/2,to:s+a/2},a)}function Fl(e,t){const o=e.end-e.start;return{start:e.start+t.from*o,end:e.start+t.to*o}}function Pl(e,t){if(0===e.length)return e;let o=0,a=e.length;for(;o>1;(e[n].end??e[n].t)0?Math.min(1,function(e){return e.group?2*{hour:36e5,day:864e5,week:6048e5,month:2592e6}[e.group.unit]:3e5}(t)/n):1,i=`${JSON.stringify(t.period)}|${t.back}|${t.group?.unit??""}|${t.source}`,s=function(e,t){let o=Dl.get(e);return o&&o.key===t||(o={key:t,window:El,pointers:new Map},Dl.set(e,o)),o}(e.host,i);o.zoom||o.overview||(s.window=El);const l=jl(s.window,r),c=t=>{const o=jl(t,r);o.from===s.window.from&&o.to===s.window.to||(s.window=o,e.requestUpdate())};return{options:o,window:l,zoomed:Nl(l),range:Fl(t.range,l),minSpan:r,current:()=>jl(s.window,r),set:c,reset:()=>c(El)}}const Hl=e=>{const t=e.currentTarget.getBoundingClientRect();return{f:t.width>0?Cl((e.clientX-t.left)/t.width,0,1):0,width:t.width}};function Il(e){try{e.currentTarget.setPointerCapture?.(e.pointerId)}catch{}}const Bl=e=>e?.leave({pointerType:"mouse"});function Rl(e,t,o){const a=Dl.get(e.host),n=()=>{a.gesture=void 0,a.select&&(a.select=void 0,e.requestUpdate())};return{down(e){const{f:n,width:r}=Hl(e);if(a.pointers.set(e.pointerId,n),2===a.pointers.size){const[e,n]=[...a.pointers.values()];return a.gesture={kind:"pinch",w0:t.current(),start:e<=n?[e,n]:[n,e]},a.select=void 0,void Bl(o)}if(!(a.pointers.size>2)){if("mouse"===e.pointerType&&0===e.button){const o=t.current();a.gesture={kind:Nl(o)?"pan":"select",x0:n,w0:o,moved:!1,px:r},Il(e)}o?.move(e)}},move(n){if(!a.pointers.has(n.pointerId))return void o?.move(n);const{f:r}=Hl(n);a.pointers.set(n.pointerId,r);const i=a.gesture;if("pinch"===i?.kind){if(a.pointers.size<2)return;const[e,o]=[...a.pointers.values()];return void t.set(function(e,t,o,a){const n=e.to-e.from,r=e.from+t[0]*n,i=e.from+t[1]*n,s=o[1]-o[0];if(Math.abs(s)<.02||Math.abs(t[1]-t[0])<.02)return Tl(e,-((o[0]+o[1])/2-(t[0]+t[1])/2)*n);const l=Cl((i-r)/s,a,1),c=r-o[0]*l;return jl({from:c,to:c+l},a)}(i.w0,i.start,e<=o?[e,o]:[o,e],t.minSpan))}if(i&&("pan"===i.kind||"select"===i.kind))return!i.moved&&Math.abs(r-i.x0)*i.px<4?void o?.move(n):(i.moved||Bl(o),i.moved=!0,void("pan"===i.kind?t.set(Tl(i.w0,-(r-i.x0)*(i.w0.to-i.w0.from))):(a.select=[i.x0,r],e.requestUpdate())));o?.move(n)},up(e){const{f:o,width:r}=Hl(e),i=a.gesture;if(a.pointers.delete(e.pointerId),"pinch"===i?.kind)return void(0===a.pointers.size&&(a.gesture=void 0));"select"===i?.kind&&i.moved&&t.set(Ol(i.w0,i.x0,o,t.minSpan));const s=!i||!("moved"in i)||!i.moved;if(n(),"mouse"!==e.pointerType&&s){const n=e.timeStamp||Date.now(),i=a.lastTap;i&&n-i.at<300&&Math.abs(o-i.x)*r<30?(a.lastTap=void 0,t.reset()):a.lastTap={at:n,x:o}}},cancel(e){a.pointers.delete(e.pointerId),"pinch"===a.gesture?.kind&&a.pointers.size>=2||(n(),o?.leave(e))},leave(e){a.gesture&&"mouse"===e.pointerType||(a.pointers.delete(e.pointerId),o?.leave(e))},wheel(e){const{f:o}=Hl(e);if(e.ctrlKey||e.metaKey){e.preventDefault();const a=1===e.deltaMode?16*e.deltaY:e.deltaY;return void t.set(Ll(t.current(),o,Math.exp(-Cl(a,-100,100)/200),t.minSpan))}const a=e.shiftKey&&0===e.deltaX?e.deltaY:e.deltaX,n=t.current();if(Nl(n)&&Math.abs(a)>Math.abs(e.shiftKey?0:e.deltaY)){e.preventDefault();const{width:o}=Hl(e);t.set(Tl(n,a/Math.max(o,1)*(n.to-n.from)))}},dblclick(){t.reset()}}}function Zl(e,t){const o=Dl.get(e.host),a=o?.select,n=a?R`
`:W,r=t.zoomed?R``:W;return n===W&&r===W?W:R`${n}${r}`}const ql="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z";function Wl(e,t){const o=xo(e.map(e=>e.points));if(!o)return Z``;const[a,n]=wo(o),r=$o([t.start,t.end],[0,cn]),i=$o([a,n],[38,2]);return Z`${e.map(e=>{const t=ln(ta(e.points,200)).map(e=>1===e.length?`M${r(e[0].t).toFixed(1)},${i(e[0].v).toFixed(1)}h0.5`:`M${e.map(e=>`${r(e.t).toFixed(1)},${i(e.v).toFixed(1)}`).join("L")}`).join("");return Z``})}`}function Vl(e,t,o,a){const n=Dl.get(e.host),r=t.window,i=e=>Hl(e),s=Fl(o,r),l=_t(e.hass,"zoom_window",{from:Qo(s.start,e.hass),to:Qo(s.end,e.hass)}),c=e=>Math.round(1e4*e)/100+"%";return R`
{if(e.button>0)return;e.stopPropagation();const{f:o,width:a}=i(e),r=t.current(),s=10/Math.max(a,1);let l="new";Nl(r)&&(Math.abs(o-r.from)<=s?l="left":Math.abs(o-r.to)<=s?l="right":o>r.from&&o{const o=n.gesture;if(!o||!("f0"in o))return;const{f:a}=i(e);if(!o.moved&&Math.abs(a-o.f0)*o.px<4)return;o.moved=!0;const{w0:r}=o;"move"===o.kind?t.set(Tl(r,a-o.f0)):"left"===o.kind?t.set({from:Math.min(a,r.to-t.minSpan),to:r.to}):"right"===o.kind?t.set({from:r.from,to:Math.max(a,r.from+t.minSpan)}):t.set(Ol(El,o.f0,a,t.minSpan))}} + @pointerup=${()=>{const e=n.gesture;if(e&&"f0"in e&&(n.gesture=void 0,!e.moved&&"new"===e.kind&&Nl(e.w0))){const o=e.w0.to-e.w0.from;t.set({from:e.f0-o/2,to:e.f0+o/2})}}} + @pointercancel=${()=>n.gesture=void 0} + @dblclick=${e=>{e.stopPropagation(),t.reset()}} + > + +
+
+
{const o=t.current(),a=o.to-o.from,n=.5,r={ArrowLeft:()=>Tl(o,-a/4),ArrowRight:()=>Tl(o,a/4),ArrowUp:()=>Ll(o,n,1.5,t.minSpan),ArrowDown:()=>Ll(o,n,1/1.5,t.minSpan),"+":()=>Ll(o,n,1.5,t.minSpan),"=":()=>Ll(o,n,1.5,t.minSpan),"-":()=>Ll(o,n,1/1.5,t.minSpan),Home:()=>Tl(o,-1),End:()=>Tl(o,1),Escape:()=>El}[e.key];r&&(e.preventDefault(),e.stopPropagation(),t.set(r()))}} + > + +
+
`}const Yl=r` + .plot-area.zoomable { + touch-action: pan-y; + } + .plot-area.zoomable.zoomed { + cursor: grab; + } + .plot-area.zoomable.zoomed:active { + cursor: grabbing; + } + .zoom-select { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-color) 15%, transparent); + border-left: 1px solid var(--primary-color); + border-right: 1px solid var(--primary-color); + pointer-events: none; + z-index: 1; + } + .zoom-reset { + position: absolute; + top: 2px; + right: 2px; + /* Under the tooltip: the box only covers it while the pointer is away from the corner. */ + z-index: 1; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px 2px 6px; + font: inherit; + font-size: 11px; + line-height: 18px; + color: var(--primary-color); + background: color-mix(in srgb, var(--card-background-color, #fff) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent); + border-radius: 12px; + cursor: pointer; + } + .zoom-reset svg { + width: 14px; + height: 14px; + fill: currentColor; + } + .zoom-reset:hover { + background: color-mix(in srgb, var(--primary-color) 12%, var(--card-background-color, #fff)); + } + .zoom-reset:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .frame.compact .zoom-reset span { + display: none; + } + .frame.compact .zoom-reset { + padding: 2px; + } + .overview { + position: relative; + grid-column: 2; + grid-row: 3; + height: 32px; + margin-top: 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--primary-text-color) 4%, transparent); + touch-action: none; + cursor: crosshair; + user-select: none; + -webkit-user-select: none; + } + .frame.compact .overview { + height: 22px; + margin-top: 4px; + } + .overview-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + .overview-lines path { + stroke-width: 1; + vector-effect: non-scaling-stroke; + opacity: 0.8; + } + .overview-shade { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--card-background-color, #fff) 60%, transparent); + pointer-events: none; + } + .overview-window { + position: absolute; + top: 0; + bottom: 0; + box-sizing: border-box; + border: 1px solid color-mix(in srgb, var(--primary-color) 70%, transparent); + border-radius: 4px; + background: color-mix(in srgb, var(--primary-color) 8%, transparent); + cursor: grab; + } + .overview:not(.zoomed) .overview-window { + border-color: transparent; + background: none; + cursor: crosshair; + } + .overview-window:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .overview-handle { + position: absolute; + top: 50%; + width: 4px; + height: 14px; + border-radius: 2px; + background: var(--primary-color); + transform: translate(-50%, -50%); + cursor: ew-resize; + } + .overview-handle.left { + left: 0; + } + .overview-handle.right { + left: 100%; + } + .overview:not(.zoomed) .overview-handle { + display: none; + } + .plot-wrap.has-overview { + grid-template-rows: minmax(0, 1fr) auto auto; + height: 220px; + } + .frame.compact .plot-wrap.has-overview { + height: 140px; + } +`,Gl="chartdeck-sync";const Xl=new WeakMap;function Jl(e,t){const o=Xl.get(e);o&&window.dispatchEvent(new CustomEvent(Gl,{detail:{group:o.group,source:e,message:t}}))}function Kl(e){const t=Xl.get(e);t&&window.removeEventListener(Gl,t.listener),Xl.delete(e)}function Ql(e,t,o){const a="string"==typeof(n=e.raw).sync&&""!==n.sync.trim()?n.sync.trim():void 0;var n;let r=Xl.get(e.host);if(!a)return void(r&&Kl(e.host));if(!r||r.group!==a){r&&Kl(e.host);const t={group:a,handlers:new Map,ctx:e,listener:()=>{}};t.listener=e=>function(e,t){if(!t||t.group!==e.group||t.source===e.ctx.host)return;const o=t.message;if("hover"===o.type){if(o.t===e.remote)return;e.remote=o.t,e.ctx.requestUpdate()}else if("period"===o.type){const t={period:JSON.parse(o.period)};if(o.period!==e.period||!t.period.navigate||Aa(e.ctx.host,t.period)===o.steps)return;Sa(e.ctx.host,t.period,o.steps),e.steps=o.steps,e.ctx.requestUpdate()}e.handlers.get(o.type)?.(o)}(t,e.detail),window.addEventListener(Gl,t.listener),Xl.set(e.host,t),r=t}r.ctx=e,o!==r.sent&&(r.sent=o,Jl(e.host,{type:"hover",t:o}));const i=(e=>JSON.stringify(e.period))(t),s=t.period.navigate?Aa(e.host,t.period):0;return r.period===i&&r.steps!==s&&Jl(e.host,{type:"period",period:i,steps:s}),r.period=i,r.steps=s,void 0===o?r.remote:void 0}const ec=["series","bands"];function tc(e,t,o){const a=e=>{const t=(dn-o.y(e))/dn;return Number.isFinite(t)?Math.min(1,Math.max(0,t)):0},n=[];return t.forEach((e,o)=>{const r=Number.isFinite(e.from)?a(e.from):0,i=o+1`)}),Z` + ${n} + `}function oc(e,t,o,a,n){const r=Ct(e.entity.config,n),i=ta(e.points,500),s=ln(i).map(o=>sn(o.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=r.dots?i.filter(e=>null!==e.v&&e.t!==a.start&&e.t!==a.end):[],c=l.length>0&&l.length<=200?Z`[t.x(e.t),t.y(e.v)]))} + d=${Nt(l.map(e=>[t.x(e.t),t.y(e.v)]))} fill="none" - stroke=${n} - style="stroke-width:${ut(r.width)}px" - >`:W;return V` + stroke=${o} + style="stroke-width:${Lt(r.width)}px" + >
`:W;return Z` ${c} - `}function Si(e,t,n){return R`
- ${e.series.flatMap(o=>{const a=o.points.filter(t=>null!==t.v&&(void 0!==e.group||t.t!==e.range.start&&t.t!==e.range.end));return a.length>40?[]:a.map(e=>R`${Io(e.v,o.entity.decimals,n.hass)}`)})} -
`}nt("en",{editor_style_line:"Line",chart_loading:"Loading history…",chart_error:"Couldn't load history: {error}",chart_no_data:"No readings in this period.",chart_aria:"{style} chart of {names}"});const Ei={layout:"chart",group:"history",height:240,styles:r` - ${Bo} - ${Jo} - ${$a} - ${oa} + `}function ac(e,t,o,a,n,r,i){const{range:s}=e;return kr(t.map(e=>({points:e.points.filter(e=>e.t>=o.start&&e.t<=o.end),decimals:e.entity.decimals,color:e.color})),n,{at:e=>[a.x(e.t)/cn,a.y(e.v)/dn],width:Pn(i),height:r?96:162,skip:t=>void 0===e.group&&(t.t===s.start||t.t===s.end)},i.hass)}function nc(e,t){const o="outside"===t.labels;return(e?110:180)-(o&&t.x?e?14:18:0)-(o&&t.y?6:0)}function rc(e,t,o){const a=e.series.reduce((e,t)=>e+t.entity.name.length+t.entity.display.length+6,0);return{l:0,t:0,r:Math.min(o,8*a),b:t?24:34}}yt("en",{editor_style_line:"Line",chart_loading:"Loading history…",chart_error:"Couldn't load history: {error}",chart_no_data:"No readings in this period.",chart_aria:"{style} chart of {names}",chart_log_nothing:"Nothing above zero to draw on a log scale."});const ic={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + ${Yl} + ${hi} + ${Mr} + ${Fr} + ${qr} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -979,21 +1532,21 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .style-line .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,validate(e){if(To(e),sn(e),ga(e),ft(e),Qo(e),void 0!==e.color_by&&!Mi.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Mi.join(", ")}`)},editor:function(e){const t=Ta(`line|${Ma(e.config)}`,e.hass,()=>[...Aa(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:wa(e.hass,"curve",wo)},{name:"color_by",selector:wa(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}}]},{name:"labels",selector:{boolean:{}}},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",t,Oa)},entityFields:(e,t)=>({schema:jt(e),fields:At(!0===t.dots)}),disconnect(e){Po(e),Wo(e),pa(e)},render(e){const t=jo(e),n=Uo(t,e),o=Do(t,e),a=n===W&&o===W?W:R`${n}${o}`,r=Ho(t,e),i=ea(e.raw);if(r)return ta("over"===i.header?{...i,header:"above"}:i,a,r);const s=Yo(e,t.range,Jt(t.series.map(e=>e.points))),{scales:l}=s,c=fa(e.raw),d="bands"===e.raw.color_by,h=d?V`${t.series.map(e=>function(e,t,n){const[o,a]=n.domain,r=e=>Math.min(1,Math.max(0,(e-o)/(a-o))),i=[];return t.forEach((e,n)=>{const o=Number.isFinite(e.from)?r(e.from):0,a=n+1`)}),V` - ${i} - `}(`cd-bands-${e.index}`,e.entity.bands,l))}`:W,u=dt(t.series,t=>ct(t.entity.config,e.raw).onTop),m=V`${u.map(n=>Ai(n,l,d?`url(#cd-bands-${n.index})`:n.color,t.range,e.raw))}`,p=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return ta(i,a,Xo({axes:s,ctx:e,content:m,label:p,defs:h,overlay:R`${"over"===i.header?na(a):W} - ${!0===e.raw.labels?Si(t,l,e):W} - ${c?xa(e,t.series,l,ma(e.host),Zo(e)):W}`,hover:c?ya(e,t.range,t.series.map(e=>e.points)):void 0}))}},Ci=["line","column","area"],zi=["auto","left","right"];nt("en",{editor_style_combined:"Combined",editor_series_type:"Draw as",editor_series_type_line:"Line",editor_series_type_column:"Columns",editor_series_type_area:"Area",editor_series_axis:"Value axis",editor_series_axis_auto:"Automatic (by unit)",editor_series_axis_left:"Left",editor_series_axis_right:"Right",editor_series_aggregate:"Combine with",editor_series_aggregate_auto:"As the card",editor_series_aggregate_mean:"Average",editor_series_aggregate_sum:"Sum",editor_series_aggregate_min:"Lowest",editor_series_aggregate_max:"Highest",editor_series_aggregate_first:"First",editor_series_aggregate_last:"Last",editor_series_aggregate_change:"Change (meters that count up)"});const Li=e=>e.config,Ti=e=>Li(e).type??"line";function ji(e,t){const n={...e},o=t.some(e=>"column"===Ti(e));return"none"!==e.group_by&&(void 0!==e.group_by&&"auto"!==e.group_by||o)?void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ii(Jn(e.period))):delete n.group_by,"auto"===e.aggregate&&delete n.aggregate,n}function Oi(e,t){const n=e.flatMap(e=>e.points.map(e=>e.v));return hi(n,t.includes("column"))}const Ni=[...ka.map(e=>"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Ca,...Sa];const Ui=[{name:"series_type",path:["type"],default:"line"},{name:"series_axis",path:["y_axis"],default:"auto"},{name:"series_color",path:["color"]},{name:"series_aggregate",path:["aggregate"],default:"auto"},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"negative_color",path:["negative_color"]}],Fi={layout:"chart",group:"history",height:240,styles:r` - ${Bo} - ${Jo} - ${$a} - ${oa} - ${Ra} - ${wi} + stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3" />`,validate(e){if(pn(e),Lo(e),lr(e),Ft(e),Zn(e),function(e){for(const t of["zoom","overview"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`)}(e),ti(e),function(e){const t=e;if(void 0!==t.labels&&"boolean"!=typeof t.labels)throw new Error('"labels" must be true or false');if(void 0!==t.labels_show&&!fr.includes(t.labels_show))throw new Error(`"labels_show" must be one of: ${fr.join(", ")}`);if(void 0!==t.labels_position&&!gr.includes(t.labels_position))throw new Error(`"labels_position" must be one of: ${gr.join(", ")}`);if(void 0!==t.labels_overlap&&"boolean"!=typeof t.labels_overlap)throw new Error('"labels_overlap" must be true or false')}(e),function(e){const t=e.extremes;if(void 0!==t){if("object"!=typeof t||null===t||Array.isArray(t))throw new Error('"extremes" must have "min", "max" (true or false) and "average"');const e=t;for(const t of["min","max"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"extremes.${t}" must be true or false`);if(void 0!==e.average&&!zr.includes(e.average))throw new Error(`"extremes.average" must be one of: ${zr.join(", ")}`)}for(const o of e.entities??[]){if("object"!=typeof o)continue;const e=o.extremes;if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"extremes" of ${o.entity} must be true or false`)}}(e),function(e){if(void 0!==e.sync&&("string"!=typeof e.sync||""===e.sync.trim()))throw new Error('"sync" must be a group name')}(e),function(e){if(void 0!==e.multiples&&"boolean"!=typeof e.multiples)throw new Error('"multiples" must be true or false');if(void 0!==e.multiples_scale&&!Rr.includes(e.multiples_scale))throw new Error(`"multiples_scale" must be one of: ${Rr.join(", ")}`);const t=e.multiples_columns;if(void 0!==t&&!(Number.isInteger(t)&&t>=1&&t<=6))throw new Error('"multiples_columns" must be a whole number from 1 to 6')}(e),void 0!==e.color_by&&!ec.includes(e.color_by))throw new Error(`"color_by" must be one of: ${ec.join(", ")}`)},editor:function(e){const t=Fi(`line|${wi(e.config)}|${o=e.config,!0===o?.labels?"labels":""}|${(e=>!0===e?.multiples?"multiples":"")(e.config)}`,e.hass,()=>{return[...$i(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:_i(e.hass,"curve",an)},{name:"color_by",selector:_i(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:Vt},{name:"dots",selector:{boolean:{}}}]},...Ni(e.hass,e.config),...Ei(e.hass),{name:"y_scale",selector:_i(e.hass,"y_scale",ko)},...Ci,...ji,...(t=e.hass,[{type:"grid",name:"",schema:[{name:"extremes_max",selector:{boolean:{}}},{name:"extremes_min",selector:{boolean:{}}}]},{name:"extremes_average",selector:{select:{mode:"dropdown",options:zr.map(e=>({value:e,label:_t(t,`editor_extremes_average_${e}`)}))}}}]),...Li,...Ai(e.hass),...Ti(e.hass,e.config)];var t});var o;return R`${Pi(e,"chart",t,Di)}${bi(e)}`},entityFields:(e,t)=>{const o=oo(e),a=Gt(!0===t.dots);return jr(Cr(t))?{schema:Hr(o,e),fields:Br(a)}:{schema:o,fields:a}},watch:function(e){const t=new Set;for(const o of oi(e))for(const e of[o.value,o.from,o.to,"state"===o.type?o.entity:void 0])Xr(e)&&t.add(e.trim());return[...t]},disconnect(e){bn(e),Fn(e),ir(e),function(e){Dl.delete(e)}(e),function(e){const t=si.get(e);t&&t.generation++,si.delete(e)}(e),Kl(e)},render(e){const t=mn(e),o=rr(e.host),a=Ql(e,t,o),n=vn(t,e),r=_n(t,e),i=n===W&&r===W?W:R`${n}${r}`,s=xn(t,e),l=qn(e.raw);if(s)return Wn("over"===l.header?{...l,header:"above"}:l,i,s);const c=function(e){if(!0===e.multiples)return{scale:e.multiples_scale??"auto",columns:e.multiples_columns}}(e.raw);if(c)return Wn({...l,header:"above"},n,Zr(e,t,c,o??a,(n,r,i)=>function(e,t,o,a,n,r,i=W,s=rr(e.host)){const{scales:l}=a,c=vr(e.raw),d=sr(e.raw),u="bands"===e.raw.color_by,h=u?Z`${o.map(e=>tc(`cd-bands-${e.index}${r}`,e.entity.bands,l))}`:W,p=jt(o,t=>Ct(t.entity.config,e.raw).onTop),m=Z`${p.map(o=>oc(o,l,u?`url(#cd-bands-${o.index}${r})`:o.color,t.range,e.raw))}`,f=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_line"),names:o.map(e=>e.entity.name).join(", ")});return In({axes:a,ctx:e,content:m,label:f,defs:h,overlay:R`${i} ${c?ac(t,o,t.range,l,c,!0,e):W} + ${d?pr(e,o,l,s,n):W}`,hover:d?cr(e,t.range,o.map(e=>e.points)):void 0})}(e,t,[n],r,i,`-${n.index}`,W,o??a)));const d=Ul(e,t),u=d.range,h=d.zoomed?t.series.map(e=>({...e,points:Pl(e.points,u)})):t.series,p="log"===e.raw.y_scale,m=p?h.map(e=>{return{...e,points:(t=e.points,t.map(e=>{const t=e=>"number"==typeof e&&e<=0;return t(e.v)||t(e.min)||t(e.max)?{...e,v:t(e.v)?null:e.v,...void 0!==e.min?{min:t(e.min)?null:e.min}:{},...void 0!==e.max?{max:t(e.max)?null:e.max}:{}}:e}))};var t}):h,f=(g=e.raw,v=e.hass,oi(g).filter(e=>"line"===e.type).map(e=>ai(e.value,v)).filter(Gr)).filter(e=>!p||e>0).map(e=>({t:u.start,v:e}));var g,v;const y=xo([...m.map(e=>e.points),f]);if(p&&!y){const t=R`
${_t(e.hass,"chart_log_nothing")}
`;return Wn("over"===l.header?{...l,header:"above"}:l,i,t)}const b=Dn(e,u,y),{scales:_}=b,x=sr(e.raw)&&!function(e){const t=Dl.get(e)?.gesture;return!!t&&("pinch"===t.kind||"moved"in t&&t.moved)}(e.host),w="bands"===e.raw.color_by,$=w?Z`${h.map(e=>tc(`cd-bands-${e.index}`,e.entity.bands,_))}`:W,k=jt(m,t=>Ct(t.entity.config,e.raw).onTop),M=Z`${k.map(o=>oc(o,_,w?`url(#cd-bands-${o.index})`:o.color,t.range,e.raw))}`,z=sr(e.raw)?cr(e,u,h.map(e=>e.points)):void 0,A=ui(e,t,_,Pn(e)),S=vr(e.raw),E=Pn(e),C=Or({ctx:e,series:h,scales:_,range:u,group:t.group,timeWeighted:"history"===t.source&&void 0===t.group,width:E,height:nc(l.compact,b.options),until:Math.min(u.end,Date.now()),obstacles:"over"===l.header?[rc(t,l.compact,E)]:[]}),j=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return Wn(l,i,In({axes:b,ctx:e,content:Z`${A.behind}${C.lines}${M}${A.front}`,label:j,defs:$,overlay:R`${"over"===l.header?Vn(i):W} + ${S?ac(t,m,u,_,S,l.compact,e):W} + ${Zl(e,d)} ${A.overlay} ${C.overlay} + ${x?pr(e,h,_,o??a,Pn(e)):W}`,hover:z,gestures:d.options.zoom?Rl(e,d,z):void 0,classes:d.options.zoom?"zoomable "+(d.zoomed?"zoomed":""):"",below:d.options.overview?Vl(e,d,t.range,Wl(t.series,t.range)):W}))}},sc=["line","column","area"],lc=["auto","left","right"];yt("en",{editor_style_combined:"Combined",editor_series_type:"Draw as",editor_series_type_line:"Line",editor_series_type_column:"Columns",editor_series_type_area:"Area",editor_series_axis:"Value axis",editor_series_axis_auto:"Automatic (by unit)",editor_series_axis_left:"Left",editor_series_axis_right:"Right",editor_series_aggregate:"Combine with",editor_series_aggregate_auto:"As the card",editor_series_aggregate_mean:"Average",editor_series_aggregate_sum:"Sum",editor_series_aggregate_min:"Lowest",editor_series_aggregate_max:"Highest",editor_series_aggregate_first:"First",editor_series_aggregate_last:"Last",editor_series_aggregate_change:"Change (meters that count up)"});const cc=e=>e.config,dc=e=>cc(e).type??"line";function uc(e,t){const o={...e},a=t.some(e=>"column"===dc(e));return"none"!==e.group_by&&(void 0!==e.group_by&&"auto"!==e.group_by||a)?void 0!==e.group_by&&"auto"!==e.group_by||(o.group_by=dl($a(e.period))):delete o.group_by,"auto"===e.aggregate&&delete o.aggregate,o}function hc(e,t){const o=e.flatMap(e=>e.points.map(e=>e.v));return fl(o,t.includes("column"))}const pc=[...xi.map(e=>"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:At},{name:"dots",path:["dots"],default:!1},{name:"rounded",path:["rounded"],default:!0},...Si,...zi];const mc=[{name:"series_type",path:["type"],default:"line"},{name:"series_axis",path:["y_axis"],default:"auto"},{name:"series_color",path:["color"]},{name:"series_aggregate",path:["aggregate"],default:"auto"},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"dash",path:["dash"],default:!1},{name:"negative_color",path:["negative_color"]}],fc={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + ${Vi} + ${Al} .lines-over { pointer-events: none; } @@ -1007,28 +1560,28 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .lines-over .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,validate:function(e){if(void 0!==e.group_by&&!ni.includes(e.group_by))throw new Error(`"group_by" must be one of: ${ni.join(", ")}`);if(void 0!==e.aggregate&&!oi.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${oi.join(", ")}`);const t={...e};if("auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,To(t),mo(t),sn(e),ga(e),ft(e),Qo(e),void 0!==e.rounded&&"boolean"!=typeof e.rounded)throw new Error('"rounded" must be true or false');for(const n of e.entities??[]){if("object"!=typeof n)continue;const e=n;if(void 0!==e.type&&!Ci.includes(e.type))throw new Error(`"type" of ${n.entity} must be one of: ${Ci.join(", ")}`);if(void 0!==e.y_axis&&"left"!==e.y_axis&&"right"!==e.y_axis)throw new Error(`"y_axis" of ${n.entity} must be one of: left, right`);if(void 0!==e.aggregate&&!uo.includes(e.aggregate))throw new Error(`"aggregate" of ${n.entity} must be one of: ${uo.join(", ")}`)}},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Ta(`combined|${Ma(n)}|${t}`,e.hass,()=>[...Aa(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:wa(e.hass,"group_by",ni)},...t?[]:[{name:"aggregate",selector:wa(e.hass,"aggregate",oi)}]]},{name:"curve",selector:wa(e.hass,"curve",wo)},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",o,Ni)},entityFields:e=>({schema:Ta("combined-entity",e,()=>[{type:"grid",name:"",schema:[{name:"series_type",selector:wa(e,"series_type",Ci)},{name:"series_axis",selector:wa(e,"series_axis",zi)},{name:"series_color",selector:{text:{}}},{name:"series_aggregate",selector:wa(e,"series_aggregate",["auto",...uo])}]},{type:"grid",name:"",schema:[{name:"curve",selector:wa(e,"curve",wo)},{name:"line_width",selector:kt},{name:"dash",selector:{boolean:{}}},{name:"negative_color",selector:{text:{}}}]}]),fields:Ui}),disconnect(e){Po(e),Wo(e),pa(e)},render:function(e){const t={...e,raw:ji(e.raw,e.items)},n=t.raw,o=jo(t,Date.now(),{aggregate:t=>function(e,t){const n=t.aggregate;return Li(e).aggregate??(n&&"auto"!==n?n:si(e)?"change":"mean")}(t,e.raw)}),a=Uo(o,t),r=Do(o,t),i=a===W&&r===W?W:R`${a}${r}`,s=Ho(o,t),l=ea(n);if(s)return ta("over"===l.header?{...l,header:"above"}:l,i,s);const c=o.series.map(e=>Ti(e.entity)),d=function(e){const t=e[0]?.unit,n=e.map(e=>Li(e).y_axis??(e.unit===t?"left":"right"));return n.includes("left")?n:n.map(()=>"left")}(o.series.map(e=>e.entity)),h=o.group,u=o.series.map(e=>h?{...e,points:e.points.map(e=>({...e,t:void 0!==e.end?(e.t+e.end)/2:e.t}))}:e),m=h?Math.max(o.range.end,...o.series.flatMap(e=>e.points.map(e=>e.end??0))):0,p=h?{...o.range,end:m}:o.range,f=e=>u.filter((t,n)=>d[n]===e),g=e=>c.filter((t,n)=>d[n]===e),y=Oi(f("left"),g("left"));let v=n;y&&g("left").includes("column")?v={...n,y_min:n.y_min??(y[0]>=0?0:void 0),y_max:n.y_max??(y[1]<=0?0:void 0)}:g("left").includes("area")&&(v=Na(y,n));const _=Yo({...t,raw:v},p,y),b=_.scales,x=f("right"),$=Oi(x,g("right")),w={};$&&(g("right").includes("column")||g("right").includes("area"))&&($[0]>=0&&$[0]<=$[1]/2&&(w.min=0),$[1]<=0&&$[1]>=$[0]/2&&(w.max=0));const k=x.length?un($,w,4):void 0,M=k?{x:b.x,y:Kt(k.domain,[Co,0]),domain:k.domain}:void 0,A=e=>"right"===d[e.index]&&M?M:b,S=u.filter(e=>"area"===c[e.index]).map(e=>Da(e,e.points,A(e),n,{range:p})),E=u.filter(e=>"line"===c[e.index]).map(e=>Ai({...e,points:e.points},A(e),e.color,p,n)),C=u.filter(e=>"column"===c[e.index]),z=ci(o.series.filter(e=>"column"===c[e.index]).map(e=>e.points),h?1/0:o.range.end),L=!1===n.rounded?0:4,T=[];z.times.forEach((e,t)=>{const n=b.x(e),o=b.x(z.ends[t]);C.forEach((e,a)=>{const r=z.values[a][t];if(null===r)return;const i=A(e),s=Math.min(Math.max(0,i.domain[0]),i.domain[1]),[l,c]=di(n,o,a,C.length);T.push(yi(i,l,c,Math.min(s,r),Math.max(s,r),e.color,r>=s?"top":"bottom",e.index))})});const j=z.times.map((e,t)=>(e+z.ends[t])/2),O=u.map(e=>{if("column"!==c[e.index]||h)return e;const t=C.indexOf(e);return{...e,points:j.map((e,n)=>({t:e,v:z.values[t][n]}))}}),N=new Map;for(const P of o.series)for(const e of P.points)void 0!==e.end&&N.set((e.t+e.end)/2,[e.t,e.end]);const U=fa(n),F=at(t.hass,"chart_aria",{style:at(t.hass,"editor_style_combined"),names:o.series.map(e=>e.entity.name).join(", ")});return ta(l,i,Xo({axes:_,ctx:t,right:k&&M?{value:k,y:M.y}:void 0,content:V`${S.map(e=>e.body)}`,defs:V`${S.map(e=>e.defs)}`,label:F,overlay:R`
${T}
-
${N}
+ - ${"over"===l.header?na(i):W} - ${U?xa(t,O,b,ma(t.host),Zo(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:A(e).y(t.v),heading:e=>{const n=N.get(e);return n&&h?pi(n[0],n[1],h.unit,t.hass):Sn(e,t.hass)}}):W}`,hover:U?ya(t,p,O.map(e=>e.points)):void 0}))}};function Pi(e,t){const n=Math.round(12+88*t);return n>=100?e:`color-mix(in srgb, ${e} ${n}%, var(--card-background-color, #fff))`}const Di=Math.sqrt(3)/2,Hi=["hours","calendar"],Bi=["scale","bands"],Ii=["square","rounded","hexagon"],Ri=10,Vi=1e4,qi={hours:{last:4,unit:"week",source:"history"},calendar:{last:12,unit:"month",source:"statistics"}};nt("en",{editor_style_heatmap:"Heatmap",heatmap_aria:"Heatmap of {name}",heatmap_hours:"Days averaged",heatmap_empty:"No readings",editor_grid:"Layout",editor_grid_hours:"Hour by weekday",editor_grid_calendar:"Calendar (a day per cell)",editor_color_by_scale:"Smooth scale",editor_cell_shape:"Cells",editor_cell_shape_square:"Square",editor_cell_shape_rounded:"Rounded",editor_cell_shape_hexagon:"Hexagon",editor_color_min:"Lowest colour at",editor_color_max:"Highest colour at",editor_legend:"Legend"});const Wi=e=>"number"==typeof e&&Number.isFinite(e);function Zi(e){return e.grid??"hours"}function Yi(e){const t=Zi(e),n=qi[t],o=e.period??{};return{...e,group_by:"hours"===t?"hour":"day",source:e.source??n.source,period:void 0!==o.calendar?o:{...o,last:o.last??n.last,unit:o.unit??n.unit}}}const Gi=new Map;function Xi(e,t,n){const o=e?.locale?.language??e?.language??"en",a=`${o}|${t}|${JSON.stringify(n)}`;let r=Gi.get(a);if(!r){try{r=new Intl.DateTimeFormat(o,{...n,timeZone:t})}catch{r=new Intl.DateTimeFormat("en",{...n,timeZone:"UTC"})}Gi.set(a,r)}return r}function Ji(e,t){return Xi(t,"UTC",{weekday:"short"}).format(Date.UTC(2024,0,e))}function Ki(e,t,n=!1){return Xi(t,"UTC",{hour:"numeric",...n?{minute:"2-digit"}:{},hour12:wn(t)}).format(Date.UTC(2024,0,1,e))}function Qi(e,t,n,o,a="cell"){const[r,i]=e.centre(t.col,t.row),s=o?`fill:${o}`:"",l=o?"":"empty";if("hexagon"===n)return V`{const a=(60*n-90)*Math.PI/180;return`${Math.round(100*(e+o*Math.cos(a)))/100},${Math.round(100*(t+o*Math.sin(a)))/100}`});return`M${a.join("L")}Z`}(r,i,8.8)} style=${s}>`;const c=8.8;return V``}const es=e=>Math.round(1e4*e)/100+"%";function ts(e,t,n,o){const{zone:a}=t,r=e=>(a.firstDay-1+e)%7+1,i=function(e,t){const n=new Array(168).fill(0),o=new Array(168).fill(0);for(const a of e){if(null===a.v)continue;const e=Nn(a.t,t.timeZone),r=(e.weekday-t.firstDay+7)%7*24+e.hour;n[r]+=a.v,o[r]+=1}return n.map((e,t)=>({row:Math.floor(t/24),hour:t%24,v:o[t]?e/o[t]:null,count:o[t]}))}(e.points,a).map(e=>({col:e.hour,row:e.row,v:e.v,count:e.count,title:()=>`${Ji(r(e.row),n)} ${Ki(e.hour,n,!0)} – ${Ki((e.hour+1)%24,n,!0)}`})),s=o?3:6;return{cells:i,cols:24,rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ji(r(e),n)})),colLabels:Array.from({length:24/s},(e,t)=>({col:t*s,text:Ki(t*s,n)}))}}function ns(e,t,n){const{zone:o}=t,a=function(e,t){if(0===e.length)return{cells:[],weeks:0,months:[]};const n=e=>{const n=Nn(e,t.timeZone);return Math.round(Date.UTC(n.year,n.month-1,n.day)/864e5)},o=n(Dn("week",e[0].t,t)),a=[],r=[];let i=-1;for(const s of e){const e=Nn(s.t,t.timeZone),l=Math.floor((n(s.t)-o)/7);a.push({t:s.t,col:l,row:(e.weekday-t.firstDay+7)%7,v:s.v});const c=12*e.year+e.month;c!==i&&(-1!==i&&1!==e.day||r.push({col:l,t:s.t}),i=c)}return r.length>1&&r[1].col-r[0].col<3&&r.shift(),{cells:a,weeks:a[a.length-1].col+1,months:r}}(e.points,o),r=Xi(n,o.timeZone,{weekday:"short",day:"numeric",month:"short",year:"numeric"}),i=Xi(n,o.timeZone,{month:"short"}),s=e=>(o.firstDay-1+e)%7+1;return{cells:a.cells.map(e=>({col:e.col,row:e.row,v:e.v,title:()=>r.format(e.t)})),cols:Math.max(1,a.weeks),rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ji(s(e),n)})),colLabels:a.months.map(e=>({col:e.col,text:i.format(e.t)}))}}function os(e,t,n,o){const a=n.raw,r=Zi(n.raw),i=a.cell_shape??"rounded",s="bands"===a.color_by,l=qo(n),c="hours"===r?ts(e,t,n.hass,l>=400):ns(e,t,n.hass),d=function(e,t,n){if("hexagon"===n){const n=Ri/Math.sqrt(3),o=Ri*Di,a=e*Ri+5,r=(t-1)*o+2*n;return{width:a,height:r,centre:(e,t)=>[e*Ri+5+t%2*5,n+t*o],at(i,s){const l=Math.min(t-1,Math.max(0,Math.round((s*r-n)/o))),c=Math.floor((i*a-l%2*5)/Ri);return[Math.min(e-1,Math.max(0,c)),l]}}}return{width:e*Ri,height:t*Ri,centre:(e,t)=>[e*Ri+5,t*Ri+5],at:(n,o)=>[Math.min(e-1,Math.floor(n*e)),Math.min(t-1,Math.floor(o*t))]}}(c.cols,c.rows,i),h=Math.max(0,l-72)/d.width*Ri,u=Math.ceil(13/Math.max(1,h*("hexagon"===i?Di:1))),m=c.rowLabels.filter(e=>e.row%u===0);let p=-1/0;const f=c.colLabels.filter(e=>!((e.col-p)*h<32)&&(p=e.col,!0)),g=function(e,t={}){const n=e.filter(e=>null!==e);if(0===n.length&&(void 0===t.min||void 0===t.max))return;let o=t.min??Math.min(...n),a=t.max??Math.max(...n);return a<=o&&(void 0===t.max?a=o+1:o=a-1),[o,a]}(c.cells.map(e=>e.v),{min:Wi(a.color_min)?a.color_min:void 0,max:Wi(a.color_max)?a.color_max:void 0}),y=t=>{if(null!==t)return s?ze(je(t,e.entity.bands).color):g?Pi(e.color,function(e,[t,n]){return Math.min(1,Math.max(0,(e-t)/(n-t)))}(t,g)):e.color},v=mr(n.host),_=void 0!==v&&Math.floor(v/Vi)===e.index?v%Vi:void 0,b=new Map(c.cells.map((e,t)=>[8*e.col+e.row,t])),x=fa(n.raw)?fr(n,(t,n)=>{const[o,a]=d.at(t,n),r=b.get(8*o+a);return void 0===r?void 0:e.index*Vi+r}):void 0,$=void 0===_?void 0:c.cells[_];let w=W;if($){const[t,o]=d.centre($.col,$.row),a=null===$.v?at(n.hass,"heatmap_empty"):Ro(Io($.v,e.entity.decimals,n.hass),e.entity.unit);w=yr({x:t/d.width,y:o/d.height,plotWidth:Math.max(0,l-80),title:$.title(),rows:[{color:y($.v)??"var(--cd-track)",name:e.entity.name,value:a},...void 0!==$.count&&null!==$.v?[{name:at(n.hass,"heatmap_hours"),value:String($.count)}]:[]]})}const{show:k}=n.config,M=k.name||k.value||o?R`
+ ${"over"===l.header?Vn(i):W} + ${F?pr(t,T,_,rr(t.host),Pn(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:z(e).y(t.v),heading:e=>{const o=O.get(e);return o&&u?yl(o[0],o[1],u.unit,t.hass):Qo(e,t.hass)}}):W}`,hover:F?cr(t,m,T.map(e=>e.points)):void 0}))}};function gc(e,t){const o=Math.round(12+88*t);return o>=100?e:`color-mix(in srgb, ${e} ${o}%, var(--card-background-color, #fff))`}const vc=Math.sqrt(3)/2,yc=["hours","calendar"],bc=["scale","bands"],_c=["square","rounded","hexagon"],xc=10,wc=1e4,$c={hours:{last:4,unit:"week",source:"history"},calendar:{last:12,unit:"month",source:"statistics"}};yt("en",{editor_style_heatmap:"Heatmap",heatmap_aria:"Heatmap of {name}",heatmap_hours:"Days averaged",heatmap_empty:"No readings",editor_grid:"Layout",editor_grid_hours:"Hour by weekday",editor_grid_calendar:"Calendar (a day per cell)",editor_color_by_scale:"Smooth scale",editor_cell_shape:"Cells",editor_cell_shape_square:"Square",editor_cell_shape_rounded:"Rounded",editor_cell_shape_hexagon:"Hexagon",editor_color_min:"Lowest colour at",editor_color_max:"Highest colour at",editor_legend:"Legend"});const kc=e=>"number"==typeof e&&Number.isFinite(e);function Mc(e){return e.grid??"hours"}function zc(e){const t=Mc(e),o=$c[t],a=e.period??{};return{...e,group_by:"hours"===t?"hour":"day",source:e.source??o.source,period:void 0!==a.calendar?a:{...a,last:a.last??o.last,unit:a.unit??o.unit}}}const Ac=new Map;function Sc(e,t,o){const a=e?.locale?.language??e?.language??"en",n=`${a}|${t}|${JSON.stringify(o)}`;let r=Ac.get(n);if(!r){try{r=new Intl.DateTimeFormat(a,{...o,timeZone:t})}catch{r=new Intl.DateTimeFormat("en",{...o,timeZone:"UTC"})}Ac.set(n,r)}return r}function Ec(e,t){return Sc(t,"UTC",{weekday:"short"}).format(Date.UTC(2024,0,e))}function Cc(e,t,o=!1){return Sc(t,"UTC",{hour:"numeric",...o?{minute:"2-digit"}:{},hour12:Go(t)}).format(Date.UTC(2024,0,1,e))}function jc(e,t,o,a,n="cell"){const[r,i]=e.centre(t.col,t.row),s=a?`fill:${a}`:"",l=a?"":"empty";if("hexagon"===o)return Z`{const n=(60*o-90)*Math.PI/180;return`${Math.round(100*(e+a*Math.cos(n)))/100},${Math.round(100*(t+a*Math.sin(n)))/100}`});return`M${n.join("L")}Z`}(r,i,8.8)} style=${s}>`;const c=8.8;return Z``}const Nc=e=>Math.round(1e4*e)/100+"%";function Lc(e,t,o,a){const{zone:n}=t,r=e=>(n.firstDay-1+e)%7+1,i=function(e,t){const o=new Array(168).fill(0),a=new Array(168).fill(0);for(const n of e){if(null===n.v)continue;const e=sa(n.t,t.timeZone),r=(e.weekday-t.firstDay+7)%7*24+e.hour;o[r]+=n.v,a[r]+=1}return o.map((e,t)=>({row:Math.floor(t/24),hour:t%24,v:a[t]?e/a[t]:null,count:a[t]}))}(e.points,n).map(e=>({col:e.hour,row:e.row,v:e.v,count:e.count,title:()=>`${Ec(r(e.row),o)} ${Cc(e.hour,o,!0)} – ${Cc((e.hour+1)%24,o,!0)}`})),s=a?3:6;return{cells:i,cols:24,rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ec(r(e),o)})),colLabels:Array.from({length:24/s},(e,t)=>({col:t*s,text:Cc(t*s,o)}))}}function Tc(e,t,o){const{zone:a}=t,n=function(e,t){if(0===e.length)return{cells:[],weeks:0,months:[]};const o=e=>{const o=sa(e,t.timeZone);return Math.round(Date.UTC(o.year,o.month-1,o.day)/864e5)},a=o(ua("week",e[0].t,t)),n=[],r=[];let i=-1;for(const s of e){const e=sa(s.t,t.timeZone),l=Math.floor((o(s.t)-a)/7);n.push({t:s.t,col:l,row:(e.weekday-t.firstDay+7)%7,v:s.v});const c=12*e.year+e.month;c!==i&&(-1!==i&&1!==e.day||r.push({col:l,t:s.t}),i=c)}return r.length>1&&r[1].col-r[0].col<3&&r.shift(),{cells:n,weeks:n[n.length-1].col+1,months:r}}(e.points,a),r=Sc(o,a.timeZone,{weekday:"short",day:"numeric",month:"short",year:"numeric"}),i=Sc(o,a.timeZone,{month:"short"}),s=e=>(a.firstDay-1+e)%7+1;return{cells:n.cells.map(e=>({col:e.col,row:e.row,v:e.v,title:()=>r.format(e.t)})),cols:Math.max(1,n.weeks),rows:7,rowLabels:[0,1,2,3,4,5,6].map(e=>({row:e,text:Ec(s(e),o)})),colLabels:n.months.map(e=>({col:e.col,text:i.format(e.t)}))}}function Oc(e,t,o,a){const n=o.raw,r=Mc(o.raw),i=n.cell_shape??"rounded",s="bands"===n.color_by,l=On(o),c="hours"===r?Lc(e,t,o.hass,l>=400):Tc(e,t,o.hass),d=function(e,t,o){if("hexagon"===o){const o=xc/Math.sqrt(3),a=xc*vc,n=e*xc+5,r=(t-1)*a+2*o;return{width:n,height:r,centre:(e,t)=>[e*xc+5+t%2*5,o+t*a],at(i,s){const l=Math.min(t-1,Math.max(0,Math.round((s*r-o)/a))),c=Math.floor((i*n-l%2*5)/xc);return[Math.min(e-1,Math.max(0,c)),l]}}}return{width:e*xc,height:t*xc,centre:(e,t)=>[e*xc+5,t*xc+5],at:(o,a)=>[Math.min(e-1,Math.floor(o*e)),Math.min(t-1,Math.floor(a*t))]}}(c.cols,c.rows,i),u=Math.max(0,l-72)/d.width*xc,h=Math.ceil(13/Math.max(1,u*("hexagon"===i?vc:1))),p=c.rowLabels.filter(e=>e.row%h===0);let m=-1/0;const f=c.colLabels.filter(e=>!((e.col-m)*u<32)&&(m=e.col,!0)),g=function(e,t={}){const o=e.filter(e=>null!==e);if(0===o.length&&(void 0===t.min||void 0===t.max))return;let a=t.min??Math.min(...o),n=t.max??Math.max(...o);return n<=a&&(void 0===t.max?n=a+1:a=n-1),[a,n]}(c.cells.map(e=>e.v),{min:kc(n.color_min)?n.color_min:void 0,max:kc(n.color_max)?n.color_max:void 0}),v=t=>{if(null!==t)return s?Ce(Te(t,e.entity.bands).color):g?gc(e.color,function(e,[t,o]){return Math.min(1,Math.max(0,(e-t)/(o-t)))}(t,g)):e.color},y=vs(o.host),b=void 0!==y&&Math.floor(y/wc)===e.index?y%wc:void 0,_=new Map(c.cells.map((e,t)=>[8*e.col+e.row,t])),x=sr(o.raw)?bs(o,(t,o)=>{const[a,n]=d.at(t,o),r=_.get(8*a+n);return void 0===r?void 0:e.index*wc+r}):void 0,w=void 0===b?void 0:c.cells[b];let $=W;if(w){const[t,a]=d.centre(w.col,w.row),n=null===w.v?_t(o.hass,"heatmap_empty"):Mn(kn(w.v,e.entity.decimals,o.hass),e.entity.unit);$=xs({x:t/d.width,y:a/d.height,plotWidth:Math.max(0,l-80),title:w.title(),rows:[{color:v(w.v)??"var(--cd-track)",name:e.entity.name,value:n},...void 0!==w.count&&null!==w.v?[{name:_t(o.hass,"heatmap_hours"),value:String(w.count)}]:[]]})}const{show:k}=o.config,M=k.name||k.value||a?R`
${s?W:R``} - ${k.name||o?R`${e.entity.name}`:W} + ${k.name||a?R`${e.entity.name}`:W} ${k.value?R`${e.entity.display}`:W}
-
`:W,A=at(n.hass,"heatmap_aria",{name:e.entity.name});return R`
+
`:W,z=_t(o.hass,"heatmap_aria",{name:e.entity.name});return R`
${M}
- - ${c.cells.map(e=>Qi(d,e,i,y(e.v)))} - ${$?Qi(d,$,i,"none","cell picked"):W} + + ${c.cells.map(e=>jc(d,e,i,v(e.v)))} + ${w?jc(d,w,i,"none","cell picked"):W} - ${w} + ${$}
- ${!1===a.legend?W:function(e,t,n,o){const a=e.entity,r=e=>Io(e,a.decimals,o.hass);return n?R`
- ${Oe(a.bands,r,a.unit).map(e=>R`kn(e,n.decimals,a.hass);return o?R`
+ ${Oe(n.bands,r,n.unit).map(e=>R`${e.text}`)}
`:t?R`
${r(t[0])} - - ${Ro(r(t[1]),a.unit)} -
`:W}(e,g,s,n)} -
`}const as=new Map;const rs={layout:"chart",group:"distribution",height:220,styles:r` - ${Bo} - ${$a} + + ${Mn(r(t[1]),n.unit)} +
`:W}(e,g,s,o)} +
`}const Fc=new Map;const Pc={layout:"chart",group:"distribution",height:220,styles:r` + ${$n} + ${mr} .hm + .hm { margin-top: 12px; } @@ -1157,14 +1710,14 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de height: 10px; border-radius: 2px; } - `,thumbnail:()=>V` - ${[0,1,2,3].map(e=>[0,1,2,3,4,5,6].map(t=>V``))}`,validate:function(e){const t=e,n=(e,n)=>{const o=t[e];if(void 0!==o&&!n.includes(o))throw new Error(`"${e}" must be one of: ${n.join(", ")}`)};n("grid",Hi),n("color_by",Bi),n("cell_shape",Ii);for(const o of["color_min","color_max"])if(void 0!==t[o]&&!Wi(t[o]))throw new Error(`"${o}" must be a number`);if(Wi(t.color_min)&&Wi(t.color_max)&&t.color_min>=t.color_max)throw new Error('"color_min" must be below "color_max"');if(void 0!==t.legend&&"boolean"!=typeof t.legend)throw new Error('"legend" must be true or false');To(Yi(e)),ga(e)},editor:function(e){const t=Zi(e.config),n=Ta(`heatmap|${Ma({...e.config,group_by:void 0})}`,e.hass,()=>[{name:"grid",selector:wa(e.hass,"grid",Hi)},...Aa(e.hass,{...e.config,group_by:void 0}).filter(e=>!JSON.stringify(e).includes('"group_by"')),{name:"aggregate",selector:wa(e.hass,"aggregate",uo)},{type:"grid",name:"",schema:[{name:"color_by",selector:wa(e.hass,"color_by",Bi)},{name:"cell_shape",selector:wa(e.hass,"cell_shape",Ii)}]},{type:"grid",name:"",schema:[{name:"color_min",selector:{number:{mode:"box",step:"any"}}},{name:"color_max",selector:{number:{mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"legend",selector:{boolean:{}}},{name:"tooltip",selector:{boolean:{}}}]}]);return ja(e,"chart",n,function(e){let t=as.get(e);if(!t){const n=qi[e],o={period_last:n.last,period_unit:n.unit,source:n.source};t=[{name:"grid",path:["grid"],default:"hours"},...ka.filter(e=>"group_by"!==e.name).map(e=>e.name in o?{...e,default:o[e.name]}:e),{name:"color_by",path:["color_by"],default:"scale"},{name:"cell_shape",path:["cell_shape"],default:"rounded"},{name:"color_min",path:["color_min"]},{name:"color_max",path:["color_max"]},{name:"legend",path:["legend"],default:!0},{name:"tooltip",path:["tooltip"],default:!0}],as.set(e,t)}return t}(t))},entityFields:()=>({schema:[{name:"series_color",selector:{text:{}}}],fields:[{name:"series_color",path:["color"]}]}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=jo({...e,raw:Yi(e.raw)}),n=Uo(t,e),o=Ho(t,e);if(o)return R`
${n}${o}
`;const a=t.series.length>1;return R`
${n}${t.series.map(n=>os(n,t,e,a))}
`}},is=["time","readings"],ss=100,ls=e=>Number(e.toPrecision(12));const cs=6e4,ds=36e5;const hs=["percent","total"],us=["side","overlap"],ms=["series","bands"];nt("en",{editor_style_histogram:"Histogram",editor_count:"Count",editor_count_time:"Time spent in each range",editor_count_readings:"Readings in each range",editor_show_as:"Show as",editor_show_as_percent:"Share of the whole (%)",editor_show_as_total:"Hours or readings",editor_bins:"Number of bins (empty: automatic)",editor_bin_width:"Bin width (wins over the number)",editor_bars:"Two or more entities",editor_bars_side:"Bars side by side",editor_bars_overlap:"Bars over each other",editor_hist_x_axis:"Value labels along the bottom",editor_bar_color:"Bar colour",histogram_readings:"{n} readings",histogram_reading:"1 reading"});const ps=e=>"number"==typeof e&&Number.isFinite(e);function fs(e,t,n){if(void 0!==e[t]&&!n.includes(e[t]))throw new Error(`"${t}" must be one of: ${n.join(", ")}`)}function gs(e,t){const n=jo(e,t),o={count:(a=e.raw).count??"time",showAs:a.show_as??"percent",bars:a.bars??"side",bands:"bands"===a.color_by,bins:ps(a.bins)?a.bins:void 0,width:ps(a.bin_width)?a.bin_width:void 0};var a;const r=Math.min(n.range.end,t),i=n.series.map(e=>function(e,t){const{mode:n,until:o,statistics:a}=t;if(0===e.length)return[];if(void 0!==e[0].end)return e.map(e=>null===e.v?0:"readings"===n?1:Math.max(0,Math.min(e.end,o)-e.t));if(a){if("readings"===n)return e.map(e=>null===e.v||e.carried?0:1);const t=e.slice(1).map((t,n)=>t.t-e[n].t).filter(e=>e>0).sort((e,t)=>e-t),o=t.length?t[Math.floor(t.length/2)]:36e5;return e.map(e=>null===e.v||e.carried?0:o)}const r=e.length-1;return e.map((t,a)=>{if(null===t.v)return 0;if("readings"===n){const n=a===r&&a>0&&t.t>=o&&e[a-1].v===t.v;return t.carried||n?0:1}const i=ae.points.forEach((e,n)=>{null!==e.v&&i[t][n]>0&&(s=Math.min(s,e.v),l=Math.max(l,e.v))}));const c=cr(e.raw),d="side"===o.bars&&n.series.length>1,h=Math.max(5,Math.min(24,Math.round(Zo(e)/(d?40:28)))),u=s===1/0?[]:function(e,t,n={},o=12){const a=n.min??e,r=n.max??t;if(void 0!==n.bins&&void 0===n.width){let[e,t]=[a,r];t>e||([e,t]=[e-.5,e+.5]);const o=(t-e)/n.bins;return Array.from({length:n.bins+1},(a,r)=>r===n.bins?t:ls(e+r*o))}const i=r-a;let s=n.width??(i>0?cn(i,o):cn(Math.max(Math.abs(a),1),10));for(;i/s>ss;)s=cn(i,50);const l=n.min??ls(Math.floor(a/s+1e-9)*s);let c=n.max??ls(Math.ceil(r/s-1e-9)*s);c>l||(c=ls(l+s));const d=[l];for(let h=1;d[d.length-1]{const n=function(e,t,n){const o=new Array(Math.max(0,n.length-1)).fill(0);return e.forEach((e,a)=>{if(null===e.v||!(t[a]>0))return;const r=function(e,t){const n=t.length-1;if(n<1||et[n])return-1;if(e===t[n])return n-1;let o=0,a=n-1;for(;o>1;t[n]<=e?o=n:a=n-1}return o}(e.v,n);r>=0&&(o[r]+=t[a])}),o}(e.points,i[t],u),a=n.reduce((e,t)=>e+t,0),r=n.map(e=>"percent"===o.showAs?a>0?e/a*100:0:"time"===o.count?e/36e5:e);return{series:e,totals:n,heights:r,whole:a}});return{frame:n,opts:o,edges:u,data:m,side:d}}function ys(e,t,n){return"time"===t?function(e){if(e0?"< 1 min":"0 min";const t=Math.round(e/cs);if(t<60)return`${t} min`;if(e<864e5){const e=Math.floor(t/60),n=t%60;return n?`${e} h ${n} min`:`${e} h`}const n=Math.round(e/ds),o=Math.floor(n/24),a=n%24;return a?`${o} d ${a} h`:`${o} d`}(e):1===e?at(n.hass,"histogram_reading"):at(n.hass,"histogram_readings",{n:Io(e,0,n.hass)})}const vs={layout:"chart",group:"distribution",height:240,styles:r` - ${Bo} - ${Jo} - ${$a} - ${oa} - ${vr} + `,thumbnail:()=>Z` + ${[0,1,2,3].map(e=>[0,1,2,3,4,5,6].map(t=>Z``))}`,validate:function(e){const t=e,o=(e,o)=>{const a=t[e];if(void 0!==a&&!o.includes(a))throw new Error(`"${e}" must be one of: ${o.join(", ")}`)};o("grid",yc),o("color_by",bc),o("cell_shape",_c);for(const a of["color_min","color_max"])if(void 0!==t[a]&&!kc(t[a]))throw new Error(`"${a}" must be a number`);if(kc(t.color_min)&&kc(t.color_max)&&t.color_min>=t.color_max)throw new Error('"color_min" must be below "color_max"');if(void 0!==t.legend&&"boolean"!=typeof t.legend)throw new Error('"legend" must be true or false');pn(zc(e)),lr(e)},editor:function(e){const t=Mc(e.config),o=Fi(`heatmap|${wi({...e.config,group_by:void 0})}`,e.hass,()=>[{name:"grid",selector:_i(e.hass,"grid",yc)},...$i(e.hass,{...e.config,group_by:void 0}).filter(e=>!JSON.stringify(e).includes('"group_by"')),{name:"aggregate",selector:_i(e.hass,"aggregate",qa)},{type:"grid",name:"",schema:[{name:"color_by",selector:_i(e.hass,"color_by",bc)},{name:"cell_shape",selector:_i(e.hass,"cell_shape",_c)}]},{type:"grid",name:"",schema:[{name:"color_min",selector:{number:{mode:"box",step:"any"}}},{name:"color_max",selector:{number:{mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"legend",selector:{boolean:{}}},{name:"tooltip",selector:{boolean:{}}}]}]);return Pi(e,"chart",o,function(e){let t=Fc.get(e);if(!t){const o=$c[e],a={period_last:o.last,period_unit:o.unit,source:o.source};t=[{name:"grid",path:["grid"],default:"hours"},...xi.filter(e=>"group_by"!==e.name).map(e=>e.name in a?{...e,default:a[e.name]}:e),{name:"color_by",path:["color_by"],default:"scale"},{name:"cell_shape",path:["cell_shape"],default:"rounded"},{name:"color_min",path:["color_min"]},{name:"color_max",path:["color_max"]},{name:"legend",path:["legend"],default:!0},{name:"tooltip",path:["tooltip"],default:!0}],Fc.set(e,t)}return t}(t))},entityFields:()=>({schema:[{name:"series_color",selector:{text:{}}}],fields:[{name:"series_color",path:["color"]}]}),disconnect(e){bn(e),Fn(e),ys(e)},render(e){const t=mn({...e,raw:zc(e.raw)}),o=vn(t,e),a=xn(t,e);if(a)return R`
${o}${a}
`;const n=t.series.length>1;return R`
${o}${t.series.map(o=>Oc(o,t,e,n))}
`}},Dc=["time","readings"],Uc=100,Hc=e=>Number(e.toPrecision(12));const Ic=6e4,Bc=36e5;const Rc=["percent","total"],Zc=["side","overlap"],qc=["series","bands"];yt("en",{editor_style_histogram:"Histogram",editor_count:"Count",editor_count_time:"Time spent in each range",editor_count_readings:"Readings in each range",editor_show_as:"Show as",editor_show_as_percent:"Share of the whole (%)",editor_show_as_total:"Hours or readings",editor_bins:"Number of bins (empty: automatic)",editor_bin_width:"Bin width (wins over the number)",editor_bars:"Two or more entities",editor_bars_side:"Bars side by side",editor_bars_overlap:"Bars over each other",editor_hist_x_axis:"Value labels along the bottom",editor_bar_color:"Bar colour",histogram_readings:"{n} readings",histogram_reading:"1 reading"});const Wc=e=>"number"==typeof e&&Number.isFinite(e);function Vc(e,t,o){if(void 0!==e[t]&&!o.includes(e[t]))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}function Yc(e,t){const o=mn(e,t),a={count:(n=e.raw).count??"time",showAs:n.show_as??"percent",bars:n.bars??"side",bands:"bands"===n.color_by,bins:Wc(n.bins)?n.bins:void 0,width:Wc(n.bin_width)?n.bin_width:void 0};var n;const r=Math.min(o.range.end,t),i=o.series.map(e=>function(e,t){const{mode:o,until:a,statistics:n}=t;if(0===e.length)return[];if(void 0!==e[0].end)return e.map(e=>null===e.v?0:"readings"===o?1:Math.max(0,Math.min(e.end,a)-e.t));if(n){if("readings"===o)return e.map(e=>null===e.v||e.carried?0:1);const t=e.slice(1).map((t,o)=>t.t-e[o].t).filter(e=>e>0).sort((e,t)=>e-t),a=t.length?t[Math.floor(t.length/2)]:36e5;return e.map(e=>null===e.v||e.carried?0:a)}const r=e.length-1;return e.map((t,n)=>{if(null===t.v)return 0;if("readings"===o){const o=n===r&&n>0&&t.t>=a&&e[n-1].v===t.v;return t.carried||o?0:1}const i=ne.points.forEach((e,o)=>{null!==e.v&&i[t][o]>0&&(s=Math.min(s,e.v),l=Math.max(l,e.v))}));const c=ps(e.raw),d="side"===a.bars&&o.series.length>1,u=Math.max(5,Math.min(24,Math.round(Pn(e)/(d?40:28)))),h=s===1/0?[]:function(e,t,o={},a=12){const n=o.min??e,r=o.max??t;if(void 0!==o.bins&&void 0===o.width){let[e,t]=[n,r];t>e||([e,t]=[e-.5,e+.5]);const a=(t-e)/o.bins;return Array.from({length:o.bins+1},(n,r)=>r===o.bins?t:Hc(e+r*a))}const i=r-n;let s=o.width??(i>0?Oo(i,a):Oo(Math.max(Math.abs(n),1),10));for(;i/s>Uc;)s=Oo(i,50);const l=o.min??Hc(Math.floor(n/s+1e-9)*s);let c=o.max??Hc(Math.ceil(r/s-1e-9)*s);c>l||(c=Hc(l+s));const d=[l];for(let u=1;d[d.length-1]{const o=function(e,t,o){const a=new Array(Math.max(0,o.length-1)).fill(0);return e.forEach((e,n)=>{if(null===e.v||!(t[n]>0))return;const r=function(e,t){const o=t.length-1;if(o<1||et[o])return-1;if(e===t[o])return o-1;let a=0,n=o-1;for(;a>1;t[o]<=e?a=o:n=o-1}return a}(e.v,o);r>=0&&(a[r]+=t[n])}),a}(e.points,i[t],h),n=o.reduce((e,t)=>e+t,0),r=o.map(e=>"percent"===a.showAs?n>0?e/n*100:0:"time"===a.count?e/36e5:e);return{series:e,totals:o,heights:r,whole:n}});return{frame:o,opts:a,edges:h,data:p,side:d}}function Gc(e,t,o){return"time"===t?function(e){if(e0?"< 1 min":"0 min";const t=Math.round(e/Ic);if(t<60)return`${t} min`;if(e<864e5){const e=Math.floor(t/60),o=t%60;return o?`${e} h ${o} min`:`${e} h`}const o=Math.round(e/Bc),a=Math.floor(o/24),n=o%24;return n?`${a} d ${n} h`:`${a} d`}(e):1===e?_t(o.hass,"histogram_reading"):_t(o.hass,"histogram_readings",{n:kn(e,0,o.hass)})}const Xc={layout:"chart",group:"distribution",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + ${ws} .style-histogram .bin { shape-rendering: crispEdges; } @@ -1173,7 +1726,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de stroke-width: 1.5px; vector-effect: non-scaling-stroke; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` @@ -1182,7 +1735,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de - `,validate(e){if(To(e),sn(e),ga(e),Qo(e),ir(e),fs(e,"count",is),fs(e,"show_as",hs),fs(e,"bars",us),fs(e,"color_by",ms),void 0!==e.bins&&!(Number.isInteger(e.bins)&&e.bins>=1&&e.bins<=ss))throw new Error('"bins" must be a whole number from 1 to 100');if(void 0!==e.bin_width&&!(ps(e.bin_width)&&e.bin_width>0))throw new Error('"bin_width" must be a number above 0')},editor:function(e){const t=e.hass,n=Ta(`histogram|${Ma(e.config)}`,t,()=>[...Aa(t,e.config),{type:"grid",name:"",schema:[{name:"count",selector:wa(t,"count",is)},{name:"show_as",selector:wa(t,"show_as",hs)}]},{type:"grid",name:"",schema:[{name:"bins",selector:{number:{min:1,max:ss,mode:"box"}}},{name:"bin_width",selector:{number:{min:0,mode:"box",step:"any"}}}]},...lr,{type:"grid",name:"",schema:[{name:"bars",selector:wa(t,"bars",us)},{name:"color_by",selector:wa(t,"color_by",ms)}]},...za(t).map(e=>"grid"===e.type?{...e,schema:e.schema.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e)}:e),...Ea(t)]);return ja(e,"chart",n,$s)},entityFields:()=>({schema:bs,fields:_s}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=Date.now(),{frame:n,opts:o,edges:a,data:r,side:i}=gs(e,t),s=Uo(n,e),l=Do(n,e),c=s===W&&l===W?W:R`${s}${l}`,d=ea(e.raw),h=Ho(n,e)??(a.length<2?Ho({...n,series:[]},e):void 0);if(h)return ta("over"===d.header?{...d,header:"above"}:d,c,h);const u=Math.max(0,...r.flatMap(e=>e.heights)),m=dr(e,[a[0],a[a.length-1]],[0,u],{x:{min:a[0],max:a[a.length-1]},yFloor:0}),{scales:p}=m,f=p.y(Math.max(0,m.value.domain[0])),g=r.length,y="overlap"===o.bars&&g>1,v=V`${r.map((e,t)=>{const n=e.heights.map((n,r)=>{if(!(n>0))return W;const s=p.x(a[r]),l=p.x(a[r+1]),c=Math.min(4,.08*(l-s)),d=(l-s-2*c)/(i?g:1),h=s+c+(i?d*t:0),u=p.y(n),m=o.bands&&e.series.entity.bands.length?ze(je((a[r]+a[r+1])/2,e.series.entity.bands).color):e.series.color;return V``});return V`${n}`})}`,_=function(e){if(e.length<2)return 0;const t=e[1]-e[0],n=e.every(e=>Math.abs(e-Number(e.toFixed(dn(t))))<1e-6*t);return n?dn(t):Math.min(4,dn(t)+1)}(a),b=fa(e.raw),x=b?mr(e.host):void 0;let $=W;if(void 0!==x&&x{const n=t.whole>0?t.totals[x]/t.whole*100:0;return{color:t.series.color,name:t.series.entity.name,value:`${Io(n,n>0&&n<1?1:0,e.hass)}% · ${ys(t.totals[x],o.count,e)}`}})})}const w=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_histogram"),names:n.series.map(e=>e.entity.name).join(", ")});return ta(d,c,Xo({axes:m,ctx:e,content:V`${v}`,label:w,xLabel:t=>Io(t,hr(m),e.hass),yLabel:t=>"percent"===o.showAs?`${Io(t,m.value.decimals,e.hass)}%`:"time"===o.count?`${Io(t,m.value.decimals,e.hass)} h`:Io(t,m.value.decimals,e.hass),overlay:R`${"over"===d.header?na(c):W}${$}`,hover:b?fr(e,e=>{const t=a[0]+e*(a[a.length-1]-a[0]),n=a.findIndex((e,n)=>n=e&&t<=a[n+1]);return n<0?void 0:n}):void 0}))}},_s=[{name:"bar_color",path:["color"]}],bs=[{name:"bar_color",selector:{text:{}}}],xs=Ca.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e),$s=[...ka,{name:"count",path:["count"],default:"time"},{name:"show_as",path:["show_as"],default:"percent"},{name:"bins",path:["bins"]},{name:"bin_width",path:["bin_width"]},...sr,{name:"bars",path:["bars"],default:"side"},{name:"color_by",path:["color_by"],default:"series"},...xs,...Sa];const ws=e=>(Math.abs(e)<.005?0:e).toFixed(2);function ks(e,t,n,o){const a=o*Math.PI/180;return[e+n*Math.sin(a),t-n*Math.cos(a)]}function Ms(e,t,n,o,a){const r=String(Number(n.toFixed(2))),i=a-o,[s,l]=ks(e,t,n,o);if(i>=359.99){const[a,i]=ks(e,t,n,o+180);return`M${ws(s)} ${ws(l)} A${r} ${r} 0 1 1 ${ws(a)} ${ws(i)} A${r} ${r} 0 1 1 ${ws(s)} ${ws(l)}`}const[c,d]=ks(e,t,n,a),h=i>180?1:0;return`M${ws(s)} ${ws(l)} A${r} ${r} 0 ${h} 1 ${ws(c)} ${ws(d)}`}function As(e,t){const n=e/2;return[-n+t[0]*e,-n+t[1]*e]}function Ss(e){let t=0;for(const n of e)t+=n.codePointAt(0)>=11904?1/.58:1;return t}function Es(e,t=76,n=12,o=8,a=11){const r=e.trim();let i=[r];if(Ss(r)>a){let e=Ss(r);for(let t=r.indexOf(" ");t>0;t=r.indexOf(" ",t+1)){const n=Math.max(Ss(r.slice(0,t)),Ss(r.slice(t+1)));nString(Math.round(1e3*e)/1e3+0);function Ts(e,t,n,o,a,r=11){const{lines:i,size:s}=Es(e,t,n,o,r),l=a-1.2*(i.length-1)*s;return V`${i.map((e,t)=>V`${e}`)}`}const js={layout:"tiles",group:"gauges",height:140,minColumnWidth:134,styles:r` + `,validate(e){if(pn(e),Lo(e),lr(e),Zn(e),ds(e),Vc(e,"count",Dc),Vc(e,"show_as",Rc),Vc(e,"bars",Zc),Vc(e,"color_by",qc),void 0!==e.bins&&!(Number.isInteger(e.bins)&&e.bins>=1&&e.bins<=Uc))throw new Error('"bins" must be a whole number from 1 to 100');if(void 0!==e.bin_width&&!(Wc(e.bin_width)&&e.bin_width>0))throw new Error('"bin_width" must be a number above 0')},editor:function(e){const t=e.hass,o=Fi(`histogram|${wi(e.config)}`,t,()=>[...$i(t,e.config),{type:"grid",name:"",schema:[{name:"count",selector:_i(t,"count",Dc)},{name:"show_as",selector:_i(t,"show_as",Rc)}]},{type:"grid",name:"",schema:[{name:"bins",selector:{number:{min:1,max:Uc,mode:"box"}}},{name:"bin_width",selector:{number:{min:0,mode:"box",step:"any"}}}]},...hs,{type:"grid",name:"",schema:[{name:"bars",selector:_i(t,"bars",Zc)},{name:"color_by",selector:_i(t,"color_by",qc)}]},...Ei(t).map(e=>"grid"===e.type?{...e,schema:e.schema.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e)}:e),...Ai(t)]);return Pi(e,"chart",o,ed)},entityFields:()=>({schema:Kc,fields:Jc}),disconnect(e){bn(e),Fn(e),ys(e)},render(e){const t=Date.now(),{frame:o,opts:a,edges:n,data:r,side:i}=Yc(e,t),s=vn(o,e),l=_n(o,e),c=s===W&&l===W?W:R`${s}${l}`,d=qn(e.raw),u=xn(o,e)??(n.length<2?xn({...o,series:[]},e):void 0);if(u)return Wn("over"===d.header?{...d,header:"above"}:d,c,u);const h=Math.max(0,...r.flatMap(e=>e.heights)),p=ms(e,[n[0],n[n.length-1]],[0,h],{x:{min:n[0],max:n[n.length-1]},yFloor:0}),{scales:m}=p,f=m.y(Math.max(0,p.value.domain[0])),g=r.length,v="overlap"===a.bars&&g>1,y=Z`${r.map((e,t)=>{const o=e.heights.map((o,r)=>{if(!(o>0))return W;const s=m.x(n[r]),l=m.x(n[r+1]),c=Math.min(4,.08*(l-s)),d=(l-s-2*c)/(i?g:1),u=s+c+(i?d*t:0),h=m.y(o),p=a.bands&&e.series.entity.bands.length?Ce(Te((n[r]+n[r+1])/2,e.series.entity.bands).color):e.series.color;return Z``});return Z`${o}`})}`,b=function(e){if(e.length<2)return 0;const t=e[1]-e[0],o=e.every(e=>Math.abs(e-Number(e.toFixed(Fo(t))))<1e-6*t);return o?Fo(t):Math.min(4,Fo(t)+1)}(n),_=sr(e.raw),x=_?vs(e.host):void 0;let w=W;if(void 0!==x&&x{const o=t.whole>0?t.totals[x]/t.whole*100:0;return{color:t.series.color,name:t.series.entity.name,value:`${kn(o,o>0&&o<1?1:0,e.hass)}% · ${Gc(t.totals[x],a.count,e)}`}})})}const $=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_histogram"),names:o.series.map(e=>e.entity.name).join(", ")});return Wn(d,c,In({axes:p,ctx:e,content:Z`${y}`,label:$,xLabel:t=>kn(t,fs(p),e.hass),yLabel:t=>"percent"===a.showAs?`${kn(t,p.value.decimals,e.hass)}%`:"time"===a.count?`${kn(t,p.value.decimals,e.hass)} h`:kn(t,p.value.decimals,e.hass),overlay:R`${"over"===d.header?Vn(c):W}${w}`,hover:_?bs(e,e=>{const t=n[0]+e*(n[n.length-1]-n[0]),o=n.findIndex((e,o)=>o=e&&t<=n[o+1]);return o<0?void 0:o}):void 0}))}},Jc=[{name:"bar_color",path:["color"]}],Kc=[{name:"bar_color",selector:{text:{}}}],Qc=Si.map(e=>"x_axis"===e.name?{...e,name:"hist_x_axis"}:e),ed=[...xi,{name:"count",path:["count"],default:"time"},{name:"show_as",path:["show_as"],default:"percent"},{name:"bins",path:["bins"]},{name:"bin_width",path:["bin_width"]},...us,{name:"bars",path:["bars"],default:"side"},{name:"color_by",path:["color_by"],default:"series"},...Qc,...zi];function td(e){let t=0;for(const o of e)t+=o.codePointAt(0)>=11904?1/.58:1;return t}function od(e,t=76,o=12,a=8,n=11){const r=e.trim();let i=[r];if(td(r)>n){let e=td(r);for(let t=r.indexOf(" ");t>0;t=r.indexOf(" ",t+1)){const o=Math.max(td(r.slice(0,t)),td(r.slice(t+1)));oString(Math.round(1e3*e)/1e3+0);function ld(e,t,o,a,n,r=11){const{lines:i,size:s}=od(e,t,o,a,r),l=n-1.2*(i.length-1)*s;return Z`${i.map((e,t)=>Z`${e}`)}`}const cd={layout:"tiles",group:"gauges",height:140,minColumnWidth:134,styles:r` /* A definite width that shrinks with a narrow tile (the svg inside is sized in %). */ .needle-gauge { display: flex; @@ -1223,6 +1776,9 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host(:not([ready])) .needle-gauge .needle { transform: rotate(-90deg) !important; } + :host(:not([ready])) .needle-gauge.fitted .needle { + transform: rotate(var(--needle-start)) !important; + } .needle-gauge .hub { fill: var(--primary-text-color); } @@ -1237,6 +1793,15 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; fill: var(--primary-text-color); } + .needle-gauge .tick-label { + font-size: 9px; + fill: var(--secondary-text-color); + } + .needle-gauge .center-text { + font-size: 15px; + font-weight: 500; + fill: var(--primary-text-color); + } .needle-gauge .na { font-size: 12px; fill: var(--secondary-text-color); @@ -1258,39 +1823,53 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de text-overflow: ellipsis; white-space: nowrap; } - `,render(e,{show:t,hass:n}){const o=function(e,t,n){const o=n-t,a=[];return o>0?(e.forEach((r,i)=>{const s=0===i?t:Math.max(r.from,t),l=Math.min(i+1s&&a.push({from:(s-t)/o,to:(l-t)/o,color:r.color})}),a):a}(e.bands,e.min,e.max),a=o.map((e,t)=>{const n=180*e.from-90+(t>0?1:0),a=180*e.to-90-(tn?V``:W}),r=Array.from({length:11},(e,t)=>{const[n,o]=ks(Cs,zs,36,18*t-90),[a,r]=ks(Cs,zs,40,18*t-90);return V``});let i=W;if(t.target&&null!==e.targetPosition&&e.available){const t=180*e.targetPosition-90,[n,o]=ks(Cs,zs,57,t),[a,r]=ks(Cs,zs,64,t-3),[s,l]=ks(Cs,zs,64,t+3);i=V``}const s=!t.value;return R`
- - ${t.name||t.icon?R`
+ `,render(e,{show:t,hass:o}){const a=e.config.gauge,n=Je(id,a),r=e=>Qe(n,e),i=function(e,t,o){const a=o-t,n=[];return a>0?(e.forEach((r,i)=>{const s=0===i?t:Math.max(r.from,t),l=Math.min(i+1s&&n.push({from:(s-t)/a,to:(l-t)/a,color:r.color})}),n):n}(e.bands,e.min,e.max),s=i.map((e,t)=>{const o=r(e.from)+(t>0?1:0),a=r(e.to)-(to?Z``:W}),l=e=>{const[t,o]=We(ad,nd,36,e),[a,n]=We(ad,nd,40,e);return Z``},c=a?.tickLabels?function(e,t,o=5){if(!(Number.isFinite(e)&&Number.isFinite(t)&&t>e))return[];const a=ot(t-e,o),n=[];for(let r=Math.ceil(e/a)*a;re+.4*a&&n.push(Number(r.toPrecision(12)));return[e,...n,t]}(e.min,e.max):[],d=e.max-e.min,u=c.length?c.map(t=>l(r((t-e.min)/d))):Array.from({length:11},(e,t)=>l(r(t/10))),h=ot(d),p=t.target&&null!==e.targetPosition,m=p?67:60,f=Ke(a)||c.length>0,g=f?Math.max(94,Math.ceil(tt(ad,nd,rd,n[0],n[1],6).y1)+14):94,v=c.map(t=>{const a=r((t-e.min)/d),[n,i]=We(ad,nd,m,a),{anchor:s,dy:l}=nt(a);return Z`${kn(t,at(t,h),o)}`});let y=W;if(t.target&&null!==e.targetPosition&&e.available){const t=r(e.targetPosition),[o,a]=We(ad,nd,57,t),[n,i]=We(ad,nd,64,t-3),[s,l]=We(ad,nd,64,t+3);y=Z``}const b=!t.value,_=e.available?rt(e,a):{kind:"value"};let x={x:0,y:0,w:130,h:b?82:104};if(f){const t=function(e,t,o,a,n,r){const i=[tt(ad,nd,rd,t[0],t[1],n.hasTarget?15:7),tt(ad,nd,43,t[0],t[1],0),{x0:57,y0:58,x1:73,y1:74},n.compact?{x0:2,y0:40,x1:128,y1:82}:{x0:2,y0:n.valueY-16,x1:128,y1:n.valueY+6}],s=e.max-e.min;for(const c of o){const o=Qe(t,(c-e.min)/s),[l,d]=We(ad,nd,n.labelR,o),{anchor:u,dy:h}=nt(o),p=9*kn(c,at(c,a),r).length*.6,m="start"===u?l:"end"===u?l-p:l-p/2,f=d+9*h;i.push({x0:m,y0:f-9,x1:m+p,y1:f+2})}const l=et(...i);return{x0:Math.floor(l.x0-2),y0:Math.floor(l.y0-2),x1:Math.ceil(l.x1+2),y1:Math.ceil(l.y1+2)}}(e,n,c,h,{hasTarget:p,labelR:m,valueY:g,compact:b},o);x={x:t.x0,y:t.y0,w:t.x1-t.x0,h:t.y1-t.y0}}const w=f?`width: min(${sd(x.w)}px, 100%); aspect-ratio: ${sd(x.w)} / ${sd(x.h)}`:void 0;return R`
+ + ${t.name&&"name"!==a?.center||t.icon?R`
${t.icon?R``:W} - ${t.name?R`${e.name}`:W} + ${t.name&&"name"!==a?.center?R`${e.name}`:W}
`:W} -
`;var l,c,d,h,u}};function Os(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function Ns(e,t=100){const n=e.reduce((e,t)=>e+Math.max(0,t),0);if(n<=0)return e.map(()=>0);const o=e.map(e=>Math.max(0,e)/n*t),a=o.map(Math.floor);let r=t-a.reduce((e,t)=>e+t,0);const i=o.map((e,t)=>({i:t,rest:e-a[t]})).sort((e,t)=>t.rest-e.rest||e.i-t.i);for(const{i:s}of i){if(r<=0)break;a[s]+=1,r-=1}return a}function Us(e,t){return t>0?e/t*(180/Math.PI):0}function Fs(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function Ps(e,t,n,o,a="primary"){return"bands"===o?ze(e.band.color):"shades"===o?function(e,t,n){return n<=1||t<=0?e:`color-mix(in srgb, ${e} ${Math.round(100-Math.min(t,n-1)/(n-1)*65)}%, var(--card-background-color, #fff))`}(ze(a),t,n):ze(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??zo[t%zo.length])}function Ds(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function Hs(e,t,n,o=e.map(e=>e.available?e.value:0)){const a=e.map((e,t)=>({e:e,v:o[t]})).filter(({e:e})=>e.available);if(!a.length)return;const r=a.reduce((e,{v:t})=>e+t,0),i="total"===t?r:r/a.length,s=Math.max(...a.map(({e:e})=>e.decimals));return{value:i,text:Io(i,s,n),unit:Ds(e)}}function Bs(e){return Se(e.config)?0:W}function Is(e,t){const{show:n}=t.config;return!e.length||!n.name&&!n.value&&e.every(e=>void 0===e.share)?W:R`
+
`;var $,k,M,z}};function dd(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function ud(e,t=100){const o=e.reduce((e,t)=>e+Math.max(0,t),0);if(o<=0)return e.map(()=>0);const a=e.map(e=>Math.max(0,e)/o*t),n=a.map(Math.floor);let r=t-n.reduce((e,t)=>e+t,0);const i=a.map((e,t)=>({i:t,rest:e-n[t]})).sort((e,t)=>t.rest-e.rest||e.i-t.i);for(const{i:s}of i){if(r<=0)break;n[s]+=1,r-=1}return n}function hd(e,t){return t>0?e/t*(180/Math.PI):0}function pd(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function md(e,t,o,a,n="primary"){return"bands"===a?Ce(e.band.color):"shades"===a?function(e,t,o){return o<=1||t<=0?e:`color-mix(in srgb, ${e} ${Math.round(100-Math.min(t,o-1)/(o-1)*65)}%, var(--card-background-color, #fff))`}(Ce(n),t,o):Ce(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??un[t%un.length])}function fd(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function gd(e,t,o,a=e.map(e=>e.available?e.value:0)){const n=e.map((e,t)=>({e:e,v:a[t]})).filter(({e:e})=>e.available);if(!n.length)return;const r=n.reduce((e,{v:t})=>e+t,0),i="total"===t?r:r/n.length,s=Math.max(...n.map(({e:e})=>e.decimals));return{value:i,text:kn(i,s,o),unit:fd(e)}}function vd(e){return Ae(e.config)?0:W}function yd(e,t){const{show:o}=t.config;return!e.length||!o.name&&!o.value&&e.every(e=>void 0===e.share)?W:R`
${e.map(e=>R`
- ${n.name?R`${e.entity.name}`:W} - ${n.value?R`${e.entity.display}`:W} + ${o.name?R`${e.entity.name}`:W} + ${o.value?R`${e.entity.display}`:W} ${void 0===e.share?W:R`${e.share}`}
`)} -
`}function Rs(e,t){return Ro(Io(e,0,t),"%")}function Vs(e,t){return at(e.hass,"parts_aria",{style:at(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function qs(e,t,n){return R``}function bd(e,t){return Mn(kn(e,0,t),"%")}function _d(e,t){return _t(e.hass,"parts_aria",{style:_t(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function xd(e,t,o){return R``}function Ws(e,t,n){const o=e[t];if(void 0!==o&&!n.includes(o))throw new Error(`"${t}" must be one of: ${n.join(", ")}`)}function Zs(e,t,n,o){const a=e[t];if(void 0!==a&&("number"!=typeof a||!Number.isFinite(a)||ao))throw new Error(`"${t}" must be a number from ${n} to ${o}`)}function Ys(e,t){const n=e[t];if(void 0!==n&&"boolean"!=typeof n)throw new Error(`"${t}" must be true or false`)}function Gs(e,t){const n=e[t];if(void 0!==n&&"string"!=typeof n)throw new Error(`"${t}" must be text`)}nt("en",{editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_color_by_shades:"Shades of one colour",editor_shade_color:"Shade colour (palette name or CSS colour)",editor_show_percent:"Show each share as a percentage",editor_center:"In the middle",editor_center_total:"Total",editor_center_average:"Average",editor_center_none:"Nothing",parts_total:"Total",parts_average:"Average",parts_aria:"{style} of {names}"});const Xs=r` + ${o?R`
${o}
`:W} +
`}function wd(e,t,o){const a=e[t];if(void 0!==a&&!o.includes(a))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}function $d(e,t,o,a){const n=e[t];if(void 0!==n&&("number"!=typeof n||!Number.isFinite(n)||na))throw new Error(`"${t}" must be a number from ${o} to ${a}`)}function kd(e,t){const o=e[t];if(void 0!==o&&"boolean"!=typeof o)throw new Error(`"${t}" must be true or false`)}function Md(e,t){const o=e[t];if(void 0!==o&&"string"!=typeof o)throw new Error(`"${t}" must be text`)}yt("en",{editor_color_by:"Colour",editor_color_by_series:"One colour per entity",editor_color_by_bands:"By colour band",editor_color_by_shades:"Shades of one colour",editor_shade_color:"Shade colour (palette name or CSS colour)",editor_show_percent:"Show each share as a percentage",editor_center:"In the middle",editor_center_total:"Total",editor_center_average:"Average",editor_center_none:"Nothing",parts_total:"Total",parts_average:"Average",parts_aria:"{style} of {names}"});const zd=r` .parts { display: flex; flex-direction: column; @@ -1414,26 +1993,26 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; white-space: nowrap; } -`;nt("en",{editor_style_pictograph:"Pictograph",editor_unit_icon:"Icon (each entity can have its own)",editor_per_icon:"Each icon stands for (empty: automatic)",editor_group_size:"Icons per group (0: no groups)",editor_partial:"Part-fill an icon for fractions"});const Js=["series","bands"];function Ks(e,t,n,o){const{show:a}=n.config,r=function(e,t,n){const o=t-e;if(!(o>0))return{count:0,per:1};const a=void 0!==n&&n>0?n:o<=20?1:o/10,r=Math.ceil(o/a-1e-9);return r>100?{count:100,per:o/100}:{count:r,per:a}}(e.min,e.max,o.perIcon),i=e.available?function(e,t,n,o){const a=Math.min(n.count,Math.max(0,(e-t)/n.per)),r=o?a:Math.floor(a+1e-9);return Array.from({length:n.count},(e,t)=>Math.min(1,Math.max(0,r-t)))}(e.value,e.min,r,o.partial):r.count?Array(r.count).fill(0):[],s=Ps(e,t,n.items.length,o.colorBy),l=e.config.icon??o.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=r.count>20?20:28,d=e.available?R`${e.valueText}0))return{count:0,per:1};const n=void 0!==o&&o>0?o:a<=20?1:a/10,r=Math.ceil(a/n-1e-9);return r>100?{count:100,per:a/100}:{count:r,per:n}}(e.min,e.max,a.perIcon),i=e.available?function(e,t,o,a){const n=Math.min(o.count,Math.max(0,(e-t)/o.per)),r=a?n:Math.floor(n+1e-9);return Array.from({length:o.count},(e,t)=>Math.min(1,Math.max(0,r-t)))}(e.value,e.min,r,a.partial):r.count?Array(r.count).fill(0):[],s=md(e,t,o.items.length,a.colorBy),l=e.config.icon??a.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=r.count>20?20:28,d=e.available?R`${e.valueText}/ ${Ro(Io(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}/ ${Mn(kn(e.max,Number.isInteger(e.max)?0:e.decimals,o.hass),e.unit)}`:e.display;return R`
- ${a.name?R`${e.name}`:W} + ${n.name?R`${e.name}`:W} - ${a.value?R`${d}`:W} -
`}const Qs=[{name:"unit_icon",path:["unit_icon"]},{name:"per_icon",path:["per_icon"]},{name:"group_size",path:["group_size"],default:5},{name:"color_by",path:["color_by"],default:"series"},{name:"partial",path:["partial"],default:!0}];const el={layout:"chart",group:"totals",height:180,styles:r` + ${n.value?R`${d}`:W} +
`}const Ed=[{name:"unit_icon",path:["unit_icon"]},{name:"per_icon",path:["per_icon"]},{name:"group_size",path:["group_size"],default:5},{name:"color_by",path:["color_by"],default:"series"},{name:"partial",path:["partial"],default:!0}];const Cd={layout:"chart",group:"totals",height:180,styles:r` .pictograph { display: flex; flex-direction: column; @@ -1519,12 +2098,12 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--divider-color, rgba(127, 127, 127, 0.3)); } } - `,thumbnail:()=>V` - ${[0,1,2,3,4].map(e=>V` - `)}`,validate(e){Gs(e,"unit_icon");const t=e.per_icon;if(void 0!==t&&("number"!=typeof t||!Number.isFinite(t)||t<=0))throw new Error('"per_icon" must be a number more than 0');if(Zs(e,"group_size",0,50),void 0!==e.group_size&&!Number.isInteger(e.group_size))throw new Error('"group_size" must be a whole number');Ws(e,"color_by",Js),Ys(e,"partial")},editor:function(e){const t=Ta("pictograph",e.hass,()=>[{name:"unit_icon",selector:{icon:{}}},{type:"grid",name:"",schema:[{name:"per_icon",selector:{number:{min:0,step:"any",mode:"box"}}},{name:"group_size",selector:{number:{min:0,max:50,mode:"box"}}},{name:"color_by",selector:wa(e.hass,"color_by",Js)}]},{name:"partial",selector:{boolean:{}}}]);return ja(e,"chart",t,Qs)},render:function(e){const t={perIcon:(n=e.raw).per_icon,unitIcon:n.unit_icon||void 0,groupSize:n.group_size??5,partial:!1!==n.partial,colorBy:n.color_by??"series"};var n;return R`
- ${e.items.map((n,o)=>Ks(n,o,e,t))} -
`}};nt("en",{editor_style_pie:"Pie / donut",editor_shape:"Shape",editor_shape_donut:"Donut",editor_shape_pie:"Pie",editor_center_label:"Line under the total",editor_gap:"Space between slices",editor_rounded:"Rounded ends",editor_gradient:"Gradient fills"});const tl=["donut","pie"],nl=["total","none"],ol=["series","bands","shades"],al=48;function rl(e,t){const n=function(e){const t=e.reduce((e,t)=>e+Math.max(0,t),0);return e.map(e=>t>0?Math.max(0,e)/t:0)}(e),o=n.filter(e=>e>0).length,a="donut"===t.shape?41.5:24;let r=0;return"donut"===t.shape&&(o>1&&(r+=Us(t.gap,a)),t.rounded&&(r+=Us(13,a))),function(e,t=0,n=360){const o=n-t;let a=t;return e.map(e=>{const t=a;return a+=e*o,{start:t,end:a}})}(n).map((e,t)=>n[t]>0?function(e,t){const n=e.end-e.start,o=Math.min(Math.max(0,t),n)/2;return{start:e.start+o,end:e.end-o}}(e,r):void 0)}const il=[{name:"shape",path:["shape"],default:"donut"},{name:"color_by",path:["color_by"],default:"series"},{name:"center",path:["center"],default:"total"},{name:"center_label",path:["center_label"],default:""},{name:"gap",path:["gap"],default:2},{name:"shade_color",path:["shade_color"],default:""},{name:"rounded",path:["rounded"],default:!1},{name:"gradient",path:["gradient"],default:!1},{name:"show_percent",path:["show_percent"],default:!0}];const sl={layout:"chart",group:"totals",height:200,styles:r` - ${Xs} + `,thumbnail:()=>Z` + ${[0,1,2,3,4].map(e=>Z` + `)}`,validate(e){Md(e,"unit_icon");const t=e.per_icon;if(void 0!==t&&("number"!=typeof t||!Number.isFinite(t)||t<=0))throw new Error('"per_icon" must be a number more than 0');if($d(e,"group_size",0,50),void 0!==e.group_size&&!Number.isInteger(e.group_size))throw new Error('"group_size" must be a whole number');wd(e,"color_by",Ad),kd(e,"partial")},editor:function(e){const t=Fi("pictograph",e.hass,()=>[{name:"unit_icon",selector:{icon:{}}},{type:"grid",name:"",schema:[{name:"per_icon",selector:{number:{min:0,step:"any",mode:"box"}}},{name:"group_size",selector:{number:{min:0,max:50,mode:"box"}}},{name:"color_by",selector:_i(e.hass,"color_by",Ad)}]},{name:"partial",selector:{boolean:{}}}]);return Pi(e,"chart",t,Ed)},render:function(e){const t={perIcon:(o=e.raw).per_icon,unitIcon:o.unit_icon||void 0,groupSize:o.group_size??5,partial:!1!==o.partial,colorBy:o.color_by??"series"};var o;return R`
+ ${e.items.map((o,a)=>Sd(o,a,e,t))} +
`}};yt("en",{editor_style_pie:"Pie / donut",editor_shape:"Shape",editor_shape_donut:"Donut",editor_shape_pie:"Pie",editor_center_label:"Line under the total",editor_gap:"Space between slices",editor_rounded:"Rounded ends",editor_gradient:"Gradient fills"});const jd=["donut","pie"],Nd=["total","none"],Ld=["series","bands","shades"],Td=48;function Od(e,t){const o=function(e){const t=e.reduce((e,t)=>e+Math.max(0,t),0);return e.map(e=>t>0?Math.max(0,e)/t:0)}(e),a=o.filter(e=>e>0).length,n="donut"===t.shape?41.5:24;let r=0;return"donut"===t.shape&&(a>1&&(r+=hd(t.gap,n)),t.rounded&&(r+=hd(13,n))),function(e,t=0,o=360){const a=o-t;let n=t;return e.map(e=>{const t=n;return n+=e*a,{start:t,end:n}})}(o).map((e,t)=>o[t]>0?function(e,t){const o=e.end-e.start,a=Math.min(Math.max(0,t),o)/2;return{start:e.start+a,end:e.end-a}}(e,r):void 0)}const Fd=[{name:"shape",path:["shape"],default:"donut"},{name:"color_by",path:["color_by"],default:"series"},{name:"center",path:["center"],default:"total"},{name:"center_label",path:["center_label"],default:""},{name:"gap",path:["gap"],default:2},{name:"shade_color",path:["shade_color"],default:""},{name:"rounded",path:["rounded"],default:!1},{name:"gradient",path:["gradient"],default:!1},{name:"show_percent",path:["show_percent"],default:!0}];const Pd={layout:"chart",group:"totals",height:200,styles:r` + ${zd} .style-pie .track { stroke: var(--cd-track); } @@ -1541,18 +2120,18 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host([static]) .style-pie .separator { transition: none; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,validate(e){Ws(e,"shape",tl),Ws(e,"center",nl),Ws(e,"color_by",ol),Zs(e,"gap",0,10),Ys(e,"rounded"),Ys(e,"gradient"),Ys(e,"show_percent"),Gs(e,"shade_color"),Gs(e,"center_label")},editor:function(e){const t=Ta("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:wa(e.hass,"shape",tl)},{name:"color_by",selector:wa(e.hass,"color_by",ol)},{name:"center",selector:wa(e.hass,"center",nl)},{name:"center_label",selector:{text:{}}},{name:"gap",selector:{number:{min:0,max:10,step:.5,mode:"slider"}}},{name:"shade_color",selector:{text:{}}}]},{name:"rounded",selector:{boolean:{}}},{name:"gradient",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return ja(e,"chart",t,il)},render:function(e){const t={shape:(n=e.raw).shape??"donut",center:n.center??"total",colorBy:n.color_by??"series",shadeColor:n.shade_color||"primary",gap:n.gap??2,rounded:!0===n.rounded,gradient:!0===n.gradient,percent:!1!==n.show_percent,centerLabel:n.center_label??""};var n;const o=e.items,a=Os(o),r=o.map((e,n)=>Ps(e,n,o.length,t.colorBy,t.shadeColor)),i=rl(a,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:al,d=s?35/al:0,h=i.filter(Boolean).length,u=t.gradient?V`${o.map((e,t)=>i[t]?function(e,t,n){return V` - + pathLength="100" stroke-dasharray="22 78" stroke-dashoffset="-76" transform="rotate(-90 32 20)" />`,validate(e){wd(e,"shape",jd),wd(e,"center",Nd),wd(e,"color_by",Ld),$d(e,"gap",0,10),kd(e,"rounded"),kd(e,"gradient"),kd(e,"show_percent"),Md(e,"shade_color"),Md(e,"center_label")},editor:function(e){const t=Fi("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:_i(e.hass,"shape",jd)},{name:"color_by",selector:_i(e.hass,"color_by",Ld)},{name:"center",selector:_i(e.hass,"center",Nd)},{name:"center_label",selector:{text:{}}},{name:"gap",selector:{number:{min:0,max:10,step:.5,mode:"slider"}}},{name:"shade_color",selector:{text:{}}}]},{name:"rounded",selector:{boolean:{}}},{name:"gradient",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Pi(e,"chart",t,Fd)},render:function(e){const t={shape:(o=e.raw).shape??"donut",center:o.center??"total",colorBy:o.color_by??"series",shadeColor:o.shade_color||"primary",gap:o.gap??2,rounded:!0===o.rounded,gradient:!0===o.gradient,percent:!1!==o.show_percent,centerLabel:o.center_label??""};var o;const a=e.items,n=dd(a),r=a.map((e,o)=>md(e,o,a.length,t.colorBy,t.shadeColor)),i=Od(n,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:Td,d=s?35/Td:0,u=i.filter(Boolean).length,h=t.gradient?Z`${a.map((e,t)=>i[t]?function(e,t,o){return Z` + - `}(`cd-pie-${t}`,r[t],d):W)}`:W,m=o.map((e,n)=>{const o=i[n];if(!o)return W;const a=t.gradient?`url(#cd-pie-${n})`:r[n];return V``}(`cd-pie-${t}`,r[t],d):W)}`:W,p=a.map((e,o)=>{const a=i[o];if(!a)return W;const n=t.gradient?`url(#cd-pie-${o})`:r[o];return Z` - `}),p=!s&&t.gap>0&&h>1?i.map(e=>e?V``:W):W,f=0===h?V``:W,g=Ns(a),y=o.map((n,o)=>({index:o,entity:n,color:r[o],share:t.percent&&a[o]>0?Rs(g[o],e.hass):void 0})),v=Hs(o,"total",e.hass,a),_=s&&"total"===t.center&&v?qs(v.text,"",t.centerLabel||Ds(o)):W;return R`
+ `}),m=!s&&t.gap>0&&u>1?i.map(e=>e?Z``:W):W,f=0===u?Z``:W,g=ud(n),v=a.map((o,a)=>({index:a,entity:o,color:r[a],share:t.percent&&n[a]>0?bd(g[a],e.hass):void 0})),y=gd(a,"total",e.hass,n),b=s&&"total"===t.center&&y?xd(y.text,"",t.centerLabel||fd(a)):W;return R`
- - ${u}${f}${m}${p} + + ${h}${f}${p}${m} - ${_} + ${b}
- ${Is(y,e)} -
`}};let ll=0;const cl=new WeakMap;const dl=60,hl=60,ul=e=>String(Math.round(1e3*e)/1e3+0);function ml(e){const[t,n]=As(e,[0,1]),o=Ms(dl,hl,46,t,n),a=e<360;return{layout:"tiles",group:"gauges",height:a?135:155,minColumnWidth:128,styles:r` + ${yd(v,e)} +
`}};let Dd=0;const Ud=new WeakMap;const Hd=60,Id=60,Bd=e=>String(Math.round(1e3*e)/1e3+0);function Rd(e){return Math.min(22,Math.floor(124/Math.max(1,e.length)))}function Zd(e){const t=function(e,t){const o=e/2;return[-o+t[0]*e,-o+t[1]*e]}(e,[0,1]),o=Ve(Hd,Id,46,t[0],t[1]),a=e<360;return{layout:"tiles",group:"gauges",height:a?135:155,minColumnWidth:128,styles:r` /* A definite width: the dial is a size container and can't size the tile itself. */ .radial { display: flex; @@ -1643,6 +2222,10 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; fill: var(--primary-text-color); } + .radial .center-text { + font-weight: 500; + fill: var(--primary-text-color); + } .radial .unit { font-size: 11px; fill: var(--secondary-text-color); @@ -1666,6 +2249,13 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform: translate(-50%, -50%); --mdc-icon-size: 22.58cqw; } + /* A custom sweep crops --y0 drawing units off the top: move the icon up by as much. */ + .radial .dial.fitted .icon { + top: calc(25cqw - var(--y0) * 0.8065cqw); + } + .radial .dial.fitted .icon.centre { + top: calc(48.39cqw - var(--y0) * 0.8065cqw); + } .radial .name { max-width: min(124px, 100%); font-size: 12px; @@ -1674,44 +2264,50 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de text-overflow: ellipsis; white-space: nowrap; } - `,render(t,{show:n,hass:r}){const i=function(e){let t=cl.get(e);return t||(t="cd-g-"+ ++ll,cl.set(e,t)),t}(t.config),s=t.span[1]-t.span[0]<=0,l=`stroke-dasharray: ${ul(100*(t.span[1]-t.span[0]))} 100; stroke-dashoffset: ${ul(100*-t.span[0])}`,c=(d=t.band.color,Object.prototype.hasOwnProperty.call(Ce,d)?Ce[d].fallback:d);var d;const h=function(e){return!e.includes("var(")&&("undefined"==typeof CSS||"function"!=typeof CSS.supports||CSS.supports("color",e))}(c)?`url(#${i}) ${c}`:`url(#${i})`,u=n.icon?6:0,m=(p=t.valueText,Math.min(22,Math.floor(124/Math.max(1,p.length))));var p;let f=W;if(n.target&&null!==t.targetPosition&&t.available){const n=As(e,[t.targetPosition,t.targetPosition])[0],[o,a]=ks(dl,hl,39.5,n),[r,i]=ks(dl,hl,52.5,n);f=V``}return R`
-
-
+
+ - ${n.icon&&t.available?R``:W}
- ${n.name?R`${t.name}`:W} -
`}}}const pl=ml(360),fl=ml(270);nt("en",{editor_style_rings:"Rings",editor_start_angle:"Start angle (0 is the top)",editor_end_angle:"End angle",editor_rounded_ends:"Rounded ends"});const gl=["average","total","none"],yl=["series","bands"];function vl(e){return{start:e.start_angle??0,end:e.end_angle??270,center:e.center??"average",colorBy:e.color_by??"series",rounded:!1!==e.rounded_ends}}const _l=[{name:"start_angle",path:["start_angle"],default:0},{name:"end_angle",path:["end_angle"],default:270},{name:"center",path:["center"],default:"average"},{name:"color_by",path:["color_by"],default:"series"},{name:"rounded_ends",path:["rounded_ends"],default:!0}];const bl={layout:"chart",group:"totals",height:200,styles:r` - ${Xs} + ${n.name&&"name"!==s?.center?R`${e.name}`:W} +
`}}}const qd=Zd(360),Wd=Zd(270);yt("en",{editor_style_rings:"Rings",editor_start_angle:"Start angle (0 is the top)",editor_end_angle:"End angle",editor_rounded_ends:"Rounded ends"});const Vd=["average","total","none"],Yd=["series","bands"];function Gd(e){return{start:e.start_angle??0,end:e.end_angle??270,center:e.center??"average",colorBy:e.color_by??"series",rounded:!1!==e.rounded_ends}}const Xd=[{name:"start_angle",path:["start_angle"],default:0},{name:"end_angle",path:["end_angle"],default:270},{name:"center",path:["center"],default:"average"},{name:"color_by",path:["color_by"],default:"series"},{name:"rounded_ends",path:["rounded_ends"],default:!0}];const Jd={layout:"chart",group:"totals",height:200,styles:r` + ${zd} .style-rings .centre-box { position: absolute; inset: 0 0 auto 0; @@ -1729,7 +2325,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform-box: view-box; transform-origin: 50px 50px; } - `,thumbnail:()=>V` + `,thumbnail:()=>Z` `,validate(e){Zs(e,"start_angle",-360,360),Zs(e,"end_angle",-360,360);const{start:t,end:n}=vl(e);if(n<=t||n-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');Ws(e,"center",gl),Ws(e,"color_by",yl),Ys(e,"rounded_ends")},editor:function(e){const t=Ta("rings",e.hass,()=>[{type:"grid",name:"",schema:[{name:"start_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"end_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"center",selector:wa(e.hass,"center",gl)},{name:"color_by",selector:wa(e.hass,"color_by",yl)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return ja(e,"chart",t,_l)},render:function(e){const t=vl(e.raw),n=e.items,{radii:o,width:a}=function(e){if(e<=0)return{radii:[],width:0};const t=Math.min(9,28/(1.33*e)),n=1.33*t;return{radii:Array.from({length:e},(e,o)=>48-t/2-o*n),width:t}}(n.length),r=n.map((e,o)=>Ps(e,o,n.length,t.colorBy)),i=t.rounded?"round":"butt",s=Fs({start:t.start,end:t.end}),l=n.map((e,n)=>{const l=e.available?V``:W;return V``,validate(e){$d(e,"start_angle",-360,360),$d(e,"end_angle",-360,360);const{start:t,end:o}=Gd(e);if(o<=t||o-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');wd(e,"center",Vd),wd(e,"color_by",Yd),kd(e,"rounded_ends")},editor:function(e){const t=Fi("rings",e.hass,()=>[{type:"grid",name:"",schema:[{name:"start_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"end_angle",selector:{number:{min:-360,max:360,step:5,mode:"box"}}},{name:"center",selector:_i(e.hass,"center",Vd)},{name:"color_by",selector:_i(e.hass,"color_by",Yd)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return Pi(e,"chart",t,Xd)},render:function(e){const t=Gd(e.raw),o=e.items,{radii:a,width:n}=function(e){if(e<=0)return{radii:[],width:0};const t=Math.min(9,28/(1.33*e)),o=1.33*t;return{radii:Array.from({length:e},(e,a)=>48-t/2-a*o),width:t}}(o.length),r=o.map((e,a)=>md(e,a,o.length,t.colorBy)),i=t.rounded?"round":"butt",s=pd({start:t.start,end:t.end}),l=o.map((e,o)=>{const l=e.available?Z``:W;return Z` - ${l} - `}),c="none"===t.center?void 0:Hs(n,t.center,e.hass),d=c?qs(c.text,c.unit,at(e.hass,`parts_${t.center}`)):W,h=n.map((e,t)=>({index:t,entity:e,color:r[t]})),u=function(e,t){const n=e=>-Math.cos(e*Math.PI/180),o=360*Math.ceil((e-180)/360)+180<=t?1:Math.max(n(e),n(t));return Math.min(100,Math.max(66,50+48*o+6))}(t.start,t.end);return R`
-
- + `}),c="none"===t.center?void 0:gd(o,t.center,e.hass),d=c?xd(c.text,c.unit,_t(e.hass,`parts_${t.center}`)):W,u=o.map((e,t)=>({index:t,entity:e,color:r[t]})),h=function(e,t){const o=e=>-Math.cos(e*Math.PI/180),a=360*Math.ceil((e-180)/360)+180<=t?1:Math.max(o(e),o(t));return Math.min(100,Math.max(66,50+48*a+6))}(t.start,t.end);return R`
+
+ ${l} -
${d}
+
${d}
- ${Is(h,e)} -
`}},xl={layout:"chart",group:"distribution",height:240,styles:Wr,thumbnail:()=>V` + ${yd(u,e)} +
`}},Kd={layout:"chart",group:"distribution",height:240,styles:Xs,thumbnail:()=>Z` @@ -1762,7 +2358,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de - `,validate:e=>Br(e,"scatter"),editor:Jr("scatter"),entityFields:()=>ei(),disconnect:Zr,render:e=>Vr(e,"scatter")};function $l(e){const[t,n]=e;return Array.from({length:10},(e,o)=>{const a=(o+.5)/10;return a>t&&a<=n})}const wl={layout:"rows",group:"bars",height:50,minColumnWidth:180,styles:r` + `,validate:e=>qs(e,"scatter"),editor:tl("scatter"),entityFields:()=>nl(),disconnect:Js,render:e=>Ys(e,"scatter")};function Qd(e){const[t,o]=e;return Array.from({length:10},(e,a)=>{const n=(a+.5)/10;return n>t&&n<=o})}const eu={layout:"rows",group:"bars",height:50,minColumnWidth:180,styles:r` /* Each row is measured on its own, so a narrow column (columns: 2 or 3) switches layout even when the card as a whole is wide. A container can't restyle itself, hence .seg. */ .style-segmented .entity { @@ -1878,12 +2474,12 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de padding-bottom: 0; } } - `,render(e,{show:t,hass:n}){const o=e.available?$l(e.span):$l([0,0]),a=t.target&&e.available&&null!==e.targetPosition?Math.min(9,Math.max(0,Math.floor(10*e.targetPosition))):-1,r=!e.available;return R`
+ `,render(e,{show:t,hass:o}){const a=e.available?Qd(e.span):Qd([0,0]),n=t.target&&e.available&&null!==e.targetPosition?Math.min(9,Math.max(0,Math.floor(10*e.targetPosition))):-1,r=!e.available;return R`
${t.icon?R``:W}
${t.name||r?R`
@@ -1891,11 +2487,11 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${r?R`${e.display}`:W}
`:W}
- ${o.map((e,t)=>R``)} + ${a.map((e,t)=>R``)}
${t.value?R`${e.available?e.display:""}`:W} -
`}},kl={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` +
`}},tu={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` .style-tank .entity { display: flex; flex-direction: column; @@ -1957,12 +2553,12 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de text-overflow: ellipsis; white-space: nowrap; } - `,render:(e,{show:t,hass:n})=>R` + `,render:(e,{show:t,hass:o})=>R` ${t.icon?R``:W} ${t.value||!e.available?R`${e.display}`:W}
@@ -1970,7 +2566,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
${t.name?R`${e.name}`:W} - `},Ml={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` + `},ou={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` .style-thin .entity { display: flex; align-items: center; @@ -2036,38 +2632,38 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de background: var(--primary-text-color); transition: left var(--cd-duration) var(--cd-easing); } - `,render(e,{show:t,hass:n}){const o=t.value||!e.available;return R` + `,render(e,{show:t,hass:o}){const a=t.value||!e.available;return R` ${t.icon?R``:W}
- ${t.name||o?R`
+ ${t.name||a?R`
${t.name?R`${e.name}`:W} - ${o?R`${e.display}`:W} + ${a?R`${e.display}`:W}
`:W}
${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- `}};nt("en",{editor_style_waffle:"Waffle",editor_waffles:"Waffles",editor_waffles_one:"One, split between the entities",editor_waffles_each:"One per entity, side by side",editor_headline:"Show the first share in large type"});const Al=["one","each"],Sl=["series","bands"];function El(e){if(1===e.length){const t=e[0];return t.available?[Ns([t.position,1-t.position])[0]]:[0]}return Ns(Os(e),100)}function Cl(e,t){const{col:n,row:o}=function(e){return{col:e%10,row:9-Math.floor(e/10)}}(e);return V``}function zl(e,t,n,o){let a=0;const r=e.map((e,o)=>{const r=a;if(a+=e,!e)return W;const i=n[o];return V` - ${Array.from({length:e},(e,n)=>Cl(r+n,t[o]))} - `}),i=Array.from({length:100-a},(e,t)=>Cl(a+t,void 0));return R` + >`}function su(e,t,o,a){let n=0;const r=e.map((e,a)=>{const r=n;if(n+=e,!e)return W;const i=o[a];return Z` + ${Array.from({length:e},(e,o)=>iu(r+o,t[a]))} + `}),i=Array.from({length:100-n},(e,t)=>iu(n+t,void 0));return R` ${r} ${i} - `}const Ll=[{name:"waffles",path:["waffles"],default:"one"},{name:"color_by",path:["color_by"],default:"series"},{name:"headline",path:["headline"],default:!0},{name:"show_percent",path:["show_percent"],default:!0}];const Tl={layout:"chart",group:"totals",height:210,styles:r` - ${Xs} + `}const lu=[{name:"waffles",path:["waffles"],default:"one"},{name:"color_by",path:["color_by"],default:"series"},{name:"headline",path:["headline"],default:!0},{name:"show_percent",path:["show_percent"],default:!0}];const cu={layout:"chart",group:"totals",height:210,styles:r` + ${zd} .waffle-box { flex: none; width: 180px; @@ -2154,38 +2750,3997 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--secondary-text-color); font-size: 0.75em; } - `,thumbnail:()=>V` - ${Array.from({length:25},(e,t)=>{const n=t%5,o=4-Math.floor(t/5);return V``})}`,validate(e){Ws(e,"waffles",Al),Ws(e,"color_by",Sl),Ys(e,"headline"),Ys(e,"show_percent")},editor:function(e){const t=Ta("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:wa(e.hass,"waffles",Al)},{name:"color_by",selector:wa(e.hass,"color_by",Sl)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return ja(e,"chart",t,Ll)},render(e){const t={waffles:(n=e.raw).waffles??"one",colorBy:n.color_by??"series",headline:!1!==n.headline,percent:!1!==n.show_percent};var n;return"each"===t.waffles?function(e,t){const{show:n}=e.config;return R`
- ${e.items.map((o,a)=>{const r=Ps(o,a,e.items.length,t.colorBy),[i]=El([o]),s=Rs(i,e.hass),l=n.value&&t.percent&&o.available&&o.display!==s;return R`
Z` + ${Array.from({length:25},(e,t)=>{const o=t%5,a=4-Math.floor(t/5);return Z``})}`,validate(e){wd(e,"waffles",au),wd(e,"color_by",nu),kd(e,"headline"),kd(e,"show_percent")},editor:function(e){const t=Fi("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:_i(e.hass,"waffles",au)},{name:"color_by",selector:_i(e.hass,"color_by",nu)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Pi(e,"chart",t,lu)},render(e){const t={waffles:(o=e.raw).waffles??"one",colorBy:o.color_by??"series",headline:!1!==o.headline,percent:!1!==o.show_percent};var o;return"each"===t.waffles?function(e,t){const{show:o}=e.config;return R`
+ ${e.items.map((a,n)=>{const r=md(a,n,e.items.length,t.colorBy),[i]=ru([a]),s=bd(i,e.hass),l=o.value&&t.percent&&a.available&&a.display!==s;return R`
- ${zl([i],[r],[o],o.label)} - ${n.name?R`${o.name}`:W} - ${o.available&&t.percent?s:o.display} - ${l?R`${o.display}`:W} + ${su([i],[r],[a],a.label)} + ${o.name?R`${a.name}`:W} + ${a.available&&t.percent?s:a.display} + ${l?R`${a.display}`:W}
`})} -
`}(e,t):function(e,t){const n=e.items,o=n.map((e,o)=>Ps(e,o,n.length,t.colorBy)),a=El(n),r=e=>a[e]>0||1===n.length&&n[e].available,i=n.map((i,s)=>({index:s,entity:i,color:o[s],share:!t.percent||!r(s)||t.headline&&1===n.length?void 0:Rs(a[s],e.hass)})),s=n[0],l=t.headline&&s&&r(0)?R`
- ${Rs(a[0],e.hass)} +
`}(e,t):function(e,t){const o=e.items,a=o.map((e,a)=>md(e,a,o.length,t.colorBy)),n=ru(o),r=e=>n[e]>0||1===o.length&&o[e].available,i=o.map((i,s)=>({index:s,entity:i,color:a[s],share:!t.percent||!r(s)||t.headline&&1===o.length?void 0:bd(n[s],e.hass)})),s=o[0],l=t.headline&&s&&r(0)?R`
+ ${bd(n[0],e.hass)} ${s.name}
`:W;return R`
-
${zl(a,o,n,Vs(e,"editor_style_waffle"))}
-
${l}${Is(i,e)}
-
`}(e,t)}},jl=["bars","gauges","history","totals","compare","distribution"];function Ol(e){return"chart"===e.layout}const Nl={bar:tr,thin:Ml,segmented:wl,tank:kl,ring:pl,needle:js,arc:fl,line:Ei,column:ki,area:Va,combined:Fi,pie:sl,rings:bl,pictograph:el,waffle:Tl,histogram:vs,scatter:xl,bubble:ti,heatmap:rs,box:Cr},Ul="important",Fl=" !"+Ul,Pl=pe(class extends fe{constructor(e){if(super(e),e.type!==de||"style"!==e.name||e.strings?.length>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(e){return Object.keys(e).reduce((t,n)=>{const o=e[n];return null==o?t:t+`${n=n.includes("-")?n:n.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${o};`},"")}update(e,[t]){const{style:n}=e.element;if(void 0===this.ft)return this.ft=new Set(Object.keys(t)),this.render(t);for(const o of this.ft)null==t[o]&&(this.ft.delete(o),o.includes("-")?n.removeProperty(o):n[o]=null);for(const o in t){const e=t[o];if(null!=e){this.ft.add(o);const t="string"==typeof e&&e.endsWith(Fl);o.includes("-")||t?n.setProperty(o,t?e.slice(0,-11):e,t?Ul:""):n[o]=e}}return q}}),Dl=Object.keys(Ne),Hl=Object.keys(Ce),Bl="custom",Il={text:{}},Rl={entity:{domain:["sensor","input_number","number"]}}; +
${su(n,a,o,_d(e,"editor_style_waffle"))}
+
${l}${yd(i,e)}
+
`}(e,t)}};function du(e,t){return Ce(un[e%un.length])}function uu(e,t){const o=Math.round(Math.min(100,Math.max(0,t)));return o>=100?e:`color-mix(in srgb, ${e} ${o}%, var(--card-background-color, #fff))`}const hu=["funnel","pyramid"],pu=["first","previous","none"],mu=["series","bands"];function fu(e,t){const o=e=>Math.round(100*e)/100,a=o((100-e)/2),n=o((100-t)/2);return`${a},0 ${o(100-a)},0 ${o(100-n)},10 ${n},10`}yt("en",{editor_style_funnel:"Funnel",editor_funnel_shape:"Shape",editor_funnel_shape_funnel:"Funnel (first stage at the top)",editor_funnel_shape_pyramid:"Pyramid (first stage at the bottom)",editor_funnel_percent_of:"Percentages",editor_funnel_percent_of_first:"Share of the first stage",editor_funnel_percent_of_previous:"Share of the stage before",editor_funnel_percent_of_none:"Don't show",editor_funnel_color_by:"Colour",editor_funnel_color_by_series:"One colour per entity",editor_funnel_color_by_bands:"By colour band",funnel_aria:"{style} of {names}"});const gu=[{name:"funnel_shape",path:["shape"],default:"funnel"},{name:"funnel_percent_of",path:["percent_of"],default:"first"},{name:"funnel_color_by",path:["color_by"],default:"series"}];function vu(e,t,o){const a=e[t];if(void 0!==a&&!o.includes(a))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)}const yu={layout:"chart",group:"totals",height:240,styles:r` + .style-funnel .funnel { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; + } + .style-funnel .stage { + display: grid; + grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); + align-items: center; + gap: 12px; + } + .style-funnel .funnel.no-labels .stage { + grid-template-columns: minmax(0, 1fr); + } + .style-funnel .shape { + position: relative; + height: 44px; + } + .style-funnel .shape svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + transform: scaleX(0); + transform-origin: 50% 50%; + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .style-funnel .shape svg { + transform: scaleX(1); + } + .style-funnel polygon.empty { + fill: none; + stroke: var(--secondary-text-color); + stroke-width: 1; + stroke-dasharray: 4 3; + vector-effect: non-scaling-stroke; + } + .style-funnel .percent { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); + pointer-events: none; + } + .style-funnel .percent.dark { + color: #1d1f24; + text-shadow: none; + } + .style-funnel .percent.theme { + color: var(--primary-text-color); + text-shadow: none; + } + .style-funnel .label { + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + } + .style-funnel .name { + color: var(--secondary-text-color); + font-size: 0.875em; + overflow: hidden; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + .style-funnel .value { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .style-funnel .unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + } + `,thumbnail:()=>Z` + + + `,validate(e){vu(e,"shape",hu),vu(e,"percent_of",pu),vu(e,"color_by",mu)},editor:function(e){const t=Fi("funnel",e.hass,()=>[{name:"funnel_shape",selector:_i(e.hass,"funnel_shape",hu)},{type:"grid",name:"",schema:[{name:"funnel_percent_of",selector:_i(e.hass,"funnel_percent_of",pu)},{name:"funnel_color_by",selector:_i(e.hass,"funnel_color_by",mu)}]}]);return Pi(e,"chart",t,gu)},render(e){const{items:t,raw:o,hass:a,config:n}=e,r=o.shape??"funnel",i="bands"===o.color_by,s=t.map(e=>e.available?e.value:null),l=function(e){const t=Math.max(0,...e.map(e=>null!==e&&e>0?e:0));return e.map(e=>t>0&&null!==e&&e>0?e/t*100:0)}(s),c=function(e,t){return e.map((o,a)=>{if(o<=0)return{top:0,bottom:0};const n=e[a+1],r=a===e.length-1;return"pyramid"===t?{top:r?0:n>0?n:.8*o,bottom:o}:{top:o,bottom:!r&&n>0?n:.8*o}})}(l,r),d=function(e,t){return"none"===t?e.map(()=>null):e.map((o,a)=>{if(null===o)return null;if("previous"===t&&0===a)return 100;const n="first"===t?e[0]:e[a-1];return null!=n&&n>0?o/n*100:null})}(s,o.percent_of??"first"),{show:u}=n,h=u.name||u.value,p=t.map((e,t)=>{const o=function(e,t,o){if(o)return e.band.color;const a=e.config.color;return"string"==typeof a&&a?a:un[t%un.length]}(e,t,i),n=d[t],r=null===n?"":`${kn(n,0,a)}%`,s=l[t]<22?"theme":Le(o),p=Ae(e.config),{top:m,bottom:f}=c[t],g=l[t]>0;return R``});"pyramid"===r&&p.reverse();const m=_t(a,"funnel_aria",{style:_t(a,`editor_funnel_shape_${r}`).replace(/ \(.*\)$/,""),names:t.map(e=>e.name).join(", ")});return R`
${p}
`}},bu=["floor","area","device","label"];function _u(e){if(null==e||""===e)return[];const t=Array.isArray(e)?e:[e];if(t.some(e=>!bu.includes(e)))throw new Error(`"group_by" must be one or more of: ${bu.join(", ")}`);return bu.filter(e=>t.includes(e))}function xu(e){_u(e.group_by),(e.entities??[]).forEach((e,t)=>{const o="object"==typeof e&&e?e.group:void 0;if(null!=o&&"string"!=typeof o)throw new Error(`Entity ${t+1}: "group" must be text, such as "Kitchen" or "Ground floor / Kitchen"`)})}function wu(e){return"string"!=typeof e?[]:e.split("/").map(e=>e.trim()).filter(Boolean)}function $u(e){const t=e.replace(/_/g," ").trim();return t.charAt(0).toUpperCase()+t.slice(1)}function ku(e,t,o,a){const n=wu(e.config.group);return n.length?n:t.map(t=>function(e,t,o){const a=o,n=a.entities?.[e],r=n?.device_id?a.devices?.[n.device_id]:void 0,i=n?.area_id??r?.area_id??void 0;switch(t){case"area":return i?a.areas?.[i]?.name??$u(i):void 0;case"floor":{const e=i?a.areas?.[i]?.floor_id:void 0;return e?a.floors?.[e]?.name??$u(e):void 0}case"device":return r?r.name_by_user??r.name??void 0:void 0;case"label":{const e=[...n?.labels??[],...r?.labels??[]][0];return e?a.labels?.[e]?.name??$u(e):void 0}}}(e.entityId,t,o)??a)}function Mu(e,t,o){const a={name:o,path:[],value:0,depth:0,children:[]};e.forEach((e,o)=>{let n=a;for(const a of t[o]??[]){let e=n.children.find(e=>void 0===e.entity&&e.name===a);e||(e={name:a,path:[...n.path,a],value:0,depth:n.depth+1,children:[]},n.children.push(e)),n=e}n.children.push({name:e.name,path:n.path,value:0,depth:n.depth+1,children:[],entity:e,index:o})});const n=e=>{e.value=e.entity?function(e){return e.available&&null!==e.value&&e.value>0?e.value:0}(e.entity):e.children.reduce((e,t)=>e+n(t),0);const t=new Map(e.children.map((e,t)=>[e,t]));return e.children.sort((e,o)=>o.value-e.value||t.get(e)-t.get(o)),e.value};return n(a),a}function zu(e,t){let o=e;for(const a of t)if(o=o.children.find(e=>void 0===e.entity&&e.name===a),!o)return;return o}function Au(e){return e.children.length?1+Math.max(...e.children.map(Au)):0}function Su(e){const t=[],o=(e,a,n,r)=>{if(t.push({node:e,level:a,x0:n,x1:r}),e.value<=0)return;let i=n;for(const t of e.children){if(t.value<=0)continue;const s=(r-n)*t.value/e.value;o(t,a+1,i,i+s),i+=s}};return o(e,0,0,1),t}const Eu=2*Math.PI;function Cu(e,t){return[ju(e*Math.sin(t)),ju(-e*Math.cos(t))]}function ju(e){return Math.round(100*e)/100||0}function Nu(e,t,o,a){if(t<=0||a<=o)return"";const n=Math.max(0,e);if(a-o>=Eu-1e-6){const e=(e,t)=>{const[o,a]=Cu(e,0),[n,r]=Cu(e,Math.PI);return`M${o} ${a}A${e} ${e} 0 1 ${t} ${n} ${r}A${e} ${e} 0 1 ${t} ${o} ${a}Z`};return n>0?`${e(t,1)}${e(n,0)}`:e(t,1)}const r=a-o>Math.PI?1:0,[i,s]=Cu(t,o),[l,c]=Cu(t,a),d=`M${i} ${s}A${t} ${t} 0 ${r} 1 ${l} ${c}`;if(0===n)return`${d}L0 0Z`;const[u,h]=Cu(n,a),[p,m]=Cu(n,o);return`${d}L${u} ${h}A${n} ${n} 0 ${r} 0 ${p} ${m}Z`}function Lu(e,t){return t>0?e*Eu/t:0}function Tu(e){return e.map(([e,t])=>`${e},${t}`).join(" ")}function Ou(e){const t=Math.sin(e);return Math.abs(t)<.2?"middle":t>0?"start":"end"}function Fu(e,t,o,a,n=void 0){const r=e.config.entities??[],i=r.length,s=Fi(`${t}-${i}`,e.hass,()=>r.map((e,t)=>({name:`e${t}`,selector:a}))),l=e=>{const t=r[e];return"object"==typeof t?t[o]:void 0},c={};for(let d=0;d +
${_t(e.hass,`editor_${t}`)}
+ ${0===i?R`
+ ${_t(e.hass,"editor_entity_options_none")} +
`:R`{const o=Mt(r[Number(t.name.slice(1))]),a=e.hass?.states[o]?.attributes?.friendly_name;return"string"==typeof a&&a?a:o}} + @value-changed=${t=>{t.stopPropagation();const a=t.detail?.value??{};let r=e.config;for(let e=0;e`} +
`}yt("en",{editor_entity_options_none:"Add an entity above first."}),yt("en",{editor_hierarchy_group_by:"Group by",editor_group_by_floor:"Floor",editor_group_by_area:"Area",editor_group_by_device:"Device",editor_group_by_label:"Label",editor_entity_groups:'Own group (wins over "Group by"; "Ground floor / Kitchen" for two levels)'});const Pu=[{name:"group_by",path:["group_by"],default:[]}];function Du(e){return{name:"group_by",selector:{select:{multiple:!0,mode:"list",options:bu.map(t=>({value:t,label:_t(e,`editor_group_by_${t}`)}))}}}}function Uu(e){const t=e.config.group_by;if(void 0===t||Array.isArray(t))return e;let o;try{o=_u(t)}catch{return e}return{...e,config:{...e.config,group_by:o}}}function Hu(e){return Fu(e,"entity_groups","group",{text:{}})}const Iu={group_by:"editor_hierarchy_group_by"};yt("en",{editor_style_radar:"Radar",editor_radar_scale:"Scale",editor_radar_scale_limits:"Each axis from its entity's minimum to maximum",editor_radar_scale_shared:"One scale for all, from 0 to the largest value",editor_radar_fill:"Fill the shapes",editor_radar_axis:"Axis name (entities with the same axis name line up across groups)",radar_aria:"Radar chart of {names}",radar_other:"Other"});const Bu=["limits","shared"],Ru={aqi:"AQI",carbon_dioxide:"CO₂",carbon_monoxide:"CO",pm1:"PM1",pm10:"PM10",pm25:"PM2.5",volatile_organic_compounds:"VOC",volatile_organic_compounds_parts:"VOC"};function Zu(e,t,o){return e.available&&null!==e.value?"limits"===t?e.position:o>0?Math.min(1,Math.max(0,e.value/o)):0:null}function qu(e,t,o){const a=_u(t.group_by),n=a.length>0||e.some(e=>wu(e.config.group).length),r=t.scale??"limits",i=e.filter(e=>e.available&&null!==e.value).map(e=>e.value),s=i.length?Math.max(...i):0,l=e=>{const t=e.config.axis;return"string"==typeof t&&t.trim()?t.trim():void 0};if(!n)return{axes:e.map(e=>({name:l(e)??e.name,entity:e})),series:[{name:"",color:du(0),points:e.map((e,t)=>({index:t,axis:t,r:Zu(e,r,s)}))}]};const c=_t(o,"radar_other"),d=[],u=[];return e.forEach((e,t)=>{const n=e.stateObj?.attributes?.device_class,i=l(e)??("string"==typeof n?function(e){if(Ru[e])return Ru[e];const t=e.replace(/_/g," ");return t.charAt(0).toUpperCase()+t.slice(1)}(n):e.name);let h=d.findIndex(e=>e.name===i);-1===h&&(h=d.push({name:i})-1);const p=ku(e,a,o,c).join(" / ")||c;let m=u.find(e=>e.name===p);m||(m={name:p,color:du(u.length),points:[]},u.push(m)),m.points.push({index:t,axis:h,r:Zu(e,r,s)})}),{axes:d,series:u}}const Wu=[{name:"radar_scale",path:["scale"],default:"limits"},{name:"radar_fill",path:["fill"],default:!0},...Pu];const Vu={layout:"chart",group:"compare",height:280,styles:r` + .radar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .radar-plot .grid { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .radar-plot .axis-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .radar-plot .axis-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .radar-plot .axis-value.muted { + fill: var(--secondary-text-color); + font-weight: 400; + } + .radar-plot .area { + stroke-width: 2; + stroke-linejoin: round; + fill-opacity: 0; + } + .radar-plot .area.filled { + fill-opacity: 0.2; + } + .radar-plot .dot { + fill: var(--card-background-color, #fff); + stroke-width: 2; + } + .radar-plot .hit { + fill: transparent; + } + .radar-plot .marker { + cursor: pointer; + outline: none; + } + .radar-plot .marker:focus-visible .dot { + stroke-width: 4; + } + .radar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .radar-grow { + transform: scale(1); + } + :host([static]) .radar-grow { + transition: none; + } + .radar-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 4px 16px; + padding-top: 4px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .radar-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + .radar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `,thumbnail:()=>Z` + + `,validate(e){if(xu(e),void 0!==e.scale&&!Bu.includes(e.scale))throw new Error(`"scale" must be one of: ${Bu.join(", ")}`);if(void 0!==e.fill&&"boolean"!=typeof e.fill)throw new Error('"fill" must be true or false');(e.entities??[]).forEach((e,t)=>{const o="object"==typeof e&&e?e.axis:void 0;if(null!=o&&"string"!=typeof o)throw new Error(`Entity ${t+1}: "axis" must be text`)})},editor:function(e){const t=Fi("radar",e.hass,()=>[{name:"radar_scale",selector:_i(e.hass,"radar_scale",Bu)},{name:"radar_fill",selector:{boolean:{}}},Du(e.hass)]);return R`${Pi(Uu(e),"chart",t,Wu,Iu)} ${Hu(e)} + ${Fu(e,"radar_axis","axis",{text:{}})}`},render(e){const t=qu(e.items,e.raw,e.hass),o=t.axes.length,a=1===t.series.length&&""===t.series[0].name,n=!1!==e.raw.fill,r=_t(e.hass,"radar_aria",{names:(a?e.items.map(e=>e.name):t.series.map(e=>e.name)).join(", ")});return R` + ${function(e){const t=Array.from({length:4},(e,t)=>84*(t+1)/4);return Z`${t.map(t=>e>=3?Z`Cu(t,Lu(a,e))))}>`:Z``)} + ${Array.from({length:e},(t,o)=>{const[a,n]=Cu(84,Lu(o,e));return Z``})}`}(o)} ${function(e,t,o){const a=e.axes.length,n=o&&t.config.show.value;return Z`${e.axes.map((e,o)=>{const r=Lu(o,a),[i,s]=Cu(96,r),l=Ou(r),c=Math.cos(r)<-.2,d=n&&e.entity?2:1,u=c?10:Math.abs(Math.cos(r))<=.2?4-6*(d-1):12*-(d-1);return Z` + ${t.config.show.name||!n?Z`${e.name}`:W} + ${n&&e.entity?Z`${e.entity.display}`:W} + `})}`}(t,e,a)} + + + ${t.series.map(t=>function(e,t,o,a){const n=e.points.filter(e=>null!==e.r).sort((e,t)=>e.axis-t.axis).map(e=>({p:e,xy:Cu(84*e.r,Lu(e.axis,t))}));return Z` + ${n.length>1?Z`e.xy))} + stroke=${e.color} fill=${a?e.color:"none"}>`:W} + ${n.map(({p:t,xy:a})=>Z` + + + `)} + `}(t,o,e.items,n))} + + + ${a?W:R`
+ ${t.series.map(e=>R`${e.name}`)} +
`}`}};yt("en",{editor_style_polar_area:"Polar area",editor_polar_scale:"Scale",editor_polar_scale_shared:"One scale for all, from 0 to the largest value",editor_polar_scale_limits:"Each slice from its entity's minimum to maximum",editor_polar_color_by:"Colour",editor_polar_color_by_series:"One colour per entity",editor_polar_color_by_shades:"Shades of one colour, darker for more",editor_polar_color_by_bands:"By colour band",editor_polar_color:"Colour for shades",polar_area_aria:"Polar area chart of {names}"});const Yu=["shared","limits"],Gu=["series","shades","bands"];const Xu=[{name:"polar_scale",path:["scale"],default:"shared"},{name:"polar_color_by",path:["color_by"],default:"series"},{name:"polar_color",path:["color"],default:"primary"}],Ju=Object.keys(Ee);const Ku={layout:"chart",group:"compare",height:260,styles:r` + .polar-plot { + display: block; + width: 100%; + height: 240px; + overflow: visible; + } + .polar-plot .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-dasharray: 3 3; + } + .polar-plot .ring:last-of-type { + stroke-dasharray: none; + } + .polar-plot .ring-label { + fill: var(--secondary-text-color); + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .polar-plot .slice-label { + fill: var(--secondary-text-color); + font-size: 12px; + } + .polar-plot .slice-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .polar-plot .slice-label.muted .slice-value { + fill: var(--secondary-text-color); + font-weight: 400; + } + .polar-plot .slice { + cursor: pointer; + outline: none; + stroke: var(--card-background-color, #fff); + stroke-width: 0.5; + } + .polar-plot .slice:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2; + } + .polar-grow { + transform-box: fill-box; + transform-origin: center; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .polar-grow { + transform: scale(1); + } + :host([static]) .polar-grow { + transition: none; + } + `,thumbnail:()=>Z` + + + + + + + `,validate(e){if(void 0!==e.scale&&!Yu.includes(e.scale))throw new Error(`"scale" must be one of: ${Yu.join(", ")}`);if(void 0!==e.color_by&&!Gu.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Gu.join(", ")}`);if(void 0!==e.color&&("string"!=typeof e.color||!e.color))throw new Error('"color" must be a colour name, such as "primary", or any CSS colour')},editor:function(e){const t=Fi("polar_area",e.hass,()=>[{name:"polar_scale",selector:_i(e.hass,"polar_scale",Yu)},{type:"grid",name:"",schema:[{name:"polar_color_by",selector:_i(e.hass,"polar_color_by",Gu)},{name:"polar_color",selector:{select:{mode:"dropdown",custom_value:!0,options:Ju.map(t=>({value:t,label:_t(e.hass,`editor_color_${t}`)}))}}}]}]);return Pi(e,"chart",t,Xu)},render(e){const t=e.raw.scale??"shared",o=e.raw.color_by??"series",a=e.raw.color??"primary",n=e.items.length,r=e.items.filter(e=>e.available&&null!==e.value).map(e=>e.value),i=r.length?Math.max(0,...r):0,s=e.items.map((e,r)=>{const s=function(e,t,o){return e.available&&null!==e.value?"limits"===t?e.position:o>0?Math.min(1,Math.max(0,e.value/o)):0:null}(e,t,i);if(null===s||s<=0)return W;const[l,c]=function(e,t){if(t<=1)return[0,2*Math.PI];const o=Math.PI/t,a=Lu(e,t);return[a-o+.02,a+o-.02]}(r,n),d=6+86*s;return Z``}),l=_t(e.hass,"polar_area_aria",{names:e.items.map(e=>e.name).join(", ")});return R` + ${Z`${Array.from({length:3},(e,t)=>Z``)}`} + + + ${s} + + ${function(e,t,o){const a=e.items.find(e=>e.available);if("shared"!==t||!a||o<=0)return Z``;const n=e.items.length>1?Math.PI/e.items.length:0;return Z`${Array.from({length:3},(t,r)=>{const i=92*(r+1)/3,[s,l]=Cu(i,n),c=Mn(kn(o*(r+1)/3,a.decimals,e.hass),a.unit);return Z`${c}`})}`}(e,t,i)} ${function(e){const t=e.items.length,{show:o}=e.config;return o.name||o.value?Z`${e.items.map((e,a)=>{const n=Lu(a,t),[r,i]=Cu(104,n),s=Math.cos(n)<-.2,l=o.name&&o.value?2:1,c=s?10:Math.abs(Math.cos(n))<=.2?4-6*(l-1):13*-(l-1);return Z` + ${o.name?Z`${e.name}`:W} + ${o.value?Z`${e.display}`:W} + `})}`:Z``}(e)} + `}};yt("en",{editor_drill_down:"Tap a group to show what makes it up",drill_into:"{label}. Show what makes it up",drill_back:"Back to {name}",drill_top:"All"});const Qu=new WeakMap;function eh(e){return!1!==e.drill_down}function th(e){if(void 0!==e.drill_down&&"boolean"!=typeof e.drill_down)throw new Error('"drill_down" must be true or false')}function oh(e,t){Qu.set(e.host,t),e.requestUpdate()}function ah(e,t){return eh(e.raw)?function(e,t){let o=e;const a=[];for(const n of t){const e=zu(o,[n]);if(!e||e.value<=0)break;o=e,a.push(n)}return{node:o,path:a}}(t,(o=e.host,Qu.get(o)??[])):{node:t,path:[]};var o}function nh(e,t,o){if(!eh(e.raw)||t.entity||t.value<=0)return;const a=()=>oh(e,t.path);return{label:_t(e.hass,"drill_into",{label:o}),click:e=>{e.stopPropagation(),a()},keydown:e=>{"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),a())}}}function rh(e,t,o,a){if(!o.path.length)return W;const n=o.path.slice(0,-1),r=n.length&&zu(t,n)?.name||_t(e.hass,"drill_top");return R`
+ +
+ ${o.node.name} + ${a?R`${a}`:W} +
+
`}const ih=r` + .drill-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-bottom: 8px; + min-width: 0; + } + .drill-back { + display: inline-flex; + align-items: center; + gap: 2px; + min-width: 0; + max-width: 55%; + padding: 4px 12px 4px 6px; + border: none; + border-radius: 16px; + background: color-mix(in srgb, var(--primary-color, #03a9f4) 12%, transparent); + color: var(--primary-color, #03a9f4); + font: inherit; + font-size: 0.875em; + font-weight: 500; + cursor: pointer; + } + .drill-back span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .drill-back:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + .drill-current { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + line-height: 1.2; + } + .drill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .drill-total { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .drillable { + cursor: pointer; + outline: none; + } +`;function sh(e,t){const o=e.reduce((e,t)=>e+t,0);if(o<=0||t<=0)return 1/0;const a=Math.max(...e),n=Math.min(...e),r=t*t;return Math.max(r*a/(o*o),o*o/(r*n))}function lh(e,t){const o=Math.min(t,e.w/2,e.h/2);return{x:e.x+o,y:e.y+o,w:e.w-2*o,h:e.h-2*o}}yt("en",{editor_style_treemap:"Treemap",editor_treemap_color_by:"Colour",editor_treemap_color_by_group:"One colour per group",editor_treemap_color_by_bands:"By colour band",treemap_aria:"Treemap of {names}",treemap_total:"{value} in all",treemap_empty:"Nothing to show: no entity has a value above zero.",treemap_no_reading:"No reading:",treemap_other:"Other"});const ch=["group","bands"],dh=240;function uh(e,t,o,a){const n=[],r=[],i=(e,t,o,s)=>{const l=e.children.filter(e=>e.value>0),c=function(e,t){const o=e.map(()=>({x:t.x,y:t.y,w:0,h:0})),a=e.map((e,t)=>({v:Number.isFinite(e)&&e>0?e:0,i:t})).filter(e=>e.v>0).sort((e,t)=>t.v-e.v||e.i-t.i),n=a.reduce((e,t)=>e+t.v,0);if(n<=0||t.w<=0||t.h<=0)return o;const r=t.w*t.h/n;let{x:i,y:s,w:l,h:c}=t,d=[];const u=()=>{const e=d.reduce((e,t)=>e+t.area,0);if(l>=c){const t=e/c;let a=s;for(const e of d){const n=e.area/t;o[e.i]={x:i,y:a,w:t,h:n},a+=n}i+=t,l-=t}else{const t=e/l;let a=i;for(const e of d){const n=e.area/t;o[e.i]={x:a,y:s,w:n,h:t},a+=n}s+=t,c-=t}d=[]};for(const h of a){const e=h.v*r,t=Math.min(l,c),o=d.map(e=>e.area);0===d.length||sh([...o,e],t)<=sh(o,t)||u(),d.push({area:e,i:h.i})}return d.length&&u(),o}(l.map(e=>e.value),t);l.forEach((e,t)=>{const l=c[t],d=s&&void 0===a?t:o;if(e.entity)return void r.push({node:e,rect:lh(l,1.5),series:d,rank:t});const u=lh(l,1.5),h=u.h>=44&&u.w>=56;n.push({node:e,rect:u,series:d,head:h});const p=lh(h?{...u,y:u.y+22-3,h:u.h-22+3}:u,3);i(e,p,d,!1)})};return i(e,{x:0,y:0,w:t,h:o},a??0,!0),{groups:n,leaves:r}}function hh(e,t,o){const a=t.items.find(e=>e.available)?.decimals??1;return Mn(kn(e,a,t.hass),o)}const ph=new WeakMap,mh=new WeakMap;const fh=(e,t)=>e/t*100+"%";function gh(e,t){return`left:${fh(e.x,t)};top:${fh(e.y,dh)};width:${fh(e.w,t)};height:${fh(e.h,dh)}`}function vh(e,t,o,a,n){const r=e.node.entity,i=function(e,t){const o=e.node.entity;if("bands"===t)return o.band.color;const a=o.config.color;return"string"==typeof a&&a?a:un[e.series%un.length]}(e,a),s="group"===a&&e.node.path.length?uu(Ce(i),Math.max(70,100-12*(e.rank??0))):Ce(i),l=Le(i),{w:c,h:d}=e.rect,{show:u}=t.config,h=c>=120&&d>=70&&null!==r.value&&r.value>=.25*n,p=d>=20&&c>=44,m=u.value&&d>=36;return R``}const yh=[{name:"treemap_color_by",path:["color_by"],default:"group"},...Pu,{name:"drill_down",path:["drill_down"],default:!0}];const bh={layout:"chart",group:"totals",height:280,styles:r` + ${ih} + .treemap-head { + display: flex; + justify-content: flex-end; + align-items: baseline; + gap: 8px; + min-height: 20px; + padding-bottom: 6px; + color: var(--secondary-text-color); + font-size: 0.875em; + } + .treemap { + position: relative; + height: ${dh}px; + } + .treemap .group, + .treemap .tile { + position: absolute; + box-sizing: border-box; + border-radius: 6px; + overflow: hidden; + } + .treemap .group-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 6px; + height: ${22}px; + padding: 3px 8px 0; + box-sizing: border-box; + font-size: 12px; + white-space: nowrap; + } + .treemap .group-name { + font-weight: 500; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + } + .treemap .group-total { + color: var(--secondary-text-color); + } + .treemap .tile { + display: flex; + flex-direction: column; + padding: 5px 8px; + gap: 1px; + cursor: pointer; + outline: none; + opacity: 0; + transform: scale(0.96); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .treemap .tile { + opacity: 1; + transform: none; + } + :host([static]) .treemap .tile { + transition: none; + } + .treemap .tile:focus-visible { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + .treemap .tile.text-light { + color: #fff; + } + .treemap .tile.text-dark { + color: rgba(0, 0, 0, 0.87); + } + .treemap .tile.text-theme { + color: var(--primary-text-color); + } + .treemap .tile-name, + .treemap .tile-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.25; + } + .treemap .tile-name { + font-size: 13px; + font-weight: 500; + } + .treemap .tile-value { + font-size: 12px; + opacity: 0.9; + } + .treemap .tile.big .tile-value { + font-size: 22px; + font-weight: 500; + opacity: 1; + } + .treemap-message { + display: flex; + align-items: center; + justify-content: center; + height: ${dh}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .treemap-missing { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .treemap-missing .entity { + cursor: pointer; + border-radius: 4px; + } + .treemap-missing .entity:focus-visible { + outline: 2px solid var(--primary-color); + } + `,thumbnail:()=>Z` + + + + `,validate(e){if(xu(e),th(e),void 0!==e.color_by&&!ch.includes(e.color_by))throw new Error(`"color_by" must be one of: ${ch.join(", ")}`)},editor:function(e){const t=Fi("treemap",e.hass,()=>[{name:"treemap_color_by",selector:_i(e.hass,"treemap_color_by",ch)},Du(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Pi(Uu(e),"chart",t,yh,Iu)} ${Hu(e)}`},disconnect:function(e){mh.get(e)?.disconnect(),mh.delete(e)},render(e){const t=function(e){const t=e.host;if(!mh.has(t)&&"function"==typeof ResizeObserver){const o=new ResizeObserver(()=>{const o=t.shadowRoot?.querySelector(".treemap"),a=o?.clientWidth??0;a>0&&Math.abs(a-(ph.get(t)??0))>=1&&(ph.set(t,a),e.requestUpdate())});o.observe(t),mh.set(t,o)}return ph.get(t)??400}(e),o=function(e){const t=_u(e.raw.group_by),o=_t(e.hass,"treemap_other"),a=e.items.map(a=>ku(a,t,e.hass,o));return Mu(e.items,a,"")}(e),a=ah(e,o),n=a.node,r=e.raw.color_by??"group",i=function(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:void 0}(e.items),s=a.path.length?o.children.filter(e=>e.value>0).findIndex(e=>e.name===a.path[0]):void 0,{groups:l,leaves:c}=uh(n,t,dh,s),d=e.items.map((e,t)=>({e:e,index:t})).filter(({e:e})=>!e.available),u=void 0!==i&&e.config.show.value&&n.value>0?hh(n.value,e,i):"",h=u&&!a.path.length?_t(e.hass,"treemap_total",{value:u}):"",p=_t(e.hass,"treemap_aria",{names:(n.children.length?n.children:[]).map(e=>e.name).join(", ")}),m=c.length?R``:R`
${_t(e.hass,"treemap_empty")}
`;return R`${rh(e,o,a,u)} + ${h?R`
${h}
`:W} ${m} + ${d.length?R`
+ ${_t(e.hass,"treemap_no_reading")} + ${d.map(({e:e,index:t})=>R`${e.name} (${e.display})`)} +
`:W}`}};yt("en",{editor_style_sunburst:"Sunburst",editor_style_icicle:"Icicle",editor_sunburst_shape:"Shape",editor_sunburst_shape_full:"Full circle",editor_sunburst_shape_half:"Half circle",editor_hierarchy_color_by:"Colour",editor_hierarchy_color_by_group:"One colour per group",editor_hierarchy_color_by_bands:"By colour band",hierarchy_total:"Total",hierarchy_other:"Other",hierarchy_empty:"Nothing to show: no entity has a value above zero.",hierarchy_no_reading:"No reading:",sunburst_aria:"Sunburst of {names}",icicle_aria:"Icicle chart of {names}"});const _h=["full","half"],xh=["group","bands"],wh=240;function $h(e){return Math.max(45,100-20*(e-1))}function kh(e){const t=_u(e.raw.group_by),o=_t(e.hass,"hierarchy_other"),a=e.items.map(a=>ku(a,t,e.hass,o));return Mu(e.items,a,_t(e.hass,"hierarchy_total"))}function Mh(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:void 0}function zh(e,t){const o=new Map,a=new Map;let n=0;for(const{node:r,level:i}of e){if(0===i)continue;const e=r.entity?r.entity.config.color:void 0;let s;s=1===i?"string"==typeof e&&e?e:un[n++%un.length]:a.get(r)??"grey";for(const t of r.children)a.set(t,s);if("bands"===t){o.set(r,r.entity?{fill:Ce(r.entity.band.color),text:Le(r.entity.band.color)}:{fill:"var(--cd-track)",text:"theme"});continue}const l=$h(i);o.set(r,{fill:uu(Ce(s),l),text:l>=100?Le(s):"theme"})}return o}function Ah(e,t,o){const a=t.items.find(e=>e.available)?.decimals??1;return Mn(kn(e,a,t.hass),o)}function Sh(e,t,o){return e.entity?e.entity.label:void 0===o?e.name:`${e.name}, ${Ah(e.value,t,o)}`}function Eh(e){const t=e.items.map((e,t)=>({e:e,index:t})).filter(({e:e})=>!e.available);return t.length?R`
+ ${_t(e.hass,"hierarchy_no_reading")} + ${t.map(({e:e,index:t})=>R`${e.name} (${e.display})`)} +
`:W}const Ch=[{name:"hierarchy_color_by",path:["color_by"],default:"group"},...Pu,{name:"drill_down",path:["drill_down"],default:!0}],jh=[{name:"sunburst_shape",path:["shape"],default:"full"},...Ch];function Nh(e,t){if(xu(e),th(e),void 0!==e.color_by&&!xh.includes(e.color_by))throw new Error(`"color_by" must be one of: ${xh.join(", ")}`);if(t&&void 0!==e.shape&&!_h.includes(e.shape))throw new Error(`"shape" must be one of: ${_h.join(", ")}`)}function Lh(e,t,o=t){if(t.value<=0)return R`
${_t(e.hass,"hierarchy_empty")}
`;const a=e.raw.shape??"full",n=e.raw.color_by??"group",r=Mh(e.items),i=Su(t),s=zh(Su(o),n),l=function(e,t){const o=72/Math.max(1,t);return{start:"half"===e?-Math.PI/2:0,sweep:"half"===e?Math.PI:2*Math.PI,radii:e=>[40+(e-1)*o,40+e*o],viewBox:"half"===e?"-116 -116 232 124":"-116 -116 232 232"}}(a,Au(t)),c=i.filter(e=>e.level>0).map(t=>{const[o,a]=l.radii(t.level),n=Nu(o,a,l.start+t.x0*l.sweep,l.start+t.x1*l.sweep),i=Sh(t.node,e,r),c=s.get(t.node).fill;return t.node.entity?Z`${i}`:function(e,t,o,a,n){const r=nh(e,t,o);return Z`${o}`}(e,t.node,i,n,c)}),d=void 0!==r&&e.config.show.value?Ah(t.value,e,r):"",u=t.children.filter(e=>e.value>0).map(e=>e.name);return R`
+ + ${c} + +
${d?R`${d}`:W}
+
`}function Th(e,t,o=t){const a=e.raw.color_by??"group",n=Mh(e.items),r=zh(Su(o),a),i=t.children.filter(e=>e.value>0);return R`
+ ${i.map(t=>{const o=R``,a=e.config.show.value&&void 0!==n?R`${Ah(t.value,e,n)}`:W;return t.entity?R`${o}${t.name} ${a}`:function(e,t,o,a,n){const r=nh(e,t,Sh(t,e,n));return R`${o}${t.name} ${a}`}(e,t,o,a,n)})} +
`}function Oh(e,t,o=t){if(t.value<=0)return R`
${_t(e.hass,"hierarchy_empty")}
`;const a=e.raw.color_by??"group",n=Mh(e.items),r=Su(t),i=zh(Su(o),a),s=Au(t)+1,{show:l}=e.config,c=t.children.filter(e=>e.value>0).map(e=>e.name);return R``}const Fh=r` + ${ih} + .hierarchy-message { + display: flex; + align-items: center; + justify-content: center; + height: ${wh}px; + color: var(--secondary-text-color); + font-size: 0.875em; + text-align: center; + } + .hierarchy-missing, + .hierarchy-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .hierarchy-legend { + justify-content: center; + } + .hierarchy-legend .legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .hierarchy-legend .legend-value { + color: var(--primary-text-color); + font-weight: 500; + } + .hierarchy-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + .hierarchy-missing .entity, + .hierarchy-legend .entity { + cursor: pointer; + border-radius: 4px; + } + .hierarchy-missing .entity:focus-visible, + .hierarchy-legend .entity:focus-visible { + outline: 2px solid var(--primary-color); + } +`,Ph={layout:"chart",group:"totals",height:290,styles:r` + ${Fh} + .sunburst-wrap { + position: relative; + height: ${wh}px; + } + .sunburst-wrap.half { + height: ${144}px; + } + .sunburst { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .sunburst .arc { + stroke: var(--card-background-color, #fff); + stroke-width: 1.5; + stroke-linejoin: round; + } + .sunburst .arc.leaf { + cursor: pointer; + outline: none; + } + .sunburst .arc.leaf:focus-visible { + stroke: var(--primary-text-color); + stroke-width: 2.5; + } + .sunburst-grow { + transform: scale(0.6) rotate(-30deg); + opacity: 0; + transition: + transform var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .sunburst-grow { + transform: none; + opacity: 1; + } + :host([static]) .sunburst-grow { + transition: none; + } + .sunburst-centre { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + max-width: 72px; + text-align: center; + line-height: 1.2; + pointer-events: none; + } + .sunburst-wrap.half .sunburst-centre { + top: auto; + bottom: 4px; + transform: translateX(-50%); + } + .sunburst-centre > * { + pointer-events: auto; + } + .centre-value { + font-size: 1.15em; + font-weight: 500; + white-space: nowrap; + } + `,thumbnail:()=>Z` + + + + + + `,validate:e=>Nh(e,!0),editor:function(e){const t=Fi("sunburst",e.hass,()=>[{type:"grid",name:"",schema:[{name:"sunburst_shape",selector:_i(e.hass,"sunburst_shape",_h)},{name:"hierarchy_color_by",selector:_i(e.hass,"hierarchy_color_by",xh)}]},Du(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Pi(Uu(e),"chart",t,jh,Iu)} + ${Hu(e)}`},render(e){const t=kh(e),o=ah(e,t);return R`${rh(e,t,o,"")} ${Lh(e,o.node,t)} + ${o.node.value>0?Th(e,o.node,t):W} ${Eh(e)}`}},Dh={layout:"chart",group:"totals",height:280,styles:r` + ${Fh} + .icicle { + position: relative; + height: ${wh}px; + } + .icicle .cell { + position: absolute; + box-sizing: border-box; + padding: 1.5px; + } + .icicle .cell-fill { + box-sizing: border-box; + width: 100%; + height: 100%; + border-radius: 5px; + padding: 3px 8px; + overflow: hidden; + display: flex; + flex-direction: column; + line-height: 1.25; + opacity: 0; + transform: translateX(-8px); + transition: + opacity var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .icicle .cell-fill { + opacity: 1; + transform: none; + } + :host([static]) .icicle .cell-fill { + transition: none; + } + .icicle .text-root .cell-fill { + background: color-mix(in srgb, var(--primary-text-color, #212121) 78%, var(--card-background-color, #fff)); + color: var(--card-background-color, #fff); + } + .icicle .text-light { + color: #fff; + } + .icicle .text-dark { + color: rgba(0, 0, 0, 0.87); + } + .icicle .text-theme { + color: var(--primary-text-color); + } + .icicle .cell-name, + .icicle .cell-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .icicle .cell-name { + font-size: 13px; + font-weight: 500; + } + .icicle .cell-value { + font-size: 12px; + opacity: 0.9; + } + .icicle .leaf { + cursor: pointer; + outline: none; + } + .icicle .leaf:focus-visible .cell-fill { + box-shadow: inset 0 0 0 2px var(--primary-text-color); + } + `,thumbnail:()=>Z` + + + + + + `,validate:e=>Nh(e,!1),editor:function(e){const t=Fi("icicle",e.hass,()=>[{name:"hierarchy_color_by",selector:_i(e.hass,"hierarchy_color_by",xh)},Du(e.hass),{name:"drill_down",selector:{boolean:{}}}]);return R`${Pi(Uu(e),"chart",t,Ch,Iu)} ${Hu(e)}`},render(e){const t=kh(e),o=ah(e,t);return R`${rh(e,t,o,"")} ${Oh(e,o.node,t)} ${Eh(e)}`}};function Uh(e,t,o=5){if(!Number.isFinite(e)||!Number.isFinite(t))return[];e>t&&([e,t]=[t,e]),e===t&&(e-=.5,t+=.5);const a=function(e,t=5){if(!Number.isFinite(e)||e<=0||t<1)return 1;const o=e/t,a=10**Math.floor(Math.log10(o)),n=o/a;return(n<=1?1:n<=2?2:n<=2.5?2.5:n<=5?5:10)*a}(t-e,o),n=Math.floor(e/a+1e-9)*a,r=Math.ceil(t/a-1e-9)*a,i=[];for(let s=0,l=n;l<=r+a/2&&s<100;s++,l=n+s*a)i.push(Number(l.toPrecision(12)));return i}function Hh(e){let t=1/0,o=-1/0;for(const a of e)"number"==typeof a&&Number.isFinite(a)&&(ao&&(o=a));return t===1/0?void 0:[t,o]}function Ih(e,t){let o=null;for(const a of e){if(a.t>t)break;o=a.v}return o}const Bh=["grouped","stacked","percent"],Rh=["series","bands"],Zh=["primary","amber","green","purple","red","#14b8a6","#ec4899","grey"];function qh(e,t,o="none"){const a=new Map,n=[];for(const l of e){let e=a.get(l.group);e||(e={key:l.group,label:l.group,segments:[],total:null},a.set(l.group,e)),n.includes(l.series)||n.push(l.series);const t=e.segments.length;e.segments.push({index:l.index,series:l.series,value:l.value,from:0,to:0,slot:t}),null!==l.value&&(e.total=(e.total??0)+l.value)}const r=[...a.values()];for(const l of r){const e=l.segments.reduce((e,t)=>e+Math.abs(t.value??0),0);let o=0,a=0;for(const n of l.segments){if(null===n.value)continue;const r="percent"===t?0===e?0:n.value/e:n.value;"grouped"===t?n.to=r:r>=0?(n.from=o,n.to=o+=r):(n.from=a,n.to=a+=r)}}const i=r.flatMap(e=>e.segments.flatMap(e=>null===e.value?[]:[e.from,e.to])),s=function([e,t]){return[Math.min(0,e),Math.max(0,t)]}(Hh(i)??[0,1]);if(s[0]===s[1]&&(s[1]=1),"none"!==o){const e="ascending"===o?1:-1;r.sort((t,o)=>null===t.total!=(null===o.total)?null===t.total?1:-1:((t.total??0)-(o.total??0))*e)}return{rows:r,domain:s,series:n}}function Wh(e,t){if("grouped"!==t)return 36;const o=Math.max(1,e.segments.length);return 12*o+3*(o-1)+16}function Vh(e){return"string"==typeof e&&""!==e.trim()?e.trim():"number"==typeof e?String(e):void 0}function Yh(e,t){return`${kn(100*Math.abs(e),0,t)} %`}yt("en",{editor_style_grouped_bars:"Grouped bars",editor_mode:"Bars",editor_mode_grouped:"Side by side",editor_mode_stacked:"Stacked",editor_mode_percent:"Stacked to 100 %",editor_bars_color_by:"Colour",editor_bars_color_by_series:"One colour per series",editor_bars_color_by_bands:"By colour band",editor_bar_group:"Row",editor_bar_series:"Series",editor_bar_color:"Bar colour",grouped_bars_aria:"Bar chart of {names}"});const Gh=e=>`${(100*e).toFixed(3)}%`;const Xh=[{name:"mode",path:["mode"],default:"grouped"},{name:"bars_color_by",path:["color_by"],default:"series"}];const Jh=[{name:"bar_group",path:["group"]},{name:"bar_series",path:["series"]},{name:"bar_color",path:["color"]}];const Kh={layout:"chart",group:"compare",height:200,styles:r` + .bars { + padding: 4px 0; + } + .bar-rows { + position: relative; + transition: height var(--cd-duration) var(--cd-easing); + } + .bar-row { + position: absolute; + left: 0; + right: 0; + top: 0; + display: flex; + align-items: center; + transition: transform var(--cd-duration) var(--cd-easing); + } + .row-name { + flex: none; + width: var(--name-w); + padding-right: 8px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.875em; + } + .row-plot { + position: relative; + flex: 1; + min-width: 0; + align-self: stretch; + margin: 0 var(--value-w) 0 var(--neg-w); + } + .bars .zero { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .bar-seg { + position: absolute; + box-sizing: border-box; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + transform-origin: left center; + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + transform var(--cd-duration) var(--cd-easing); + } + .bar-seg.negative { + transform-origin: right center; + } + .mode-stacked .bar-seg, + .mode-percent .bar-seg { + border-radius: 2px; + border-right: 1px solid var(--card-background-color, #fff); + } + :host(:not([ready])) .bar-seg { + transform: scaleX(0); + } + .bar-seg:focus-visible { + outline: 2px solid var(--primary-text-color); + outline-offset: 1px; + } + .seg-text { + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: #fff; + pointer-events: none; + } + .seg-text.dark { + color: rgba(0, 0, 0, 0.8); + } + .seg-text.theme { + color: var(--primary-text-color); + } + .bar-value { + position: absolute; + display: flex; + align-items: center; + padding: 0 4px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + color: var(--primary-text-color); + transition: + left var(--cd-duration) var(--cd-easing), + right var(--cd-duration) var(--cd-easing); + } + .bar-value.unavailable { + color: var(--secondary-text-color); + } + .bar-value.total { + font-size: 12px; + font-weight: 500; + } + :host([static]) .bar-row, + :host([static]) .bar-rows, + :host([static]) .bar-value { + transition: none; + } + .bar-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-top: 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .bar-legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .bar-legend .swatch { + width: 10px; + height: 10px; + border-radius: 50%; + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){if(void 0!==e.mode&&!Bh.includes(e.mode))throw new Error(`"mode" must be one of: ${Bh.join(", ")}`);if(void 0!==e.color_by&&!Rh.includes(e.color_by))throw new Error(`"color_by" must be one of: ${Rh.join(", ")}`);(e.entities??[]).forEach((e,t)=>{if("object"==typeof e&&null!==e)for(const o of["group","series"]){const a=e[o];if(null!=a&&"string"!=typeof a&&"number"!=typeof a)throw new Error(`Entity ${t+1}: "${o}" must be text`)}})},editor:function(e){const t=Fi("grouped_bars",e.hass,()=>[{type:"grid",name:"",schema:[{name:"mode",selector:_i(e.hass,"mode",Bh)},{name:"bars_color_by",selector:_i(e.hass,"bars_color_by",Rh)}]}]);return Pi(e,"chart",t,Xh)},entityFields:e=>({schema:Fi("grouped_bars_entity",e,()=>[{type:"grid",name:"",schema:[{name:"bar_group",selector:{text:{}}},{name:"bar_series",selector:{text:{}}}]},{name:"bar_color",selector:{text:{}}}]),fields:Jh}),render:function(e){const{hass:t}=e,o=e.raw.mode??"grouped",a="bands"===e.raw.color_by,n=qh(function(e){return function(e){const t=e.config.entities;return e.items.map((e,o)=>({e:e,index:o,at:t.indexOf(e.config)})).sort((e,t)=>(-1===e.at?1/0:e.at)-(-1===t.at?1/0:t.at)||e.index-t.index)}(e).map(({e:e,index:t})=>{const o=e.config;return{index:t,group:Vh(o.group)??`\0${t}`,series:Vh(o.series)??"",value:e.available?e.value:null}})}(e),o,e.config.sort),[r,i]=n.domain,s=e=>(e-r)/(i-r),l=s(0),c=t=>{const o=e.items[t.index],r=o.config.color;return a?o.band.color:"string"==typeof r&&r?r:Zh[n.series.indexOf(t.series)%Zh.length]},{show:d}=e.config,u=!a&&n.series.some(e=>""!==e)&&!1!==e.raw.show?.legend,h=e.items.map(e=>e.display.length),p=d.value&&"percent"!==o?Math.min(12,Math.max(4,...h)):0,m=p?Math.round(6.5*p+8):4,f=Math.max(0,...n.rows.map(t=>(t.label.startsWith("\0")?e.items[t.segments[0].index].name:t.label).length));let g=0;const v=n.rows.map(e=>{const t=g;return g+=Wh(e,o),{row:e,top:t}}),y=_t(t,"grouped_bars_aria",{names:e.items.map(e=>e.name).join(", ")}),b=r<0;return R``}};function Qh(e,t,o,a=""){const n=Ae(e.config);return R`
+ ${o} +
`}const ep=r` + .compare .entity.interactive { + cursor: pointer; + } + .compare .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + .compare .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .compare .value { + font-weight: 500; + white-space: nowrap; + } + .compare .entity.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 0.875em; + white-space: normal; + } + .compare .sub { + color: var(--secondary-text-color); + font-size: 0.8em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +`,tp=["shades","colours"];function op(e,t,o,a){const{show:n}=o.config,r=function(e){const t=[];return e.bands.forEach((o,a)=>{const n=e.bands[a+1],r=Number.isFinite(o.from)?Gi(o.from,e.min,e.max):0,i=n?Gi(n.from,e.min,e.max):1;i<=r||t.push({from:t.length?r:0,to:i,color:o.color,level:0})}),t.forEach((e,t)=>e.level=t),t}(e),i=n.target?function(e,t){if(null!==e.target)return _t(t,"compare_target",{target:Mn(kn(e.target,e.decimals,t),e.unit)})}(e,o.hass):void 0,s=n.value||!e.available,l=R` + ${n.name?R`
+ ${e.name}${i?R`${i}`:W} +
`:W} +
+ ${r.map(e=>R`
`)} + ${e.available?R`
`:W} + ${n.target&&e.available&&null!==e.targetPosition?R`
`:W} +
+ ${s?R`${e.display}`:R``} + `;return Qh(e,t,l,"bullet-row")}yt("en",{editor_style_bullet:"Bullet",editor_ranges:"Ranges behind the bar",editor_ranges_shades:"Shades of grey",editor_ranges_colours:"Colour band colours",compare_target:"target {target}",chart_aria:"{style} chart of {names}"});const ap={layout:"chart",group:"compare",height:120,styles:r` + ${ep} + .style-bullet .bullets { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-bullet .bullets.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-bullet .bullet-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-bullet .label { + display: flex; + flex-direction: column; + min-width: 0; + max-width: 14em; + } + .style-bullet .track { + position: relative; + height: 28px; + border-radius: 6px; + overflow: hidden; + background: var(--cd-track); + } + .style-bullet .range { + position: absolute; + top: 0; + bottom: 0; + } + .style-bullet .fill { + position: absolute; + top: 30%; + bottom: 30%; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), color-mix(in srgb, var(--c-start) 80%, #fff)); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-bullet .target { + position: absolute; + top: 3px; + bottom: 3px; + width: 3px; + border-radius: 2px; + left: calc(var(--target) * (100% - 3px)); + background: var(--primary-text-color); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-bullet .value { + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 0.62em); + } + `,thumbnail:()=>Z` + + + + + + + + `,validate(e){if(void 0!==e.ranges&&!tp.includes(e.ranges))throw new Error(`"ranges" must be one of: ${tp.join(", ")}`)},editor(e){const t=Fi("bullet",e.hass,()=>[{name:"ranges",selector:_i(e.hass,"ranges",tp)}]);return Pi(e,"chart",t,[{name:"ranges",path:["ranges"],default:"shades"}])},render(e){const t=e.raw.ranges??"shades",o=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_bullet"),names:e.items.map(e=>e.name).join(", ")});return R`
+ ${e.items.map((o,a)=>op(o,a,e,t))} +
`}},np=["vertical","horizontal"];function rp(e,t,o){const{show:a}=t.config;return R`
+
+ ${e.available?R`
+ ${a.target&&null!==e.targetPosition?R`
`:W} +
+ ${o&&a.value?R`${e.display}`:W}`:o?R`${e.display}`:W} +
+
`}function ip(e,t,o){const{show:a}=o.config;return Qh(e,t,R`${rp(e,o,!0)} ${a.name?R`${e.name}`:W}`,"pop")}function sp(e,t,o){const{show:a}=o.config,n=a.value||!e.available;return Qh(e,t,R`${a.name?R`${e.name}`:W} ${rp(e,o,!1)} + ${n?R`${e.display}`:R``}`,"pop")}yt("en",{editor_style_lollipop:"Lollipop",editor_orientation:"Direction",editor_orientation_vertical:"Upright",editor_orientation_horizontal:"Across",chart_aria:"{style} chart of {names}"});const lp={layout:"chart",group:"compare",height:200,styles:r` + ${ep} + .style-lollipop .lane { + position: relative; + } + .style-lollipop .scale { + position: absolute; + } + .style-lollipop .stem { + position: absolute; + background: var(--c-start); + border-radius: 2px; + } + .style-lollipop .dot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--c-start); + background-image: radial-gradient( + circle at 35% 30%, + color-mix(in srgb, var(--c-start) 70%, #fff), + var(--c-start) + ); + } + .style-lollipop .target { + position: absolute; + width: 8px; + height: 8px; + border-radius: 50%; + border: 2px solid var(--primary-text-color); + background: var(--card-background-color, #fff); + box-sizing: content-box; + } + + /* Upright: one column per entity over a shared baseline. */ + .style-lollipop .vertical { + display: flex; + align-items: stretch; + } + .style-lollipop .vertical .pop { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + } + .style-lollipop .vertical .lane { + align-self: stretch; + height: 150px; + border-bottom: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .vertical .scale { + left: 50%; + top: 26px; + bottom: 0; + } + .style-lollipop .vertical .stem { + left: -1.5px; + width: 3px; + bottom: calc(var(--start) * 100%); + height: calc((var(--end) - var(--start)) * 100%); + transition: + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .dot { + left: -7px; + bottom: calc(var(--pos) * 100% - 7px); + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .target { + left: -6px; + bottom: calc(var(--target) * 100% - 6px); + } + .style-lollipop .vertical .tip { + position: absolute; + left: 0; + bottom: calc(var(--pos) * 100% + 10px); + transform: translateX(-50%); + font-size: 0.875em; + line-height: 1.2; + padding: 0 3px; + border-radius: 3px; + background: var(--card-background-color, #fff); + z-index: 1; + transition: bottom var(--cd-duration) var(--cd-easing); + } + .style-lollipop .vertical .name { + max-width: 100%; + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 0 2px; + } + .style-lollipop .vertical .entity.unavailable .value { + font-size: 0.75em; + white-space: nowrap; + } + + /* Across: one row per entity, stems growing to the right. */ + .style-lollipop .horizontal { + display: grid; + grid-template-columns: minmax(0, auto) minmax(96px, 1fr) fit-content(8em); + column-gap: 12px; + } + .style-lollipop .horizontal.no-name { + grid-template-columns: minmax(96px, 1fr) fit-content(8em); + } + .style-lollipop .horizontal .pop { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + } + .style-lollipop .horizontal .name { + max-width: 14em; + } + .style-lollipop .horizontal .lane { + height: 20px; + border-left: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .style-lollipop .horizontal .scale { + top: 50%; + left: 0; + right: 7px; + } + .style-lollipop .horizontal .stem { + top: -1.5px; + height: 3px; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .dot { + top: -7px; + left: calc(var(--pos) * 100% - 7px); + transition: left var(--cd-duration) var(--cd-easing); + } + .style-lollipop .horizontal .target { + top: -6px; + left: calc(var(--target) * 100% - 6px); + } + .style-lollipop .horizontal .value { + text-align: right; + } + `,thumbnail:()=>Z` + + + + + + + `,validate(e){if(void 0!==e.orientation&&!np.includes(e.orientation))throw new Error(`"orientation" must be one of: ${np.join(", ")}`)},editor(e){const t=Fi("lollipop",e.hass,()=>[{name:"orientation",selector:_i(e.hass,"orientation",np)}]);return Pi(e,"chart",t,[{name:"orientation",path:["orientation"],default:"vertical"}])},render(e){const t=e.raw.orientation??"vertical",o=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_lollipop"),names:e.items.map(e=>e.name).join(", ")}),a="vertical"===t?ip:sp;return R`
+ ${e.items.map((t,o)=>a(t,o,e))} +
`}},cp=["change","current"],dp=["vertical","horizontal"];function up(e){const t=e.find(e=>null!==e.v);return t?t.v:null}yt("en",{editor_style_waterfall:"Waterfall",editor_steps:"Each step is",editor_steps_change:"How much it changed in the period",editor_steps_current:"Its current value",editor_orientation:"Direction",editor_orientation_vertical:"Upright",editor_orientation_horizontal:"Across",editor_start_from_first:"Start from the first entity's reading at the start of the period",editor_total:"Show the total at the end",editor_waterfall_invert:"Counts down (a rise in these is a drop in the total)",waterfall_start:"Start",waterfall_end:"End",waterfall_total:"Total",waterfall_no_readings:"No readings",waterfall_aria:"Waterfall chart: {bars}"});const hp={vertical:.14,horizontal:.24};function pp(e,t){const{items:o,raw:a,hass:n}=e,r=a.steps??"change",i=!0===a.start_from_first,s=e=>t?.series[e]?.points??[],l=(e,t)=>{const o="change"===r?function(e){const t=e.filter(e=>null!==e.v);return 0===t.length?null:t[t.length-1].v-t[0].v}(s(t)):e.available?e.value:null;return null===o?null:function(e){return!0===e.config.invert}(e)?-o:o},c=i&&o.length?{value:up(s(0)),index:0}:null,d=o.map((e,t)=>({value:l(e,t),index:t})).slice(i?1:0),{bars:u}=function(e,t,o){const a=[];let n=0;if(e){const t=e.value??0;a.push({kind:"start",from:0,to:t,delta:t,index:e.index,missing:null===e.value}),n=t}for(const s of t){const e=s.value??0;a.push({kind:"step",from:n,to:n+e,delta:e,index:s.index,missing:null===s.value}),n+=e}o&&a.push({kind:"total",from:0,to:n,delta:n,missing:!1});let r=0,i=0;for(const s of a)r=Math.min(r,s.from,s.to),i=Math.max(i,s.from,s.to);return r===i&&(i=r+1),{bars:a,domain:[r,i]}}(c,d,!1!==a.total),h=o[0]?.decimals??1;return u.map(e=>{const t=void 0===e.index?void 0:o[e.index],a=t?.decimals??h;let r,i;return"start"===e.kind?(r=_t(n,"waterfall_start"),i=e.missing?_t(n,"waterfall_no_readings"):kn(e.to,a,n)):"total"===e.kind?(r=_t(n,c?"waterfall_end":"waterfall_total"),i=kn(e.to,h,n)):(r=t.name,i=e.missing?t.available?_t(n,"waterfall_no_readings"):t.display:function(e,t,o){const a=kn(Math.abs(e),t,o);return 0===Number(e.toFixed(t))?kn(0,t,o):`${e>0?"+":"−"}${a}`}(e.delta,a,n)),{...e,name:r,label:i,entity:t}})}function mp(e,t){const o="horizontal"===e.raw.orientation,a=t.flatMap(e=>[e.from,e.to]);let n=Math.min(0,...a),r=Math.max(0,...a);n===r&&(r=n+1);const i=(r-n)*hp[o?"horizontal":"vertical"];r+=i,n<0&&(n-=i);const s=e=>(e-n)/(r-n)*100,l=t.length,c=100/l,d=o?.64:.6,u=e=>c*e+c*(1-d)/2,h=e=>u(e)+c*d,p=e.items[0]?.unit??"",m=_t(e.hass,"waterfall_aria",{bars:t.map(e=>`${e.name} ${e.missing?e.label:Mn(e.label,e.entity?.unit??p)}`).join(", ")}),f=e=>void 0===e.index?"":"entity",g=t.map((e,t)=>{const a=`--a0:${s(Math.min(e.from,e.to))}%;--a1:${s(Math.max(e.from,e.to))}%;--c0:${u(t)}%;--c1:${h(t)}%;--cm:${(u(t)+h(t))/2}%;--color:${function(e){return"step"!==e.kind?Ce("primary"):Ce(e.delta<0?"red":"green")}(e)}`,n=t+1
+ ${e.missing&&!o?"–":e.label} + ${n?R`
`:W}`}),v=t.map(e=>R`
+ ${e.name} +
`);return R`
+
${v}
+ +
`}const fp=[...ki,{name:"steps",path:["steps"],default:"change"},{name:"orientation",path:["orientation"],default:"vertical"},{name:"start_from_first",path:["start_from_first"],default:!1},{name:"total",path:["total"],default:!0}];function gp(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"${t}" must be true or false`)}const vp={layout:"chart",group:"totals",height:240,styles:r` + ${$n} + .wf { + display: grid; + gap: 6px; + } + .wf.vertical { + grid-template-rows: 180px auto; + } + .wf.vertical .names { + grid-row: 2; + display: grid; + grid-template-columns: repeat(var(--n), minmax(0, 1fr)); + } + .wf.vertical .wf-plot { + grid-row: 1; + } + .wf.horizontal { + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + max-width: 100%; + } + .wf.horizontal .names { + display: grid; + grid-template-rows: repeat(var(--n), 30px); + max-width: 140px; + } + .wf.horizontal .wf-plot { + height: calc(var(--n) * 30px); + } + .wf-plot { + position: relative; + min-width: 0; + } + .bar-name { + min-width: 0; + display: flex; + align-items: center; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .bar-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .wf.vertical .bar-name { + justify-content: center; + padding: 0 2px; + } + .bar-name.entity { + cursor: pointer; + border-radius: 4px; + } + .bar { + position: absolute; + box-sizing: border-box; + background: linear-gradient(var(--color), color-mix(in srgb, var(--color) 80%, #fff)); + border-radius: 4px; + transition: transform var(--cd-duration) var(--cd-easing); + } + .bar.entity { + cursor: pointer; + } + .bar.missing { + background: none; + border: 1.5px dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .bar { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a0); + height: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 50% 100%; + transform: scaleY(0); + } + .wf.horizontal .bar { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a0); + width: max(calc(var(--a1) - var(--a0)), 2px); + transform-origin: 0 50%; + transform: scaleX(0); + } + .wf.vertical .bar.missing { + height: 16px; + bottom: calc(var(--a0) - 8px); + } + .wf.horizontal .bar.missing { + width: 16px; + left: calc(var(--a0) - 8px); + } + :host([ready]) .wf .bar { + transform: none; + } + .bar-label { + position: absolute; + font-size: 0.8125em; + font-weight: 500; + line-height: 1; + white-space: nowrap; + pointer-events: none; + color: var(--primary-text-color); + } + .bar-label:not(.start):not(.total):not(.missing) { + color: var(--color); + } + .bar-label.missing { + color: var(--secondary-text-color); + font-weight: 400; + } + .wf.horizontal .bar-label.missing { + margin-left: 10px; + } + .wf.vertical .bar-label { + left: var(--cm); + bottom: calc(var(--a1) + 4px); + transform: translateX(-50%); + } + .wf.horizontal .bar-label { + left: calc(var(--a1) + 6px); + top: var(--cm); + transform: translateY(-50%); + } + .connector { + position: absolute; + pointer-events: none; + border: 0 dashed var(--secondary-text-color); + opacity: 0.6; + } + .wf.vertical .connector { + left: var(--c0); + width: calc(var(--c1) - var(--c0)); + bottom: var(--a); + border-top-width: 1px; + } + .wf.horizontal .connector { + top: var(--c0); + height: calc(var(--c1) - var(--c0)); + left: var(--a); + border-left-width: 1px; + } + /* Narrow cards: smaller text so neighbouring columns' labels don't run into each other. */ + @container (max-width: 400px) { + .wf .bar-label, + .wf .bar-name { + font-size: 0.6875em; + } + .wf.horizontal .names { + max-width: 96px; + } + } + .zero { + position: absolute; + pointer-events: none; + background: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .wf.vertical .zero { + left: 0; + right: 0; + bottom: var(--a); + height: 1px; + } + .wf.horizontal .zero { + top: 0; + bottom: 0; + left: var(--a); + width: 1px; + } + `,thumbnail:()=>Z` + + + + + `,validate(e){if(pn(e),void 0!==e.steps&&!cp.includes(e.steps))throw new Error(`"steps" must be one of: ${cp.join(", ")}`);if(void 0!==e.orientation&&!dp.includes(e.orientation))throw new Error(`"orientation" must be one of: ${dp.join(", ")}`);gp(e.start_from_first,"start_from_first"),gp(e.total,"total");for(const t of e.entities??[])"object"==typeof t&&gp(t.invert,"invert")},editor:function(e){const t=Fi("waterfall",e.hass,()=>[...Mi(e.hass),{type:"grid",name:"",schema:[{name:"steps",selector:_i(e.hass,"steps",cp)},{name:"orientation",selector:_i(e.hass,"orientation",dp)}]},{name:"start_from_first",selector:{boolean:{}}},{name:"total",selector:{boolean:{}}}]);return R`${Pi(e,"chart",t,fp)} + ${Fu(e,"waterfall_invert","invert",{boolean:{}},!1)}`},disconnect:bn,render(e){let t;if("change"===((o=e.raw).steps??"change")||!0===o.start_from_first){t=mn(e);const o=xn(t,e);if(o)return o}var o;return mp(e,pp(e,t))}},yp=["value","change"],bp=["direction","group"],_p=["bad","good"];yt("en",{editor_style_slope:"Slope",editor_measure:"Compare",editor_measure_value:"The reading then and now",editor_measure_change:"How much it rose in each period (meters)",editor_color_by_direction:"Up or down",editor_color_by_group:"By group",editor_increase:"A rise is",editor_increase_bad:"Bad (red)",editor_increase_good:"Good (green)",editor_group:"Group",slope_now:"Now",slope_ago:"{n} {unit} ago",slope_ago_one:"A {unit} ago",slope_previous:"Previous {n} {unit}",slope_previous_one:"Previous {unit}",slope_last:"Last {n} {unit}",slope_last_one:"Last {unit}",slope_unit_hour:"hour",slope_unit_hour_other:"hours",slope_unit_day:"day",slope_unit_day_other:"days",slope_unit_week:"week",slope_unit_week_other:"weeks",slope_unit_month:"month",slope_unit_month_other:"months",slope_entity_aria:"{name}, {from} to {to}",slope_no_reading:"no reading"});const xp={"5minute":3e5,hour:36e5,day:864e5,month:26784e5};function wp(e,t,o){const a=function(e,t,o=1e-9){return Math.abs(t-e)<=o?"flat":t>e?"up":"down"}(e,t);return"flat"===a?"grey":"up"===a==("bad"===o)?"red":"green"}function $p(e,t,o){const a=1===o.last,n=_t(e,`slope_unit_${o.unit}${a?"":"_other"}`);return _t(e,a?`slope_${t}_one`:`slope_${t}`,{n:o.last,unit:n})}function kp(e,t,o){const a=function(e,t){if(null!==e&&null!==t&&0!==e)return(t-e)/Math.abs(e)}(e,t);if(void 0===a)return"";const n=Math.round(100*a);return`${n>0?"+":n<0?"−":""}${kn(Math.abs(n),0,o)} %`}function Mp(e,t,o,a){const n="statistics"===e.source?xp[Ia(e.range)]:0,r="group"===t.raw.color_by,i="good"===t.raw.increase?"good":"bad",s=new Map,l=e.series.map(e=>{const t=e.entity.available?e.entity.value:null,[l,c]=function(e,t,o,a,n=0){const r=t.find(e=>null!==e.v),i=Ih(t,o.start)??(r&&r.t-o.start<=n?r.v:null);if("value"===e)return[i,a];const s=Ih(t,o.middle);return[null===i||null===s?null:s-i,null===s||null===a?null:a-s]}(o,e.points,a,t,n),d=function(e){const t=e.config.group;return"string"==typeof t&&""!==t.trim()?t.trim():void 0}(e.entity),u=e.entity.config.color;let h;if(r){const t=d??`#${e.index}`;s.has(t)||s.set(t,un[s.size%un.length]),h=u??s.get(t)}else h=null!==l&&null!==c?wp(l,c,i):"grey";return{index:e.index,entity:e.entity,from:l,to:c,color:Ce(h),group:d}});return l}const zp=[...ki,{name:"measure",path:["measure"],default:"value"},{name:"color_by",path:["color_by"],default:"direction"},{name:"increase",path:["increase"],default:"bad"}],Ap=[{name:"group",path:["group"]}],Sp={layout:"chart",group:"compare",height:230,styles:r` + ${$n} + .style-slope .slope-head, + .style-slope .slope-plot { + --lw: min(36%, 180px); + } + .slope-head { + display: grid; + grid-template-columns: var(--lw) 1fr var(--lw); + gap: 0 8px; + padding: 4px 0 6px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-head .from { + grid-column: 1; + text-align: right; + } + .slope-head .to { + grid-column: 3; + } + .slope-head .unit { + grid-column: 2; + text-align: center; + } + .slope-plot { + position: relative; + margin: 8px 0; + } + .slope-plot .axis { + position: absolute; + top: -6px; + bottom: -6px; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .slope-plot .axis.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .axis.to { + right: calc(var(--lw) + 8px); + } + .slope-plot .entity { + position: absolute; + inset: 0; + pointer-events: none; + outline: none; + opacity: 1; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .slope-plot .entity { + opacity: 0; + } + .slope-plot .entity > * { + pointer-events: auto; + cursor: pointer; + } + .slope-plot .entity:focus-visible .lbl { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + border-radius: 3px; + } + .slope-plot svg { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--lw) + 8px); + right: calc(var(--lw) + 8px); + width: calc(100% - 2 * var(--lw) - 16px); + height: 100%; + overflow: visible; + pointer-events: none; + } + .slope-plot svg line { + vector-effect: non-scaling-stroke; + stroke-width: 3; + stroke-linecap: round; + pointer-events: stroke; + } + .slope-plot .dot { + position: absolute; + width: 10px; + height: 10px; + margin: -5px 0 0 -5px; + border-radius: 50%; + } + .slope-plot .dot.from { + left: calc(var(--lw) + 8px); + } + .slope-plot .dot.to { + left: calc(100% - var(--lw) - 8px); + } + .slope-plot .lbl { + position: absolute; + transform: translateY(-50%); + width: var(--lw); + display: flex; + gap: 4px; + align-items: baseline; + font-size: 0.875em; + line-height: 1.2; + white-space: nowrap; + min-width: 0; + } + .slope-plot .lbl.from { + left: 0; + justify-content: flex-end; + } + .slope-plot .lbl.to { + right: 0; + } + .slope-plot .lbl .name { + color: var(--secondary-text-color); + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .slope-plot .lbl .value { + font-weight: 500; + flex: none; + } + .slope-plot .lbl .pct { + flex: none; + padding-left: 2px; + font-size: 0.9em; + } + /* Narrow cards: the change column would squeeze the names to nothing. */ + @container (max-width: 380px) { + .slope-plot .lbl .pct { + display: none; + } + } + .slope-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding: 4px 0; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .slope-legend-item { + display: flex; + align-items: center; + gap: 6px; + } + `,thumbnail:()=>Z` + + + + ${[[14,28],[50,10],[14,12],[50,22],[14,32],[50,30]].map(([e,t])=>Z``)}`,validate(e){pn(e);const t=(t,o)=>{if(void 0!==e[t]&&!o.includes(e[t]))throw new Error(`"${t}" must be one of: ${o.join(", ")}`)};t("measure",yp),t("color_by",bp),t("increase",_p);for(const o of e.entities??[]){const e="object"==typeof o&&o?o.group:void 0;if(void 0!==e&&"string"!=typeof e)throw new Error('"group" must be text')}},editor(e){const t=Fi("slope",e.hass,()=>[...Mi(e.hass),{name:"measure",selector:_i(e.hass,"measure",yp)},{type:"grid",name:"",schema:[{name:"color_by",selector:_i(e.hass,"color_by",bp)},{name:"increase",selector:_i(e.hass,"increase",_p)}]}]);return Pi(e,"chart",t,zp)},entityFields:e=>({schema:Fi("slope-entity",e,()=>[{name:"group",selector:{text:{}}}]),fields:Ap}),disconnect:bn,render(e){const t=Date.now(),o=$a(e.raw.period),a="change"===e.raw.measure?"change":"value",n=mn("change"===a?{...e,raw:{...e.raw,period:{last:2*o.last,unit:o.unit}}}:e,t),r=ka(o,t).start,i={start:n.range.start,middle:r},s=e.hass,l=$p(s,"change"===a?"previous":"ago",o),c="change"===a?$p(s,"last",o):_t(s,"slope_now");if("ready"!==n.status)return R`${xn(n,e)}`;const d=Mp(n,e,a,i),u=d.flatMap(e=>[e.from,e.to]),h=Hh(u);if(!h)return R`
${_t(s,"chart_no_data")}
`;const{show:p}=e.config,m=new Set(d.filter(e=>e.entity.stateObj).map(e=>e.entity.unit)),f=1===m.size?[...m][0]:void 0,g=(e,t)=>{const o=kn(t,e.entity.decimals,s);return void 0===f&&e.entity.unit?`${o} ${e.entity.unit}`:o},v=d.filter(e=>null!==e.from||null!==e.to),y=Math.max(160,22*v.length),[b,_]=wo(h,.06),x=e=>9+(_-e)/(_-b)*(y-18),w=e=>{const t=v.filter(t=>null!==e(t)),o=function(e,t,o,a){const n=e.map((e,t)=>({y:e,i:t})).sort((e,t)=>e.y-t.y||e.i-t.i),r=n.map(e=>Math.min(a,Math.max(o,e.y)));for(let s=1;sa){r[r.length-1]=a;for(let e=r.length-2;e>=0;e--)r[e]=Math.min(r[e],r[e+1]-t)}if(r.length&&r[0]i[e.i]=r[t]),i}(t.map(t=>x(e(t))),18,9,y-9);return new Map(t.map((e,t)=>[e,o[t]]))},$=w(e=>e.from),k=w(e=>e.to),M=_t(s,"slope_no_reading"),z=_t(s,"chart_aria",{style:_t(s,"editor_style_slope"),names:d.map(e=>e.entity.name).join(", ")});return R`
+ ${l} + ${f?R`${f}`:W} + ${c} +
+
+
+
+ ${v.map(e=>{const t=_t(s,"slope_entity_aria",{name:e.entity.name,from:null===e.from?M:g(e,e.from),to:null===e.to?M:g(e,e.to)}),o=p.value&&null!==e.from&&null!==e.to?kp(e.from,e.to,s):"",a=e=>p.name?R`${e}`:W;return R``})} +
+ ${"group"===e.raw.color_by?function(e){const t=new Map;for(const o of e)o.group&&!t.has(o.group)&&t.set(o.group,o.color);return t.size?R`
+ ${[...t].map(([e,t])=>R`${e}`)} +
`:W}(d):W}`}},Ep=["range","then","target"],Cp=["horizontal","vertical"];function jp(e,t,o,a,n){const r=e.available?e.value:null,i=[];if("range"===t){const e=function(e){return Hh(e.flatMap(e=>[e.v,e.min,e.max]))}(o),t=e?Hh([e[0],e[1],r]):void 0;t&&i.push({kind:"low",value:t[0]},{kind:"high",value:t[1]}),null===r||!n&&t||i.push({kind:"now",value:r})}else{const n="then"===t?Ih(o,a)??function(e){for(const t of e)if(null!==t.v)return t.v;return null}(o):e.target;null!==n&&i.push({kind:t,value:n}),null!==r&&i.push({kind:"now",value:r})}return i.sort((e,t)=>e.value-t.value)}function Np(e){if(0===e.length)return[];const t=e.some(e=>"low"===e.kind)?e.filter(e=>"now"!==e.kind):e,o=t[0],a=t[t.length-1];return o===a?[a]:[o,a]}function Lp(e,t,o,a,n){return e.map((e,r)=>{const i=jp(e,t,o(r),a,n);return{index:r,entity:e,marks:i,labelled:Np(i)}})}function Tp(e,t){return 1===t.last?_t(e,`dumbbell_ago_${t.unit}_one`):_t(e,`dumbbell_ago_${t.unit}`,{n:t.last})}yt("en",{editor_style_dumbbell:"Dumbbell",editor_ends:"Compare",editor_ends_range:"Lowest and highest in the period",editor_ends_then:"Start of the period and now",editor_ends_target:"Target and now",editor_show_now:"Mark the current value",editor_orientation:"Direction",editor_orientation_horizontal:"Across",editor_orientation_vertical:"Upright",dumbbell_low:"Low",dumbbell_high:"High",dumbbell_now:"Now",dumbbell_target:"Target",dumbbell_ago_hour_one:"An hour ago",dumbbell_ago_hour:"{n} hours ago",dumbbell_ago_day_one:"A day ago",dumbbell_ago_day:"{n} days ago",dumbbell_ago_week_one:"A week ago",dumbbell_ago_week:"{n} weeks ago",dumbbell_ago_month_one:"A month ago",dumbbell_ago_month:"{n} months ago",dumbbell_no_value:"No value"});const Op={low:Ce("blue"),high:Ce("red"),then:Ce("grey"),target:"var(--primary-text-color, #212121)"};function Fp(e,t){return"target"===e||"now"===e&&"range"===t}function Pp(e,t,o){return"now"!==e?Op[e]:"range"===t?"var(--primary-text-color, #212121)":Ce(o.band.color)}const Dp="range",Up="horizontal",Hp=!0;function Ip(e){return{ends:e.ends??Dp,orientation:e.orientation??Up,showNow:void 0===e.show_now?Hp:!0===e.show_now}}function Bp(e,t,o){const a=e.entity;if(0===e.marks.length)return`${a.name}, ${a.available?_t(t.hass,"dumbbell_no_value"):a.display}`;const n=e.marks.map(e=>`${(e=>"then"===e?Tp(t.hass,o):_t(t.hass,`dumbbell_${e}`))(e.kind)} ${Mn(kn(e.value,a.decimals,t.hass),a.unit)}`);return`${a.name}: ${n.join(", ")}`}const Rp=[...ki,{name:"ends",path:["ends"],default:Dp},{name:"show_now",path:["show_now"],default:Hp},{name:"orientation",path:["orientation"],default:Up}];const Zp={layout:"chart",group:"compare",height:220,styles:r` + ${$n} + .db { + --pad: 40px; + --span: calc(100% - 2 * var(--pad)); + --dot: 14px; + display: flex; + flex-direction: column; + } + .db.no-values { + --pad: 12px; + } + .db-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 4px 14px; + padding: 2px 0 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .db-unit { + margin-right: auto; + } + /* Narrow cards: every other tick label, so they don't run together. */ + @container (max-width: 400px) { + .db.horizontal .db-tick:nth-child(even) { + visibility: hidden; + } + } + .db-key { + display: inline-flex; + align-items: center; + gap: 6px; + } + .db-swatch { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + } + .db-swatch.ring, + .db-dot.ring { + background: var(--card-background-color, #fff); + border: 2.5px solid var(--mark); + } + .db-row { + position: relative; + cursor: pointer; + border-radius: 6px; + outline: none; + } + .db-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .db-name { + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .db-plot { + position: relative; + } + .db-grid, + .db-bar, + .db-dot, + .db-label, + .db-tick { + position: absolute; + } + .db-dot { + width: var(--dot); + height: var(--dot); + border-radius: 50%; + background: var(--mark); + box-sizing: border-box; + z-index: 1; + } + .db-dot.now.ring { + z-index: 2; + } + .db-label, + .db-tick, + .db-status { + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + line-height: 1; + } + .db-status { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + .db-bar, + .db-dot, + .db-label { + transition: + left var(--cd-duration) var(--cd-easing), + width var(--cd-duration) var(--cd-easing), + bottom var(--cd-duration) var(--cd-easing), + height var(--cd-duration) var(--cd-easing), + opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .db-bar, + :host(:not([ready])) .db-dot, + :host(:not([ready])) .db-label { + opacity: 0; + } + + /* Rows: value across, one entity per row. */ + .db.horizontal .db-row { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + align-items: center; + column-gap: 8px; + height: 36px; + } + .db.horizontal.no-names .db-row, + .db.horizontal.no-names .db-axis { + grid-template-columns: minmax(0, 1fr); + } + .db.horizontal .db-plot { + height: 100%; + } + .db.horizontal .db-grid { + top: 0; + bottom: 0; + left: calc(var(--pad) + var(--span) * var(--f)); + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.horizontal .db-bar { + top: 50%; + height: 6px; + transform: translateY(-50%); + border-radius: 3px; + left: calc(var(--pad) + var(--span) * var(--a)); + width: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to right, var(--from), var(--to)); + opacity: 0.45; + } + .db.horizontal .db-dot { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, -50%); + } + .db.horizontal .db-label { + top: 50%; + left: calc(var(--pad) + var(--span) * var(--f)); + } + .db.horizontal .db-label.lo { + transform: translate(calc(-100% - 11px), -50%); + } + .db.horizontal .db-label.hi { + transform: translate(11px, -50%); + } + .db.horizontal .db-axis { + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr); + column-gap: 8px; + height: 22px; + } + .db.horizontal .db-axis .db-plot { + grid-column: -2; + } + .db.horizontal .db-tick { + top: 6px; + left: calc(var(--pad) + var(--span) * var(--f)); + transform: translateX(-50%); + } + + /* Columns: value up, one entity per column. */ + .db.vertical .db-body { + display: flex; + } + .db.vertical .db-row { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column-reverse; + align-items: stretch; + } + .db.vertical .db-name { + text-align: center; + font-size: 0.8125em; + padding-top: 4px; + height: 1.4em; + } + .db.vertical .db-plot { + height: 180px; + } + /* Columns are narrow and tall: status text runs up the column. */ + .db.vertical .db-status { + writing-mode: vertical-rl; + transform: rotate(180deg); + overflow: hidden; + } + .db.vertical { + --pad: 22px; + } + .db.vertical .db-grid { + left: 0; + right: 0; + bottom: calc(var(--pad) + var(--span) * var(--f)); + border-top: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .db.vertical .db-bar { + left: 50%; + width: 6px; + transform: translateX(-50%); + border-radius: 3px; + bottom: calc(var(--pad) + var(--span) * var(--a)); + height: calc(var(--span) * (var(--b) - var(--a))); + background: linear-gradient(to top, var(--from), var(--to)); + opacity: 0.45; + } + .db.vertical .db-dot { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translate(-50%, 50%); + } + .db.vertical .db-label { + left: 50%; + bottom: calc(var(--pad) + var(--span) * var(--f)); + } + .db.vertical .db-label.lo { + transform: translate(-50%, calc(100% + 11px)); + } + .db.vertical .db-label.hi { + transform: translate(-50%, -11px); + } + .db.vertical .db-axis { + flex: none; + position: relative; + width: 3.5em; + height: 180px; + } + .db.vertical .db-tick { + right: 6px; + bottom: calc(var(--pad) + var(--span) * var(--f)); + transform: translateY(50%); + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate(e){if(pn(e),void 0!==e.ends&&!Ep.includes(e.ends))throw new Error(`"ends" must be one of: ${Ep.join(", ")}`);if(void 0!==e.orientation&&!Cp.includes(e.orientation))throw new Error(`"orientation" must be one of: ${Cp.join(", ")}`);if(void 0!==e.show_now&&"boolean"!=typeof e.show_now)throw new Error('"show_now" must be true or false')},editor:function(e){const{ends:t}=Ip(e.config),o=Fi(`dumbbell-${t}`,e.hass,()=>[{type:"grid",name:"",schema:[{name:"ends",selector:_i(e.hass,"ends",Ep)},{name:"orientation",selector:_i(e.hass,"orientation",Cp)}]},..."target"===t?[]:Mi(e.hass),..."range"===t?[{name:"show_now",selector:{boolean:{}}}]:[]]);return Pi(e,"chart",o,Rp)},disconnect:bn,render(e){const{ends:t,orientation:o,showNow:a}=Ip(e.raw),n=$a(e.raw.period);let r;if("target"===t)r=Lp(e.items,t,()=>[],0,a);else{const o=mn(e);if("ready"!==o.status)return xn(o,e)??R``;r=Lp(e.items,t,e=>o.series[e]?.points??[],o.range.start,a)}const i=Hh(r.flatMap(e=>e.marks.map(e=>e.value))),s=i?Uh(i[0],i[1],4):[],[l,c]=s.length?[s[0],s[s.length-1]]:[0,1],d=e=>c===l?.5:(e-l)/(c-l),u=function(e){const t=new Set(e.map(e=>e.unit).filter(e=>""!==e));return 1===t.size?[...t][0]:""}(e.items),h=function(e){if(e.length<2)return 0;const t=Math.abs(e[1]-e[0]);for(let o=0;o<6;o++)if(Math.abs(Math.round(t*10**o)-t*10**o)<1e-6)return o;return 6}(s),{show:p}=e.config,m=Math.max(...e.items.map(e=>e.name.length)),f=`min(${Math.min(m,16)+1}ch, 38%)`,g=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_dumbbell"),names:e.items.map(e=>e.name).join(", ")}),v=s.map(t=>R`${kn(t,h,e.hass)}`),y=r.map(o=>function(e,t,o,a,n,r){const i=e.entity,{show:s}=t.config,l=e.labelled[0],c=e.labelled[e.labelled.length-1],d=e=>Fp(e.kind,o)?"color-mix(in srgb, var(--primary-text-color, #212121) 30%, transparent)":Pp(e.kind,o,i);return R``}(o,e,t,d,s,n)),b=`db ${o} ${p.name?"":"no-names"} ${p.value?"":"no-values"}`;return R`
+ ${i?function(e,t,o,a,n,r){const i=t=>_t(e.hass,t),s=new Set(a.map(e=>Ce(e.entity.band.color))),l=1===s.size?[...s][0]:Ce("primary"),c="range"===t?[[i("dumbbell_low"),Op.low,!1],...o?[[i("dumbbell_now"),Pp("now",t,a[0].entity),!0]]:[],[i("dumbbell_high"),Op.high,!1]]:["then"===t?[Tp(e.hass,n),Op.then,!1]:[i("dumbbell_target"),Op.target,!0],[i("dumbbell_now"),l,!1]];return R`
+ ${r?R`${r}`:W} + ${c.map(([e,t,o])=>R`${e}`)} +
`}(e,t,a,r,n,u):W} + ${"horizontal"===o?R`${y} + ${s.length?R``:W}`:R`
+ ${s.length?R``:W}${y} +
`} +
`}};function qp(e){const t={...e,source:e.source??"statistics"};if("history"===t.source&&void 0===e.group_by){const o=e.period??{},a=(o.last??24)*({hour:1,day:24,week:168,month:720}[o.unit??"hour"]??1);t.group_by="day"===o.calendar||!o.calendar&&a<=48?"hour":"day"}return t}function Wp(e){return e.flatMap((t,o)=>o>0?[[t[0],e[o-1][1]],t]:[t])}function Vp(e,t,o){if(e.length<2)return"";const[a,n,r]="step"===o?[Wp(e),Wp(t),"straight"]:[e,t,o];return`${sn(a,r)}${sn([...n].reverse(),r).replace(/^M/,"L")}Z`}yt("en",{editor_style_range_area:"Range area",editor_mean:"Draw the average as a line",editor_range_color:"Colour"});const Yp=[...xi.map(e=>"source"===e.name?{...e,default:"statistics"}:e),{name:"curve",path:["curve"],default:"smooth"},{name:"mean",path:["mean"],default:!0},{name:"line_width",path:["line_width"],default:At},...Si,...zi];const Gp=[{name:"range_color",path:["color"]},{name:"curve",path:["curve"]},{name:"dash",path:["dash"],default:!1}],Xp={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + .plot .band { + fill-opacity: 0.22; + } + .plot .band-edge { + stroke-width: 1px; + stroke-opacity: 0.45; + } + .plot .series .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .plot .series .line.dashed { + stroke-dasharray: 6 5; + } + `,thumbnail:()=>Z` + + `,validate(e){if(pn(e),Lo(e),lr(e),Ft(e),Zn(e),void 0!==e.mean&&"boolean"!=typeof e.mean)throw new Error('"mean" must be true or false')},editor:function(e){const t=Fi(`range_area|${wi(e.config)}`,e.hass,()=>[...$i(e.hass,e.config),{name:"curve",selector:_i(e.hass,"curve",an)},{type:"grid",name:"",schema:[{name:"mean",selector:{boolean:{}}},{name:"line_width",selector:Vt}]},...Ei(e.hass),...Ai(e.hass)]);return Pi(e,"chart",t,Yp)},entityFields:e=>({schema:Fi("range-area-entity",e,()=>[{type:"grid",name:"",schema:[{name:"range_color",selector:{text:{}}},{name:"curve",selector:_i(e,"curve",an)},{name:"dash",selector:{boolean:{}}}]}]),fields:Gp}),disconnect(e){bn(e),Fn(e),ir(e)},render:function(e){const t={...e,raw:qp(e.raw)},o=mn(t,Date.now(),{range:!0}),a=vn(o,t),n=_n(o,t),r=a===W&&n===W?W:R`${a}${n}`,i=xn(o,t),s=qn(t.raw);if(i)return Wn("over"===s.header?{...s,header:"above"}:s,r,i);const l=o.series,c=Dn(t,o.range,xo(l.map(e=>e.points))),d=!1!==t.raw.mean,u=t.raw.line_width??At,h={text:(e,o)=>{if(!o||null===o.v)return"—";const a=o=>kn(o,e.entity.decimals,t.hass),n=o.min??o.v,r=o.max??o.v;return Mn(`${a(n)} – ${a(r)}`,e.entity.unit)+(d?` · ${a(o.v)}`:"")}};d||(h.dotY=()=>null);const p=sr(t.raw),m=_t(t.hass,"chart_aria",{style:_t(t.hass,"editor_style_range_area"),names:o.series.map(e=>e.entity.name).join(", ")});return Wn(s,r,In({axes:c,ctx:t,content:Z`${l.map(e=>function(e,t,o,a){const n=[],r=[],i=[];for(const s of function(e){const t=[];let o=[];for(const a of e)null!==a.v?o.push({t:a.t,v:a.v,lo:a.min??a.v,hi:a.max??a.v}):(o.length&&t.push(o),o=[]);return o.length&&t.push(o),t}(e.points)){const o=s.map(e=>[t.x(e.t),t.y(e.hi)]),a=s.map(e=>[t.x(e.t),t.y(e.lo)]);n.push(Vp(o,a,e.curve)),r.push(sn(o,e.curve),sn(a,e.curve)),i.push(sn(s.map(e=>[t.x(e.t),t.y(e.v)]),e.curve))}return Z` + + + ${o?Z``:W} + `}(e,c.scales,d,u))}`,label:m,overlay:R`${"over"===s.header?Vn(r):W} + ${p?pr(t,l,c.scales,rr(t.host),Pn(t),h):W}`,hover:p?cr(t,o.range,l.map(e=>e.points)):void 0}))}},Jp=["line","area","column"];function Kp(e,t){return e.config.spark??t.spark??"line"}function Qp(e){const t=e.period??{},o=(t.last??24)*({hour:1,day:24,week:168,month:720}[t.unit??"hour"]??1);return(t.calendar?"day"===t.calendar:o<=48)?"hour":"day"}function em(e,t){return void 0===e.group_by&&0!==t.length&&t.every(e=>"column"===e)?{...e,group_by:Qp(e),aggregate:e.aggregate??"mean"}:e}function tm(e,t,o,a){const n=ta(e.points,120),r=n.filter(e=>null!==e.v).map(e=>e.v),i=function(e,t){if(0===e.length)return;let o=Math.min(...e),a=Math.max(...e);return t?(o=Math.min(o,0),a=Math.max(a,0),o===a?[o,o+1]:[o,a]):([o,a]=wo([o,a],.1),[o,a])}(r,"column"===t);if(!i)return Z``;const s=$o([o.start,o.end],[0,100]),l=$o(i,[23,1]),c=l(Math.min(Math.max(0,i[0]),i[1]));if("column"===t){const t=[];return n.forEach((e,a)=>{if(null===e.v)return;const r=e.end??n[a+1]?.t??o.end,i=s(e.t),d=Math.max(0,Math.min(100,s(r))-i);if(d<=0)return;const u=Math.min(l(e.v),c);t.push(Z``)}),Z`${t}`}const d=ln(n).map(e=>e.map(e=>[s(e.t),l(e.v)])),u=d.map(t=>sn(t,e.curve)).join(""),h="area"===t?d.filter(e=>e.length>1).map(t=>`${sn(t,"step"===e.curve?"straight":e.curve)}L${t[t.length-1][0]},${c}L${t[0][0]},${c}Z`).join(""):"";return Z`${h?Z``:W} + `}yt("en",{editor_style_sparkline:"Sparklines",editor_spark:"Sparkline",editor_spark_line:"Line",editor_spark_area:"Area",editor_spark_column:"Columns",editor_spark_bar:"Bar under the name and value",editor_spark_color:"Colour",spark_aria:"Trend of {name}"});const om=[...xi,{name:"spark",path:["spark"],default:"line"},{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:1.5},{name:"spark_bar",path:["bar"],default:!1}];const am=[{name:"spark",path:["spark"]},{name:"spark_color",path:["color"]}];const nm={layout:"chart",group:"history",height:120,styles:r` + ${$n} + /* One grid for every row, so the sparklines line up whatever each value's width. */ + .spark-rows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%) auto; + column-gap: 12px; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(64px, 38%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(64px, 38%); + } + .spark-row { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + padding: 6px 0; + min-height: 28px; + border-radius: 6px; + cursor: pointer; + } + .spark-row:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .spark-row .icon { + color: var(--state-icon-color, var(--secondary-text-color)); + --mdc-icon-size: 22px; + } + .spark-row .name { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .spark-row .value { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + text-align: right; + min-width: calc(var(--cd-value-ch, 4) * 1ch); + font-variant-numeric: tabular-nums; + } + .spark-row.unavailable .value { + color: var(--secondary-text-color); + font-weight: 400; + font-size: 13px; + max-width: 7em; + overflow: hidden; + text-overflow: ellipsis; + } + /* Narrow cards: the names keep their room, the sparklines give some up. */ + @container (max-width: 360px) { + .spark-rows { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.icons { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%) auto; + } + .spark-rows.bars { + grid-template-columns: minmax(0, 1fr) minmax(48px, 30%); + } + .spark-rows.icons.bars { + grid-template-columns: auto minmax(0, 1fr) minmax(48px, 30%); + } + } + .spark { + width: 100%; + height: 28px; + overflow: visible; + } + .spark path { + vector-effect: non-scaling-stroke; + stroke-linecap: round; + stroke-linejoin: round; + } + .spark .spark-fill { + fill-opacity: 0.22; + } + .spark-body { + min-width: 0; + } + .spark-body .top { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + .spark-body .value { + font-weight: 400; + font-size: 13px; + color: var(--secondary-text-color); + min-width: 0; + } + .spark-track { + position: relative; + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--cd-track); + } + .spark-bar { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--start) * 100%); + width: calc((var(--end) - var(--start)) * 100%); + border-radius: 4px; + background: var(--c-start); + background-image: linear-gradient(90deg, var(--c-start), var(--c-end)); + } + .spark-target { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + left: calc(var(--target) * (100% - 2px)); + background: var(--primary-text-color); + } + .spark-message { + color: var(--secondary-text-color); + font-size: 0.875em; + padding: 4px 0; + } + .spark-message.error { + color: var(--error-color, #db4437); + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){pn(e);const t=[e.spark,...(e.entities??[]).map(e=>"object"==typeof e?e.spark:void 0)];for(const a of t)if(void 0!==a&&!Jp.includes(a))throw new Error(`"spark" must be one of: ${Jp.join(", ")}`);if(void 0!==e.bar&&"boolean"!=typeof e.bar)throw new Error('"bar" must be true or false');const o=e.line_width;if(void 0!==o&&("number"!=typeof o||oEt))throw new Error('"line_width" must be a number from 0.5 to 12')},editor:function(e){const t=Fi(`sparkline|${wi(e.config)}`,e.hass,()=>[...$i(e.hass,e.config),{type:"grid",name:"",schema:[{name:"spark",selector:_i(e.hass,"spark",Jp)},{name:"curve",selector:_i(e.hass,"curve",an)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:Vt},{name:"spark_bar",selector:{boolean:{}}}]}]);return Pi(e,"chart",t,om)},entityFields:e=>({schema:Fi("sparkline-entity",e,()=>[{type:"grid",name:"",schema:[{name:"spark",selector:_i(e,"spark",Jp)},{name:"spark_color",selector:{text:{}}}]}]),fields:am}),disconnect(e){bn(e)},render:function(e){const t=e.items.map(t=>Kp(t,e.raw)),o={...e,raw:em(e.raw,t)},a=mn(o),{show:n}=o.config,r=!0===o.raw.bar,i=o.raw.line_width??1.5,s="error"===a.status?R`
${_t(o.hass,"chart_error",{error:a.error??""})}
`:W,l=a.series.map((s,l)=>{const c=s.entity,d=c.config.color,u=Ce(d||c.band.color),h="column"===Kp(c,e.raw)&&void 0===o.raw.group_by,p={...s,color:u,points:h?(m=s.points,f=o.raw,g=a.range,v=o.hass,Va(m,pa(Qp(f),g.start,g.end,fa(v)),f.aggregate??"mean",Date.now())):s.points};var m,f,g,v;const y=c.config.line_width??i,b=n.name?R`${c.name}`:W,_=n.value||!c.available?R`${c.display}`:W;return R`
+ ${n.icon?R``:W} + ${r?R`
+
${b}${_}
+ ${function(e,t){const o=je(e.band.color);return R`
+
+ ${t&&null!==e.targetPosition&&e.available?R`
`:W} +
`}(c,n.target)} +
`:b} + + ${"ready"===a.status?tm(p,t[l],a.range,y):W} + + ${r?W:_} +
`});return R`${vn(a,o)}${s} +
${l}
`}},rm=["wiggle","centre","share","zero"],im=["inside_out","listed"];function sm(e){return e.map(e=>null===e||!Number.isFinite(e)||e<0?0:e)}function lm(e,t,o){const a="share"===o?function(e){const t=e[0]?.length??0,o=Array.from({length:t},(t,o)=>e.reduce((e,t)=>e+t[o],0));return e.map(e=>e.map((e,t)=>o[t]>0?e/o[t]*100:0))}(e):e,n=t.map(e=>a[e]);let r=function(e,t){const o=e[0]?.length??0,a=Array.from({length:o},(t,o)=>e.reduce((e,t)=>e+t[o],0));if("zero"===t||"share"===t)return new Array(o).fill(0);if("centre"===t)return a.map(e=>-e/2);const n=new Array(o).fill(0);for(let l=1;le+s)}(n,o);const i=new Array(e.length);for(const s of t){const e=r.map((e,t)=>e+a[s][t]);i[s]={lower:r,upper:e},r=e}return i}function cm(e){let t=0,o=-1/0;return e.upper.forEach((a,n)=>{const r=a-e.lower[n];r>o&&(o=r,t=n)}),{at:t,thickness:Math.max(0,o)}}function dm(e,t,o){const a=Math.min(e.range.end,t);if(e.group){const t=e.series[0]?.points??[],o=t.map((e,t)=>t).filter(e=>t[e].t(t[e].t+Math.min(t[e].end??t[e].t,a))/2),values:e.series.map(e=>o.map(t=>e.points[t]?.v??null))}}let n=Math.min(240,Math.max(24,Math.round(o/4)));if("statistics"===e.source){const t=Math.max(1,...e.series.map(e=>e.points.filter(e=>!e.carried).length));n=Math.min(n,t)}const r=function(e,t,o){const a=Math.max(e.start+1,Math.min(e.end,t)),n=Math.max(1,Math.round(o)),r=(a-e.start)/n,i=[];for(let s=0;s(e+r[t+1])/2),values:e.series.map(t=>function(e,t,o,a){return("statistics"===o?Ya(e,t,"mean",a):Va(e,t,"mean",a)).map(e=>e.v)}(t.points,r,e.source,a))}}function um(e){return e.baseline??"wiggle"}yt("en",{editor_style_streamgraph:"Streamgraph",editor_baseline:"Shape",editor_baseline_wiggle:"Streamgraph (flowing)",editor_baseline_centre:"Centred",editor_baseline_share:"Share of the total (100 %)",editor_baseline_zero:"Stacked from zero",editor_stream_order:"Order",editor_stream_order_inside_out:"Biggest in the middle",editor_stream_order_listed:"As listed, first at the bottom",editor_stream_labels:"Names on the streams",editor_stream_curve:"Edges",editor_fill_color:"Colour",stream_total:"Total"});const hm=e=>Math.round(1e4*e)/100+"%";function pm(e,t,o,a,n,r,i){const[s,l]=a.domain,c=qn(n.raw).compact?110:180,d=Pn(n),u=e=>e/(l-s)*c,h=new Map,p=[];for(const f of e.series){const e=t[f.index],{at:s,thickness:l}=cm(e);if(u(l)<18)continue;const m=i[f.index][s],g=r?`${kn(e.upper[s]-e.lower[s],0,n.hass)} %`:null===m?"":Mn(kn(m,f.entity.decimals,n.hass),f.entity.unit),v=`${f.entity.name} ${g}`.trim();h.set(f.index,v),p.push({key:f.index,x:o[s]/cn*d,y:a.y((e.upper[s]+e.lower[s])/2)/dn*c,width:6.2*v.length,thickness:u(l)})}const m=function(e,t){const o=[],a=new Map;for(const n of[...e].sort((e,t)=>t.thickness-e.thickness||e.key-t.key)){if(n.width>t-4)continue;const e=Math.min(Math.max(2,n.x-n.width/2),t-n.width-2),r={left:e,right:e+n.width,top:n.y-6.5,bottom:n.y+6.5},i=o.some(e=>r.lefte.left-4&&r.tope.top);i||(o.push(r),a.set(n.key,e))}return a}(p,d);return R``}function mm(e,t,o,a,n,r,i,s){if(void 0===i)return W;const l=ar(o.times.map(e=>({t:e})),i);if(l<0)return W;const c=r[l]/cn,d=tr(c,Pn(e)),u=[...n].reverse().map(e=>t.series[e]),h=a.map(e=>e.upper[l]-e.lower[l]),p=o.values.reduce((e,t)=>e+Math.max(0,t[l]??0),0),m=new Set(t.series.map(e=>e.entity.unit));return R``}const fm=[...xi,{name:"baseline",path:["baseline"],default:"wiggle"},{name:"stream_order",path:["stream_order"],default:"inside_out"},{name:"stream_curve",path:["curve"],default:"smooth"},{name:"stream_labels",path:["labels"],default:!0},...Si,...zi],gm=new Set(["y_axis","grid_horizontal","y_min","y_max"]);function vm(e){return e.map(e=>{const t=e;if(!gm.has(t.name)){if(t.schema){const e=t.schema.filter(e=>!gm.has(e.name));return e.length?{...t,schema:e}:void 0}return t}}).filter(e=>void 0!==e)}const ym={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]},bm={layout:"chart",group:"totals",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + .style-streamgraph .stream { + cursor: pointer; + stroke: var(--card-background-color, #fff); + stroke-width: 1px; + stroke-linejoin: round; + } + .stream-labels { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; + } + .stream-label { + position: absolute; + transform: translateY(-50%); + font-size: 11px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + color: #fff; + } + .stream-label.dark { + color: rgba(0, 0, 0, 0.8); + } + .stream-label.theme { + color: var(--primary-text-color); + } + .tooltip-row.total { + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + margin-top: 2px; + padding-top: 2px; + } + `,thumbnail:()=>Z` + + + `,validate(e){if(pn(e),Lo(e),lr(e),Zn(e),void 0!==e.baseline&&!rm.includes(e.baseline))throw new Error(`"baseline" must be one of: ${rm.join(", ")}`);if(void 0!==e.stream_order&&!im.includes(e.stream_order))throw new Error(`"stream_order" must be one of: ${im.join(", ")}`);if(void 0!==e.labels&&"boolean"!=typeof e.labels)throw new Error('"labels" must be true or false')},editor:function(e){const t=function(e){const t=um(e);return`${wi(e)}|${"wiggle"===t||"centre"===t?"floating":"axis"}`}(e.config),o=Fi(`streamgraph|${t}`,e.hass,()=>{const o=Ei(e.hass);return[...$i(e.hass,e.config),{type:"grid",name:"",schema:[{name:"baseline",selector:_i(e.hass,"baseline",rm)},{name:"stream_order",selector:_i(e.hass,"stream_order",im)}]},{type:"grid",name:"",schema:[{name:"stream_curve",selector:_i(e.hass,"curve",["smooth","straight","step"])},{name:"stream_labels",selector:{boolean:{}}}]},...t.endsWith("floating")?vm(o):o,...Ai(e.hass)]});return Pi(e,"chart",o,fm)},entityFields:()=>ym,disconnect(e){bn(e),Fn(e),ir(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n=_n(o,e),r=a===W&&n===W?W:R`${a}${n}`,i=xn(o,e),s=qn(e.raw);if(i)return Wn("over"===s.header?{...s,header:"above"}:s,r,i);const l=um(e.raw),c="share"===l,d="wiggle"===l||"centre"===l,u=dm(o,t,Pn(e)),h=u.values.map(sm),p="listed"===(e.raw.stream_order??"inside_out")?h.map((e,t)=>t):function(e){const t=e.map((e,t)=>{let o=0;for(let a=1;ae[o]&&(o=a);return{i:t,at:o,sum:e.reduce((e,t)=>e+t,0)}});t.sort((e,t)=>e.at-t.at||e.i-t.i);let o=0,a=0;const n=[],r=[];for(const i of t)ot?.04*(o-t):1;g={...e.raw,y_axis:!1,y_min:t-a,y_max:o+a,gridlines:{...e.raw.gridlines??{},horizontal:!1}}}else c?g={...e.raw,y_min:0,y_max:100}:void 0===e.raw.y_min&&(g={...e.raw,y_min:0});const v=Dn({...e,raw:g},o.range,f),{scales:y}=v,b=u.times.map(e=>y.x(e)),_=e.raw.curve??"smooth",x=Math.min(o.range.end,t),w=[y.x(o.range.start),...b,y.x(x)],$=0===u.times.length?Z``:Z`${p.map(e=>{const t=o.series[e];return Z`[t,o.y(e.upper[a])]),r=t.map((t,a)=>[t,o.y(e.lower[a])]).reverse();return 0===n.length?"":`${sn(n,a)}L${sn(r,a).slice(1)}Z`}((e=>({lower:[e.lower[0],...e.lower,e.lower[e.lower.length-1]],upper:[e.upper[0],...e.upper,e.upper[e.upper.length-1]]}))(m[e]),w,y,_)} + fill=${t.color} + >`})}`,k=sr(e.raw),M=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_streamgraph"),names:o.series.map(e=>e.entity.name).join(", ")}),z=u.times.map(e=>({t:e,v:0}));return Wn(s,r,In({axes:v,ctx:{...e},content:$,label:M,overlay:R`${"over"===s.header?Vn(r):W} + ${!1!==e.raw.labels&&u.times.length?pm(o,m,b,y,e,c,u.values):W} + ${k?mm(e,o,u,m,p,b,rr(e.host),c):W}`,hover:k?cr(e,o.range,[z]):void 0}))}};const _m=["columns","line"],xm=.7;function wm(e){return{last:14,unit:"day",...e.period??{}}}function $m(e){return e.group_by??"day"}function km(e,t=Date.now()){const o=$a(wm(e.raw)),a=e.raw.source??"statistics";const n=$m(e.raw),r=e.raw.aggregate??"mean",i=fa(e.hass),s=Ma(o,t,i,o.navigate?Aa(e.host,o):0),l=ua(n,s.start,i),c=pa(n,l,s.end,i),d={start:l,end:c[c.length-1]},u=Math.min(s.end,t),h=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),p=on(e.host,e.requestUpdate),m="statistics"===a?function(e,t,o){let a="hour";return"hour"===e?a="5minute":"day"!==e&&t.timeZone===o&&(a="day"),{period:a,types:["mean","min","max","state"],value:"mean"}}(n,i,e.hass.config?.time_zone):void 0,f=p.get(e.hass,h,{start:l,end:s.end},a,{live:s.live,statistics:m});let g=[];const v=e.items.map((e,t)=>{const o=e.config,n=f.data.get(Ra(h[t]))??[];let i;return 0===t?(g="statistics"===a?function(e,t,o){const a=[];let n=0;for(let r=0;r+1=o)continue;const n="number"==typeof a.max?Math.max(a.max,a.v):a.v,r="number"==typeof a.min?Math.min(a.min,a.v):a.v;l?(l.high=Math.max(l.high,n),l.low=Math.min(l.low,r),l.close=a.v):l={t:i,end:s,open:a.v,high:n,low:r,close:a.v}}a.push(l)}return a}(n,c,u):function(e,t,o){const[a,n,r,i]=["first","max","min","last"].map(a=>Va(e,t,a,o));return a.map((e,t)=>null===e.v||null===n[t].v||null===r[t].v||null===i[t].v?null:{t:e.t,end:e.end??e.t,open:e.v,high:n[t].v,low:r[t].v,close:i[t].v})}(n,c,u),i=g.map((e,t)=>({t:c[t],end:c[t+1],v:e?e.close:null}))):i=function(e,t,o,a,n){return"history"===a?Va(e,t,o,n):"sum"===o||"change"===o?Va(e,t,"change",n):Ya(e,t,o,n)}(n,c,r,a,u),{index:t,entity:e,points:i,color:Ce(o.color??(0===t?un[0]:"grey")),curve:"smooth",dash:!1}});return{status:f.status,error:f.error,range:d,period:o,source:a,series:v,live:s.live,back:s.back,zone:i,group:{unit:n,aggregate:r},candles:g,unit:n}}yt("en",{editor_style_candlestick:"Candlestick",editor_candle_by:"One candle per",editor_under_aggregate:"Entities underneath: combine readings with",editor_up_color:"Colour when it rose",editor_down_color:"Colour when it fell",editor_underneath:"Draw the other entities underneath as",editor_underneath_columns:"Columns",editor_underneath_line:"A line",candle_open:"Open",candle_high:"High",candle_low:"Low",candle_close:"Close",candle_week_of:"Week of {date}"});const Mm=e=>Math.round(1e4*e)/100+"%";function zm(e,t,o,a){if(void 0===a)return W;const n=t.series[0].points.map(e=>({t:(e.t+(e.end??e.t))/2})),r=ar(n,a);if(r<0)return W;const i=t.candles[r],s=o(n[r].t)/cn,l=tr(s,Pn(e)),c=t.series[0].entity,d=(t,o=c)=>Mn(kn(t,o.decimals,e.hass),o.unit),u=i?[[_t(e.hass,"candle_open"),d(i.open)],[_t(e.hass,"candle_high"),d(i.high)],[_t(e.hass,"candle_low"),d(i.low)],[_t(e.hass,"candle_close"),d(i.close)]]:[[c.name,"—"]];for(const h of t.series.slice(1)){const e=h.points[r]?.v;u.push([h.entity.name,null==e?"—":d(e,h.entity),h.color])}return R``}const Am=[...xi.map(e=>"period_last"===e.name?{...e,default:14}:"period_unit"===e.name?{...e,default:"day"}:"source"===e.name?{...e,default:"statistics"}:"group_by"===e.name?{name:"candle_by",path:["group_by"],default:"day"}:"aggregate"===e.name?{name:"under_aggregate",path:["aggregate"],default:"mean"}:e),{name:"up_color",path:["up_color"],default:"green"},{name:"down_color",path:["down_color"],default:"red"},{name:"underneath",path:["underneath"],default:"columns"},...Si,...zi];const Sm={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const Em={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + .candle { + cursor: pointer; + } + .candle .wick { + stroke-width: 1.5px; + } + .under-columns rect { + opacity: 0.45; + } + .under-line { + stroke-width: 2px; + stroke-linejoin: round; + opacity: 0.7; + } + `,thumbnail:()=>Z` + + + + + + + + + + `,validate(e){pn(e),Lo(e),lr(e),Zn(e),$a(wm(e));for(const t of["up_color","down_color"])if(void 0!==e[t]&&("string"!=typeof e[t]||""===e[t]))throw new Error(`"${t}" must be a colour name or CSS colour`);if(void 0!==e.underneath&&!_m.includes(e.underneath))throw new Error(`"underneath" must be one of: ${_m.join(", ")}`)},editor:function(e){const t=Fi(`candlestick|${e.config.period?.calendar?"calendar":"rolling"}`,e.hass,()=>{return[...(t=e.hass,o=e.config,$i(t,{...o,group_by:$m(o)}).map(e=>{const o=e;return o.schema?.some(e=>"group_by"===e.name)?{...o,schema:[{name:"candle_by",selector:_i(t,"group_by",Za)},{name:"under_aggregate",selector:_i(t,"aggregate",qa)}]}:e})),{type:"grid",name:"",schema:[{name:"up_color",selector:{text:{}}},{name:"down_color",selector:{text:{}}}]},{name:"underneath",selector:_i(e.hass,"underneath",_m)},...Ei(e.hass),...Ai(e.hass)];var t,o});return Pi(e,"chart",t,Am)},entityFields:()=>Sm,disconnect(e){bn(e),Fn(e),ir(e)},render(e){const t=km(e),o=vn(t,e),a=_n(t,e),n=o===W&&a===W?W:R`${o}${a}`,r=xn(t,e),i=qn(e.raw);if(r)return Wn("over"===i.header?{...i,header:"above"}:i,n,r);const s=Dn(e,t.range,function(e){let t=1/0,o=-1/0;for(const a of e)a&&(t=Math.min(t,a.low),o=Math.max(o,a.high));return t===1/0?void 0:[t,o]}(t.candles)),l=t.series.length>1;l&&(s.scales.y=$o(s.value.domain,[146,0]));const{x:c,y:d}=s.scales,u=Ce(e.raw.up_color??"green"),h=Ce(e.raw.down_color??"red"),p=t.series[0],m=t.candles.map(e=>{if(!e)return W;const t=c(e.t),o=c(e.end)-t,a=t+o/2,n=e.close>=e.open?u:h,r=d(Math.max(e.open,e.close)),i=Math.max(1.5,d(Math.min(e.open,e.close))-r);return Z` + + + `}),f=e.raw.underneath??"columns",g=Z`${l?function(e,t,o){const a=e.series.slice(1);return Z`${a.map((e,n)=>{const r=e.points.map(e=>e.v).filter(e=>null!==e),i=Math.min(0,...r),s=Math.max(0,...r),l=$o([i,s===i?i+1:s],[dn,156]);if("line"===o){const o=ln(e.points).map(e=>sn(e.map(e=>[t((e.t+(e.end??e.t))/2),l(e.v)]),"straight")).join("");return Z``}return Z` + ${e.points.map(e=>{if(null===e.v)return W;const o=t(e.t),r=t(e.end??e.t)-o,i=r*xm/a.length,s=o+r*(1-xm)/2+n*i,c=l(Math.max(0,e.v));return Z``})} + `})}`}(t,c,f):W}${m}`,v=sr(e.raw),y=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_candlestick"),names:t.series.map(e=>e.entity.name).join(", ")}),b=p.points.map(e=>({t:(e.t+(e.end??e.t))/2,v:e.v}));return Wn(i,n,In({axes:s,ctx:e,content:g,label:y,overlay:R`${"over"===i.header?Vn(n):W} + ${v?zm(e,t,c,rr(e.host)):W}`,hover:v?cr(e,t.range,[b]):void 0}))}},Cm=["horizontal","vertical"],jm=1e3,Nm=100,Lm=e=>"number"==typeof e&&Number.isFinite(e);yt("en",{editor_orientation:"Direction",editor_orientation_horizontal:"Across",editor_orientation_vertical:"Upright",editor_value_axis:"Value labels",editor_value_grid:"Lines at each value label",editor_value_min:"Lowest value shown",editor_value_max:"Highest value shown",dist_median:"Median",dist_middle:"Middle half",dist_range:"Lowest – highest",dist_count:"Readings"});const Tm=[{name:"orientation",path:["orientation"],default:"horizontal"},{name:"value_axis",path:["value_axis"],default:!0},{name:"value_grid",path:["value_grid"],default:!0},{name:"value_min",path:["value_min"]},{name:"value_max",path:["value_max"]},{name:"tooltip",path:["tooltip"],default:!0},{name:"compact",path:["compact"],default:!1}];function Om(e){return[{name:"orientation",selector:_i(e,"orientation",Cm)},{type:"grid",name:"",schema:[{name:"value_axis",selector:{boolean:{}}},{name:"value_grid",selector:{boolean:{}}},{name:"value_min",selector:{number:{mode:"box",step:"any"}}},{name:"value_max",selector:{number:{mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]}]}function Fm(e){for(const t of["compact","tooltip"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);if(void 0!==e.orientation&&!Cm.includes(e.orientation))throw new Error(`"orientation" must be one of: ${Cm.join(", ")}`);for(const t of["value_axis","value_grid"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);for(const t of["value_min","value_max"])if(void 0!==e[t]&&!Lm(e[t]))throw new Error(`"${t}" must be a number`);if(Lm(e.value_min)&&Lm(e.value_max)&&e.value_min>=e.value_max)throw new Error('"value_min" must be below "value_max"')}function Pm(e){return{orientation:e.orientation??"horizontal",axis:!1!==e.value_axis,grid:!1!==e.value_grid,min:Lm(e.value_min)?e.value_min:void 0,max:Lm(e.value_max)?e.value_max:void 0}}function Dm(e){return Math.min(140,Math.max(64,Math.round(.3*(e-32))))}function Um(e,t,o,a=64){const n=Pm(e.raw),r=!0===e.raw.compact,i=On(e),s=Math.max(1,t);if("horizontal"===n.orientation){const e=$o(o.domain,[0,jm]);return{orientation:n.orientation,count:s,width:jm,height:s*Nm,at:(t,o,a=0)=>[e(t),(o+.5+a)*Nm],valuePx:Math.max(40,i-32-Dm(i)-8),bandPx:Math.min(r?Math.round(.69*a):a,s>12?28:s>6?40:1/0)}}const l=$o(o.domain,[jm,0]),c=Math.max(40,i-32-(n.axis?40:0));return{orientation:n.orientation,count:s,width:s*Nm,height:jm,at:(e,t,o=0)=>[(t+.5+o)*Nm,l(e)],valuePx:r?110:180,bandPx:c/s}}function Hm(e,t){return"horizontal"===t?Math.max(2,Math.min(8,Math.floor(e/64))):e>140?4:3}function Im(e,t,o,a){const n=Pm(t),r=Do(e,{min:n.min,max:n.max},o);return{domain:r.domain,ticks:r.ticks,format:e=>kn(e,r.decimals,a)}}function Bm(e,t){const o=function(e){for(const t of[1,2,3,4,6,12])if(t>=e)return t;return Math.max(12,Oo(e,1))}(24/Math.max(1,e)),a=[];for(let i=0;i<=24;i+=o)a.push(i);let n;const r={hour:"numeric",minute:"2-digit",timeZone:"UTC",hour12:Go(t)};try{n=new Intl.DateTimeFormat(t?.locale?.language??t?.language??"en",r)}catch{n=new Intl.DateTimeFormat("en",r)}return{domain:[0,24],ticks:a,format:e=>n.format(Date.UTC(2e3,0,1,0,Math.round(60*e)))}}const Rm=e=>Math.round(1e4*e)/100+"%",Zm=new WeakMap;function qm(e){return Zm.get(e)}function Wm(e){Zm.delete(e)}function Vm(e,t,o,a,n){const r=(t+.5)/e.count,i="horizontal"===e.orientation?r>.5?`top:auto;bottom:${Rm(1-t/e.count)};right:0`:`top:${Rm((t+1)/e.count)};right:0`:r>.5?`top:0;right:${Rm(1-t/e.count)}`:`top:0;left:${Rm((t+1)/e.count)}`;return R``}function Ym({ctx:e,geometry:t,categories:o,scale:a,content:n,label:r,overlay:i=W,hover:s=!1}){const l=Pm(e.raw),c="horizontal"===t.orientation,d=a.ticks.filter(e=>e>=a.domain[0]&&e<=a.domain[1]),u=l.grid?Z`${d.map(e=>{const[o,a]=t.at(e,0);return c?Z``:Z``})}`:W,h=l.axis&&d.length?R`
+ ${c?W:R``} + ${d.map(e=>{const o=(e=>{const[o,a]=t.at(e,0);return c?o/t.width:a/t.height})(e);return R`.96?"end":"":o<.08?"top":""}" style="${c?"left":"top"}:${Rm(o)}" + >${a.format(e)}`})} +
`:W,p=s?function(e,t){const o=t=>{Zm.get(e.host)!==t&&(void 0===t?Zm.delete(e.host):Zm.set(e.host,t),e.requestUpdate())};return{move(e){const a=e.currentTarget.getBoundingClientRect(),n="horizontal"===t.orientation,r=n?a.height:a.width,i=n?e.clientY-a.top:e.clientX-a.left;r>0&&o(Math.min(t.count-1,Math.max(0,Math.floor(i/r*t.count))))},leave(){o(void 0)}}}(e,t):void 0,m=R`
+ ${o.map(e=>R`
+ ${e.name} + ${e.detail?R`${e.detail}`:W} +
`)} +
`,f=c?`height:${t.count*t.bandPx}px`:`height:${t.valuePx}px`,g=c&&t.bandPx<40;return R`
+ ${c?m:h} +
+ + ${u} + ${n} + + ${i} +
+ ${c?h:m} +
`}const Gm=r` + .cat-plot { + display: grid; + gap: 0 8px; + } + .cat-plot.horizontal { + grid-template-columns: clamp(64px, 30%, 140px) minmax(0, 1fr); + } + .cat-plot.vertical { + grid-template-columns: auto minmax(0, 1fr); + } + .cat-plot .plot-area { + position: relative; + min-width: 0; + } + .cat-plot.horizontal .plot-area, + .cat-plot.vertical .plot-area { + grid-column: 2; + grid-row: 1; + } + .cat-plot .plot { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .cat-plot .plot path, + .cat-plot .plot line, + .cat-plot .plot rect { + vector-effect: non-scaling-stroke; + } + .cat-plot .grid line { + stroke: var(--divider-color, rgba(127, 127, 127, 0.25)); + stroke-width: 1; + } + .cat-content { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .cat-content { + opacity: 1; + } + :host([static]) .cat-content { + transition: none; + } + .cat-labels { + display: flex; + min-width: 0; + } + .horizontal .cat-labels { + grid-column: 1; + grid-row: 1; + flex-direction: column; + } + .vertical .cat-labels { + grid-column: 2; + grid-row: 2; + padding-top: 4px; + } + .cat-label { + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; + flex: 1 1 0; + cursor: pointer; + border-radius: 4px; + } + .vertical .cat-label { + align-items: center; + text-align: center; + } + .cat-label:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } + .cat-name, + .cat-detail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + .cat-name { + font-size: 0.875em; + } + .cat-name .swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + vertical-align: middle; + } + .cat-detail { + font-size: 0.75em; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .frame.compact .cat-detail, + .cat-plot.dense .cat-detail { + display: none; + } + .cat-axis { + position: relative; + } + .horizontal .cat-axis { + grid-column: 2; + grid-row: 2; + height: 18px; + } + .horizontal .cat-axis .axis-label { + top: 5px; + transform: translateX(-50%); + } + .horizontal .cat-axis .axis-label.start { + transform: none; + } + .horizontal .cat-axis .axis-label.end { + transform: translateX(-100%); + } + .vertical .cat-axis { + grid-column: 1; + grid-row: 1; + min-width: 24px; + } + .vertical .cat-axis .axis-label { + right: 0; + transform: translateY(-50%); + } + .vertical .cat-axis .axis-label.top { + transform: none; + } + .cat-plot .axis-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + font-variant-numeric: tabular-nums; + } + .cat-sizer { + display: block; + height: 0; + overflow: hidden; + visibility: hidden; + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .band-tooltip { + position: absolute; + min-width: 120px; + } + .band-tooltip .tooltip-time { + display: flex; + align-items: center; + gap: 6px; + } + .band-tooltip .swatch { + width: 8px; + height: 8px; + border-radius: 50%; + } +`;function Xm(e,t,o){const a=[];for(let n=0;n0&&n===e.length-1&&r.t>=o)continue;const s=t?Math.max(1,Math.min(i,o)-r.t):1;a.push({t:r.t,v:r.v,w:s,real:!0!==r.carried})}return a}function Jm(e){return[...e].sort((e,t)=>e.v-t.v)}function Km(e,t){const o=e.length;if(0===o)return Number.NaN;if(1===o)return e[0].v;const a=e.reduce((e,t)=>e+t.w,0),n=Math.min(1,Math.max(0,t))*a;let r=0,i=-1/0,s=e[0].v;for(const l of e){const e=r+l.w/2;if(n<=e)return i===-1/0?l.v:s+(l.v-s)*(n-i)/(e-i||1);r+=l.w,i=e,s=l.v}return e[o-1].v}function Qm(e){if(0===e.length)return;const t=Jm(e),o=Km(t,.25),a=Km(t,.5),n=Km(t,.75),r=1.5*(n-o),i=t.filter(e=>e.v>=o-r&&e.v<=n+r),s=t.reduce((e,t)=>e+t.w,0);return{n:e.length,min:t[0].v,max:t[t.length-1].v,q1:o,median:a,q3:n,low:i.length?i[0].v:o,high:i.length?i[i.length-1].v:n,mean:s>0?t.reduce((e,t)=>e+t.v*t.w,0)/s:a}}function ef(e){const t=e.length;if(0===t)return 1;const o=Jm(e),a=o.reduce((e,t)=>e+t.w,0),n=o.reduce((e,t)=>e+t.v*t.w,0)/a,r=Math.sqrt(o.reduce((e,t)=>e+t.w*(t.v-n)**2,0)/a),i=Km(o,.75)-Km(o,.25),s=i>0?Math.min(r,i/1.34):r,l=o.reduce((e,t)=>e+t.w*t.w,0),c=l>0?a*a/l:t;let d=1/0;for(let h=1;h0&&e0?Math.max(u,d===1/0?0:d):d!==1/0?d:.01*Math.abs(n)||1}function tf(e,t,o,a=64){if(0===e.length)return[];let n=1/0,r=-1/0;for(const u of e)n=Math.min(n,u.v),r=Math.max(r,u.v);const i=Math.max(o[0],n-2*t),s=Math.min(o[1],r+2*t);if(!(s>i))return[];const l=Array.from({length:a+1},(e,t)=>i+(s-i)*t/a),c=function(e,t,o){const a=e.reduce((e,t)=>e+t.w,0);if(0===a||t<=0)return o.map(()=>0);const n=1/(a*t*Math.sqrt(2*Math.PI));return o.map(o=>{let a=0;for(const n of e){const e=(o-n.v)/t;e>-6&&e<6&&(a+=n.w*Math.exp(-.5*e*e))}return a*n})}(e,t,l),d=Math.max(...c);return l.map((e,t)=>({x:e,d:d>0?c[t]/d:0}))}function of(e){const t=43758.5453*Math.sin(12.9898*(e+1));return t-Math.floor(t)-.5}function af(e,t){const o=e.filter(e=>e.real);if(o.length<=t)return o;const a=[];for(let n=0;ne?Mn(kn(e.median,t.entity.decimals,o),t.entity.unit):"";function uf(e,t,o,a,n,r,i){const s=(e,t)=>e?_t(i,"violin_median",{value:df(e,t,i)}):_t(i,"violin_none");if("none"===o.split)return e.map((e,o)=>{const a=Qm(t[o]);return{series:e,readings:t[o],summary:a,name:e.entity.name,detail:s(a,e)}});const l=e.map((e,o)=>function(e,t,o){const a=t.slice(0,-1).map(()=>[]);for(const n of e)for(let e=0;e+1=r&&n.t=r})}return a}(t[o],a,n)),c=[];for(let d=0;d+11?`${h} · ${e[t].entity.name}`:h,detail:s(u,e[t])})}return c}function hf(e,t,o,a,n=0,r=.42){if(0===e.length)return"";const i=e.map(e=>t.at(e.x,o,n-e.d*r)),s=e.map(e=>t.at(e.x,o,a?n:n+e.d*r));return function(e,t){const o=e=>Math.round(100*e)/100;return`${[...e,...[...t].reverse()].map(([e,t],a)=>`${a?"L":"M"}${o(e)},${o(t)}`).join("")}Z`}(i,s)}function pf(e,t,o,a,n){const{summary:r,series:i}=e;if(!r)return Z``;const s=a.bandwidth??ef(e.readings),l=tf(e.readings,s,n),c="raincloud"===a.shape,d=Math.min(1,110/o.bandPx),u=Z``,h=c?.08*d:0,p=function([e,t],[o,a]){return{x:Math.min(e,o),y:Math.min(t,a),width:Math.abs(o-e),height:Math.abs(a-t)}}(o.at(r.q1,t,h-.045*d),o.at(r.q3,t,h+.045*d)),[m,f]=o.at(r.low,t,h),[g,v]=o.at(r.high,t,h),y=a.box?Z` + + + + `:W,b=a.readings?Z`o.at(e.v,t,d*function(e,t,o){return o?.3+.22*of(e):1.6*of(e)*sf*t}(a,function(e,t){if(0===e.length)return 0;let o=0;for(;o`:W;return Z`${u}${y}${b}`}const mf=(e,t,o)=>{const a=e=>kn(e,t.entity.decimals,o),n=e=>Mn(e,t.entity.unit);return[[_t(o,"dist_median"),n(a(e.median))],[_t(o,"dist_middle"),n(`${a(e.q1)} – ${a(e.q3)}`)],[_t(o,"dist_range"),n(`${a(e.min)} – ${a(e.max)}`)],[_t(o,"dist_count"),kn(e.n,0,o)]]},ff=[...xi,{name:"shape",path:["shape"],default:"violin"},{name:"box",path:["box"],default:!0},{name:"split_by",path:["split_by"],default:"none"},{name:"bandwidth",path:["bandwidth"]},...Tm],gf=new Map;const vf={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const yf={layout:"chart",group:"distribution",height:240,styles:r` + ${$n} + ${mr} + ${Yn} + ${Gm} + .style-violin .shape { + fill-opacity: 0.35; + stroke-width: 1.5px; + stroke-linejoin: round; + } + .style-violin .whisker { + stroke-width: 1.5px; + } + .style-violin .median { + stroke: var(--card-background-color, #fff); + stroke-width: 6px; + stroke-linecap: round; + } + .style-violin .dots { + fill: none; + stroke-width: 4px; + stroke-linecap: round; + stroke-opacity: 0.7; + } + `,thumbnail:()=>Z` + + + + + + `,validate(e){if(pn(e),lr(e),Fm(e),void 0!==e.shape&&!nf.includes(e.shape))throw new Error(`"shape" must be one of: ${nf.join(", ")}`);if(void 0!==e.split_by&&!rf.includes(e.split_by))throw new Error(`"split_by" must be one of: ${rf.join(", ")}`);for(const o of["box","readings"])if(void 0!==e[o]&&"boolean"!=typeof e[o])throw new Error(`"${o}" must be true or false`);const t=e.bandwidth;if(void 0!==t&&!("number"==typeof t&&Number.isFinite(t)&&t>0))throw new Error('"bandwidth" must be a number above 0')},editor:function(e){const t=lf(e.config),o=Fi(`violin|${wi(e.config)}`,e.hass,()=>[...$i(e.hass,e.config),{type:"grid",name:"",schema:[{name:"shape",selector:_i(e.hass,"shape",nf)},{name:"split_by",selector:_i(e.hass,"split_by",rf)}]},{type:"grid",name:"",schema:[{name:"box",selector:{boolean:{}}},{name:"readings",selector:{boolean:{}}}]},{name:"bandwidth",selector:{number:{min:0,mode:"box",step:"any"}}},...Om(e.hass)]);return Pi(e,"chart",o,function(e){let t=gf.get(e);return t||(t=[...ff,{name:"readings",path:["readings"],default:"raincloud"===e}],gf.set(e,t)),t}(t.shape))},entityFields:()=>vf,disconnect(e){bn(e),Fn(e),Wm(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n={...qn(e.raw),header:"above"},r=xn(o,e);if(r)return Wn(n,a,r);const i=lf(e.raw),s=Math.min(o.range.end,t),l="history"===o.source&&!o.group,c=o.series.map(e=>Xm(e.points,l,s)),d="none"===i.split?[]:pa(i.split,o.range.start,s,o.zone),u=uf(o.series,c,i,d,l,o.zone,e.hass);let h=1/0,p=-1/0;for(const k of u){if(!k.summary)continue;const e=i.bandwidth??ef(k.readings);h=Math.min(h,k.summary.min-e),p=Math.max(p,k.summary.max+e)}const m=Pm(e.raw),f=Um(e,u.length,{domain:[0,1]}),g=Im(h===1/0?void 0:[h,p],e.raw,Hm(f.valuePx,m.orientation),e.hass),v=Um(e,u.length,g),y=Z`${u.map((e,t)=>pf(e,t,v,i,g.domain))}`,b=u.map(e=>({index:e.series.index,name:e.name,detail:e.detail,color:e.series.color,label:`${e.name}, ${e.detail}`})),_=sr(e.raw),x=_?qm(e.host):void 0,w=void 0===x?void 0:u[x],$=_t(e.hass,"chart_aria",{style:_t(e.hass,"raincloud"===i.shape?"violin_raincloud":"editor_style_violin"),names:o.series.map(e=>e.entity.name).join(", ")});return Wn(n,a,Ym({ctx:e,geometry:v,categories:b,scale:g,content:y,label:$,hover:_,overlay:w?.summary&&void 0!==x?Vm(v,x,w.name,mf(w.summary,w.series,e.hass),w.series.color):W}))}},bf=.75;function _f(e){const t=e.map((e,t)=>t).sort((t,o)=>e[t].at-e[o].at||t-o),o=new Array(e.length).fill(0),a=[];let n=0;const r=function(e){let t=0;for(const o of e)o.r>t&&(t=o.r);return t}(e);for(const i of t){const{at:t,r:s}=e[i];for(;ns+r+bf;)n++;const l=a.slice(n).filter(e=>Math.abs(e.at-t)Math.abs(e)-Math.abs(t)||t-e).find(e=>l.every(o=>(o.at-t)**2+(o.y-e)**2>=(o.r+s+bf)**2-1e-6)),u=d??0;o[i]=u,a.push({at:t,r:s,y:u})}return o}function xf(e,t,o=1){let a=1,n=_f(e);const r=t=>function(e,t){let o=0;return e.forEach((e,a)=>{o=Math.max(o,Math.abs(t[a])+e.r)}),o}(e.map(e=>({...e,r:e.r*t})),n),i=Math.min(...e.map(e=>e.r)),s=Math.min(1,o/Math.max(i,1e-9));for(let l=0;l<4;l++){const o=r(a);if(o<=t)return{offsets:n,scale:a};const i=Math.max(s,a*Math.sqrt(t/o)*.95);if(i>=a)break;a=i,n=_f(e.map(e=>({...e,r:e.r*a})))}if(r(a)>t){const o=Math.max(0,t-Math.max(...e.map(e=>e.r*a))),r=Math.max(...n.map(Math.abs));r>0&&(n=n.map(e=>e*o/r))}return{offsets:n,scale:a}}const wf=["value","time_of_day"];function $f(e){return{placeBy:e.place_by??"value",dot:"number"==typeof e.dot_size?e.dot_size:6,sized:!0===e.size_by_value}}yt("en",{editor_style_beeswarm:"Beeswarm",editor_place_by:"Place each dot by",editor_place_by_value:"Its value",editor_place_by_time_of_day:"The time of day it happened",editor_dot_size:"Dot size (px)",editor_size_by_value:"Bigger dots for bigger values",swarm_readings:"{n} readings",swarm_events:"{n} times on",swarm_shown:"Dots shown",swarm_none:"No readings",swarm_earliest_latest:"Earliest – latest"});const kf=/^(binary_sensor|switch|input_boolean|light|fan|siren|automation)\./;function Mf(e,t,o){const a=Math.min(e.range.end,o);return e.series.map(o=>{const n="time_of_day"===t.placeBy&&kf.test(o.entity.entityId);let r;r=n?function(e){const t=[];let o=null;for(const a of e)null!==a.v&&(1!==a.v||1===o||a.carried||t.push(a),o=a.v);return t}(o.points).map(e=>({t:e.t,v:1,w:1,real:!0})):Xm(o.points,"history"===e.source&&!e.group,a).filter(e=>e.real);const i=af(r,400);return{series:o,positions:i.map(o=>"time_of_day"===t.placeBy?function(e,t){const o=sa(e,t);return o.hour+o.minute/60+o.second/3600}(o.t,e.zone.timeZone):o.v),values:i.map(e=>e.v),total:r.length,events:n}})}function zf(e,t,o,a,n){if(0===e.positions.length)return Z``;const r=n.dot/2,i=a.domain[1]-a.domain[0]||1,s=Math.min(...e.values),l=Math.max(...e.values),c=e.values.map(t=>{if(!n.sized||e.events)return r;const o=function(e,t,o,a){if(!(o>t))return a;const n=Math.min(1,Math.max(0,(e-t)/(o-t)));return a*Math.sqrt(.12+.88*n)}(t,s,l,r);return Math.max(.5,Math.round(o/r*6)/6)*r}),d=e.positions.map((e,t)=>({at:(e-a.domain[0])/i*o.valuePx,r:c[t]})),{offsets:u,scale:h}=xf(d,o.bandPx/2-1,1),p=new Map;return d.forEach((a,n)=>{const r=Math.round(a.r*h*2*100)/100,i=p.get(r)??[];i.push(o.at(e.positions[n],t,u[n]/o.bandPx)),p.set(r,i)}),Z` + ${[...p].map(([e,t])=>Z``)} + `}function Af(e,t,o,a){const n=Qm(e.positions.map((e,t)=>({t:t,v:e,w:1,real:!0})));if(!n)return[];const r=n=>o?t.format(n):Mn(kn(n,e.series.entity.decimals,a),e.series.entity.unit);return[[_t(a,"dist_count"),kn(e.total,0,a)],...e.positions.length{const t=e;return t.schema?{...t,schema:t.schema.filter(e=>"value_min"!==e.name&&"value_max"!==e.name)}:e})}const Cf={schema:[{name:"fill_color",selector:{text:{}}}],fields:[{name:"fill_color",path:["color"]}]};yt("en",{editor_fill_color:"Colour"});const jf={layout:"chart",group:"distribution",height:260,styles:r` + ${$n} + ${mr} + ${Yn} + ${Gm} + .swarm .dots { + fill: none; + stroke-linecap: round; + stroke-opacity: 0.85; + } + `,thumbnail:()=>Z` + + + + + + + + + + `,validate(e){if(pn(e),Fm(e),void 0!==e.place_by&&!wf.includes(e.place_by))throw new Error(`"place_by" must be one of: ${wf.join(", ")}`);const t=e.dot_size;if(void 0!==t&&!("number"==typeof t&&t>=2&&t<=16))throw new Error('"dot_size" must be a number from 2 to 16');if(void 0!==e.size_by_value&&"boolean"!=typeof e.size_by_value)throw new Error('"size_by_value" must be true or false')},editor:function(e){const t=$f(e.config),o=Fi(`beeswarm|${wi(e.config)}|${t.placeBy}`,e.hass,()=>[...$i(e.hass,e.config),{name:"place_by",selector:_i(e.hass,"place_by",wf)},{type:"grid",name:"",schema:[{name:"dot_size",selector:{number:{min:2,max:16,step:1,mode:"box",unit_of_measurement:"px"}}},{name:"size_by_value",selector:{boolean:{}}}]},..."value"===t.placeBy?Om(e.hass):Ef(Om(e.hass))]);return Pi(e,"chart",o,Sf)},entityFields:()=>Cf,disconnect(e){bn(e),Fn(e),Wm(e)},render(e){const t=Date.now(),o=mn(e,t),a=vn(o,e),n={...qn(e.raw),header:"above"},r=xn(o,e);if(r)return Wn(n,a,r);const i=$f(e.raw),s=Mf(o,i,t),l=Pm(e.raw),c=Hm(Um(e,s.length,{domain:[0,1]},96).valuePx,l.orientation);let d;if("time_of_day"===i.placeBy)d=Bm(c,e.hass);else{const t=s.flatMap(e=>e.positions);d=Im(t.length?[Math.min(...t),Math.max(...t)]:void 0,e.raw,c,e.hass)}const u=Um(e,s.length,d,96),h=Z`${s.map((e,t)=>zf(e,t,u,d,i))}`,p=s.map(t=>{const o=function(e,t){if(0===e.total)return _t(t,"swarm_none");const o=kn(e.total,0,t);return _t(t,e.events?"swarm_events":"swarm_readings",{n:o})}(t,e.hass);return{index:t.series.index,name:t.series.entity.name,detail:o,color:t.series.color,label:`${t.series.entity.name}, ${o}`}}),m=sr(e.raw),f=m?qm(e.host):void 0,g=void 0===f?void 0:s[f],v=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_beeswarm"),names:o.series.map(e=>e.entity.name).join(", ")});return Wn(n,a,Ym({ctx:e,geometry:u,categories:p,scale:d,content:h,label:v,hover:m,overlay:g&&g.total>0&&void 0!==f?Vm(u,f,g.series.entity.name,Af(g,d,"time_of_day"===i.placeBy,e.hass),g.series.color):W}))}};function Nf(e,t,o){const a=new Set,n=[t];for(;n.length;){const t=n.pop();if(t===o)return!0;a.has(t)||(a.add(t),n.push(...e.get(t)??[]))}return!1}function Lf(e){const t=new Map,o=[];for(const a of e)a.from&&a.to&&a.from!==a.to&&(Nf(t,a.to,a.from)||(t.set(a.from,[...t.get(a.from)??[],a.to]),o.push(a)));return o}function Tf(e){return null!==e&&Number.isFinite(e)&&e>0?e:0}function Of(e,t){const o=Lf(e),a=function(e){const t=new Map;for(const o of e)t.set(o.from,t.get(o.from)??0),t.set(o.to,t.get(o.to)??0);for(let o=0;o<=t.size;o++){let o=!1;for(const a of e){const e=t.get(a.from)+1;e>t.get(a.to)&&(t.set(a.to,e),o=!0)}if(!o)break}return t}(o),n=new Map;for(const b of o)n.has(b.from)||n.set(b.from,n.size),n.has(b.to)||n.set(b.to,n.size);const r=o.filter(e=>Tf(e.value)>0),i=new Map,s=e=>{let t=i.get(e);return t||(t={name:e,order:n.get(e),column:a.get(e),value:0,in:0,out:0,x:0,y:0,h:0},i.set(e,t)),t},l=r.map(e=>{const t=Tf(e.value),o=s(e.from),a=s(e.to);return o.out+=t,a.in+=t,{flow:e,value:t,source:o,target:a,width:0,y0:0,y1:0}}),c=[...i.values()].sort((e,t)=>e.order-t.order);for(const b of c)b.value=Math.max(b.in,b.out);const d=[...new Set(c.map(e=>e.column))].sort((e,t)=>e-t);for(const b of c)b.column=d.indexOf(b.column);const u=d.length;if(0===u)return{nodes:c,links:l,columns:u};const h=d.map((e,t)=>c.filter(e=>e.column===t)),p=e=>t.above?.(e,u)??0,m=h.map(e=>e.reduce((e,t)=>e+p(t),0)+t.gap*(e.length-1)),f=h.map(e=>e.reduce((e,t)=>e+t.value,0)),g=Math.max(0,Math.min(...h.map((e,o)=>(t.height-m[o])/f[o]))),v=t.width-t.left-t.right-t.nodeWidth;h.forEach((e,o)=>{let a=(t.height-(f[o]*g+m[o]))/2;const n=t.left+(u>1?v*o/(u-1):v/2);for(const r of e)a+=p(r),r.x=n,r.y=a,r.h=r.value*g,a+=r.h+t.gap});const y=e=>e.y+e.h/2;for(const b of l)b.width=b.value*g;for(const b of c){const e=l.filter(e=>e.source===b).sort((e,t)=>y(e.target)-y(t.target));let t=b.y;for(const a of e)a.y0=t+a.width/2,t+=a.width;const o=l.filter(e=>e.target===b).sort((e,t)=>y(e.source)-y(t.source));t=b.y;for(const a of o)a.y1=t+a.width/2,t+=a.width}return{nodes:c,links:l,columns:u}}function Ff(e,t){return e.length*t*.58}function Pf(e,t,o){if(Ff(e,o)<=t)return e;const a=Math.floor(t/(.58*o))-1;return a>=1?`${e.slice(0,a).trimEnd()}…`:""}function Df(e,t,o){const a=e.map((e,t)=>t).sort((t,o)=>e[t]-e[o]),n=[...e];let r=-1/0;for(const s of a)n[s]=Math.max(n[s],r+t,t/2),r=n[s];let i=1/0;for(const s of[...a].reverse())n[s]=Math.min(n[s],i-t,o-t/2),i=n[s];return n}yt("en",{editor_style_sankey:"Sankey (flow)",editor_link_color:"Colour flows by",editor_link_color_auto:"Where they come from, or where they go after a middle node",editor_link_color_source:"Where they come from",editor_link_color_target:"Where they go",editor_from:"Flows from",editor_to:"Flows to",editor_flow_color:"Flow colour",sankey_aria:"Flow chart: {flows}",sankey_flow:"{from} to {to} {value}",sankey_no_flows:'Give each entity a "Flows from" and a "Flows to" in its options.',sankey_nothing:"Nothing is flowing right now."});const Uf=["auto","source","target"],Hf=10,If=e=>"string"==typeof e?e.trim():"";const Bf=new WeakMap,Rf=(e,t)=>e.column>0&&e.column0===e.column?"left":Rf(e,t)&&e.in>0&&e.out>0?"above":"right";function qf(e,t){return R`
${_t(t,e)}
`}const Wf=[{name:"link_color",path:["link_color"],default:"auto"}];const Vf=[{name:"from",path:["from"],default:""},{name:"to",path:["to"],default:""},{name:"flow_color",path:["color"]}],Yf=[{type:"grid",name:"",schema:[{name:"from",selector:{text:{}}},{name:"to",selector:{text:{}}}]},{name:"flow_color",selector:{text:{}}}];const Gf={layout:"chart",group:"totals",height:220,styles:r` + ${$n} + ${mr} + .sankey-wrap { + position: relative; + } + .sankey { + display: block; + width: 100%; + height: auto; + overflow: visible; + } + .sankey .link { + fill-opacity: 0.35; + transition: + fill-opacity 0.15s ease, + opacity var(--cd-duration) var(--cd-easing); + } + .sankey.dark .link { + fill-opacity: 0.45; + } + .sankey.hovering .link { + fill-opacity: 0.2; + } + .sankey .flow:hover .link, + .sankey .flow:focus-visible .link, + .sankey .flow.active .link { + fill-opacity: 0.75; + } + .sankey .flow { + cursor: pointer; + outline: none; + } + .sankey .flow:focus-visible { + box-shadow: none; + } + .sankey .flow:focus-visible .link { + fill-opacity: 0.85; + } + :host(:not([ready])) .sankey .link { + opacity: 0; + } + .sankey .node { + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host(:not([ready])) .sankey .node { + opacity: 0; + } + :host([static]) .sankey .node { + transition: none; + } + .sankey .label { + pointer-events: none; + } + .sankey .name { + fill: var(--primary-text-color); + } + .sankey .value { + fill: var(--secondary-text-color); + } + .sankey-tip { + pointer-events: none; + white-space: nowrap; + max-width: none; + transform: translate(-50%, calc(-100% - 6px)); + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate:function(e){if(void 0!==e.link_color&&!Uf.includes(e.link_color))throw new Error(`"link_color" must be one of: ${Uf.join(", ")}`);const t=[];(e.entities??[]).forEach((e,o)=>{if("object"!=typeof e||null===e)return;const a=e;for(const t of["from","to"])if(void 0!==a[t]&&"string"!=typeof a[t])throw new Error(`Entity ${o+1}: "${t}" must be a node name`);const n=If(a.from),r=If(a.to);if(n&&r&&n===r)throw new Error(`Entity ${o+1}: "from" and "to" must be different nodes`);n&&r&&t.push([n,r])});const o=function(e){const t=new Map;for(const[r,i]of e){if(r===i)return[r,i];t.set(r,[...t.get(r)??[],i])}const o=new Map,a=[],n=e=>{if("open"===o.get(e))return[...a.slice(a.indexOf(e)),e];if("done"!==o.get(e)){o.set(e,"open"),a.push(e);for(const o of t.get(e)??[]){const e=n(o);if(e)return e}a.pop(),o.set(e,"done")}};for(const r of t.keys()){const e=n(r);if(e)return e}}(t);if(o)throw new Error(`Flows can't go round in a loop: ${o.join(" → ")}`)},editor:function(e){const t=Fi("sankey",e.hass,()=>[{name:"link_color",selector:_i(e.hass,"link_color",Uf)}]);return Pi(e,"chart",t,Wf)},entityFields:()=>({schema:Yf,fields:Vf}),disconnect(e){Fn(e),Bf.delete(e)},render:function(e){const t=function(e){return e.items.flatMap((e,t)=>{const o=e.config,a=If(o.from),n=If(o.to);return a&&n?[{index:t,from:a,to:n,value:e.available?e.value:null}]:[]})}(e);if(!t.length)return qf("sankey_no_flows",e.hass);const o=e.items[t[0].index],a=t=>Mn(kn(t,o.decimals,e.hass),o.unit),n=Math.max(200,On(e)-32),{show:r}=e.config,i=function(e,t,o){const a=t<400?11:12,n=a+3,r=Of(e,{width:t,height:200,left:0,right:0,nodeWidth:Hf,gap:12}),i=r.columns,s=new Map;for(const b of r.nodes)s.set(b.column,(s.get(b.column)??0)+1);const l=Math.max(200,44*Math.max(0,...s.values())),c=.3*t,d=e=>({name:o.name?e.name:"",value:o.value?o.format(e.value):""}),u=e=>{const t=r.nodes.filter(t=>Zf(t,i)===e&&!Rf(t,i)).map(e=>Math.max(Ff(d(e).name,a),Ff(d(e).value,a)));return t.length&&Math.max(...t)>0?Math.min(c,Math.max(...t))+6+3:2},h=u("left"),p=u("right"),m=r.nodes.some(e=>"above"===Zf(e,i)),f=m&&(o.name||o.value)?20:0,g=Of(e,{width:t,height:l-f,left:h,right:p,nodeWidth:Hf,gap:12,above:(e,t)=>"above"===Zf(e,t)&&f?20:0});for(const b of g.nodes)b.y+=f;for(const b of g.links)b.y0+=f,b.y1+=f;const v=i>1?(t-h-p-Hf)/(i-1):t,y=[];if(o.name||o.value){for(const o of g.nodes){const e=Zf(o,i),n=d(o);if("above"===e){const r=o.x+5,i=Math.min(1.6*v,2*r,2*(t-r)),s=Ff(`${n.name} ${n.value}`,a)<=i||!n.name?n.value:"",l=Pf(n.name,s?i-Ff(` ${s}`,a):i,a);y.push({node:o,side:e,name:l,value:s,y:o.y-6});continue}const r=Rf(o,i)?v-Hf-12:("left"===e?h:p)-6-2,s=Ff(n.value,a)<=r?n.value:"";y.push({node:o,side:e,name:Pf(n.name,r,a),value:s,y:o.y+o.h/2})}const e=o.name&&o.value?2*n:n;for(let t=0;t"above"!==e.side&&e.node.column===t);Df(o.map(e=>e.y),e,l).forEach((e,t)=>o[t].y=e)}}return{width:t,height:l,font:a,layout:g,labels:y}}(t,n,{name:r.name,value:r.value,format:a}),{layout:s}=i;if(!s.links.length)return qf("sankey_nothing",e.hass);const l=function(e,t,o){const a=Lf(e),n=new Set(a.map(e=>e.to)),r=new Map;for(const c of a)for(const e of[c.from,c.to])r.has(e)||r.set(e,"");const i=e=>"target"===t||"auto"===t&&n.has(e.from)?e.to:e.from,s=new Set;for(const c of a){const e=o(c.index);e&&!s.has(i(c))&&(r.set(i(c),Ce(e)),s.add(i(c)))}[...r.keys()].forEach((e,t)=>{s.has(e)||r.set(e,Ce(un[t%un.length]))});const l=new Map;for(const c of a){const e=o(c.index);l.set(c.index,e?Ce(e):r.get(i(c)))}return{node:r,link:l}}(t,e.raw.link_color??"auto",t=>{const o=e.items[t].config.color;return"string"==typeof o&&o?o:void 0}),c=Bf.get(e.host),d=t=>()=>{void 0===t?Bf.delete(e.host):Bf.set(e.host,t),e.requestUpdate()},u=!0===e.hass.themes?.darkMode,h=t=>_t(e.hass,"sankey_flow",{from:t.flow.from,to:t.flow.to,value:a(t.value)}),p=_t(e.hass,"sankey_aria",{flows:s.links.map(h).join(", ")}),m=s.links.find(e=>e.flow.index===c),f=s.links.map(t=>{const o=e.items[t.flow.index],a=Ae(o.config);return Z` + ${h(t)} + Math.round(10*e)/10,[s,l,c,d]=[e.y0-r,e.y1-r,e.y1+r,e.y0+r].map(i);return`M${i(o)},${s}C${i(n)},${s} ${i(n)},${l} ${i(a)},${l}L${i(a)},${c}C${i(n)},${c} ${i(n)},${d} ${i(o)},${d}Z`}(t,Hf)} fill=${l.link.get(t.flow.index)}> + `}),g=s.nodes.map(e=>Z``);return R`
+ + ${f} + ${g} + + + ${function(e,t,o,a){if(!e)return W;const n=(e.source.x+Hf+e.target.x)/2,r=(e.y0+e.y1)/2-e.width/2,i=Math.min(Math.max(n,70),t.width-70);return R``}(m,i,m?l.link.get(m.flow.index):"",m?a(m.value):"")} +
`}},Xf=new Set(["unavailable","unknown"]);function Jf(e){return void 0===e||""===e||Xf.has(e)}function Kf(e,t){const o=[];for(let a=0;a=t)break;const r=Math.min(t,a+18}yt("en",{editor_bar_labels:"Text on the bars",editor_bar_labels_state:"State",editor_bar_labels_duration:"How long",editor_bar_labels_both:"State and how long",editor_bar_labels_none:"None",editor_timeline_legend:"Legend of states",editor_timeline_legend_above:"Above the chart",editor_timeline_legend_hidden:"Hidden",editor_state_colors:"State colours",editor_state_colors_help:"A palette name (amber, green, blue, red, purple, grey, primary) or any CSS colour.",editor_state_colors_state:"State",editor_state_colors_color:"Colour",editor_state_colors_add:"Add a state colour",editor_state_colors_remove:"Remove",editor_timeline_group:"Group (rows with the same group share a heading)"});const ng=new Set(["source","group_by","aggregate"]),rg=[...xi.filter(e=>!ng.has(e.name)),{name:"bar_labels",path:["bar_labels"],default:"state"},{name:"x_axis",path:["x_axis"],default:!0},{name:"grid_vertical",path:["gridlines","vertical"],default:!0},{name:"tooltip",path:["tooltip"],default:!0},{name:"timeline_legend",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];const ig={text:{}};function sg(e){const t=e.state_colors;return t&&"object"==typeof t&&!Array.isArray(t)?Object.entries(t):[]}function lg(e,t){const o=new Set(sg(e).map(([e])=>e)),a=(e.entities??[]).map(e=>"string"==typeof e?e:e.entity),n=a.map(e=>t?.states[e]?.state).filter(e=>!!e);return[...n,"on","off","home","not_home","heat","idle"].find(e=>!o.has(e)&&"unavailable"!==e&&"unknown"!==e)??`state_${o.size+1}`}function cg(e){const t=sg(e.config),o=t=>_t(e.hass,t),a=t=>e.commit(function(e,t){const o={...e},a=t.filter(([e])=>""!==e.trim());return a.length?o.state_colors=Object.fromEntries(a.map(([e,t])=>[e.trim(),t])):delete o.state_colors,o}(e.config,t)),n=(e,o)=>n=>{n.stopPropagation();const r=String(n.detail?.value??""),i=t.map(e=>[...e]);1===o&&""===r.trim()||(i[e][o]=r,a(i))};return R`
+ +
${o("editor_state_colors")}
+
${o("editor_state_colors_help")}
+ ${t.map(([r,i],s)=>R`
+ + + + a(t.filter((e,t)=>t!==s))} + > +
`)} + +
`}const dg=[{name:"timeline_group",path:["group"]}];yt("en",{editor_style_timeline:"State timeline",timeline_no_states:"No states recorded in this period.",timeline_for:"for {duration}",timeline_now:"Now",timeline_measurement:"Readings, not states: a line chart suits this one"});const ug=["state","duration","both","none"],hg={normal:30,compact:22},pg={normal:24,compact:18};function mg(e,t,o){const a=t.entity.stateObj??{entity_id:t.entity.entityId,state:o,attributes:{}},n=t.entity.config.attribute;try{return n?e.formatEntityAttributeValue?.(a,n,o)??o:e.formatEntityState?.({...a,state:o},o)??o}catch{return o}}function fg(e,t,o){return function(e,t,o){const a=o?.[e];if(a)return{color:Ce(a),quiet:!1,text:Le(a)};const n=eg.has(e)&&!Object.prototype.hasOwnProperty.call(Qf,e),r=Object.prototype.hasOwnProperty.call(Qf,e)?Qf[e]:n?"grey":tg[function(e){let t=0;for(let o=0;o>>0;return t}(e)%tg.length],i=Ce(r);return{color:/^[a-z0-9_]+$/.test(e)&&/^[a-z0-9_]+$/.test(t)?`var(--state-${t}-${e}-color, ${i})`:i,quiet:n,text:n?"theme":Le(r)}}(t,e.domain,function(e){const t=e.state_colors;return t&&"object"==typeof t&&!Array.isArray(t)?t:void 0}(o))}const gg=e=>Math.round(1e4*e)/100+"%";function vg(e,t,o,a){const n=o.raw.bar_labels??"state";return e.spans.map(r=>{if(Jf(r.state))return W;const{left:i,width:s}=function(e,t){const o=t.end-t.start;if(!(o>0))return{left:0,width:0};const a=Math.max(t.start,e.start),n=Math.min(t.end,e.end);return{left:(a-t.start)/o,width:Math.max(0,n-a)/o}}(r,t.range);if(s<=0)return W;const l=r.state,c=fg(e,l,o.raw),d=function(e,t,o){const a=og(o.end-o.start);return"state"===e?t:"duration"===e?a:"both"===e?`${t} · ${a}`:""}(n,mg(o.hass,e.series,l),r),u=""!==d&&function(e){return 6.2*e.length+10}(d)<=s*a;return R`
+ ${u?R`${d}`:W} +
`})}const yg={layout:"chart",group:"history",height:200,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + .tl-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + padding: 0 0 8px; + font-size: 0.875em; + color: var(--secondary-text-color); + } + .tl-key { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + } + .swatch.quiet { + opacity: 0.45; + } + .tl-wrap { + display: grid; + grid-template-columns: fit-content(35%) minmax(0, 1fr); + grid-template-rows: auto auto; + } + .tl-wrap.no-names { + grid-template-columns: 0 minmax(0, 1fr); + } + .tl-names { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding-right: 10px; + } + .tl-name, + .tl-heading { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + height: var(--tl-row); + } + .tl-name { + font-size: 0.875em; + cursor: pointer; + border-radius: 4px; + } + .tl-name span { + overflow: hidden; + text-overflow: ellipsis; + } + .tl-name:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + .tl-heading { + height: var(--tl-heading); + font-size: 0.75em; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--secondary-text-color); + } + .tl-plot { + position: relative; + grid-column: 2; + grid-row: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + .tl-gap { + height: var(--tl-heading); + position: relative; + } + .tl-gap::after { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 50%; + border-top: 1px solid var(--divider-color, rgba(127, 127, 127, 0.25)); + } + .tl-row { + position: relative; + height: var(--tl-row); + cursor: pointer; + } + .tl-track, + .tl-bar { + position: absolute; + top: 4px; + bottom: 4px; + border-radius: 6px; + } + .tl-track { + left: 0; + right: 0; + background: color-mix(in srgb, var(--primary-text-color) 5%, transparent); + } + .tl-bars { + position: absolute; + inset: 0; + clip-path: inset(0 100% 0 0); + transition: clip-path var(--cd-duration) var(--cd-easing); + } + :host([ready]) .tl-bars { + clip-path: inset(0 0 0 0); + } + .tl-bar { + min-width: 2px; + background: var(--tl-color); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-sizing: border-box; + } + .tl-bar.quiet { + background: color-mix(in srgb, var(--tl-color) 30%, transparent); + } + .tl-label { + font-size: 11px; + font-weight: 500; + line-height: 1; + padding: 0 5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tl-bar.text-dark .tl-label { + color: #1c1c1c; + } + .tl-bar.text-light .tl-label { + color: #fff; + } + .tl-bar.text-theme .tl-label { + color: var(--primary-text-color); + } + .tl-grid { + position: absolute; + top: 0; + bottom: 0; + border-left: 1px dashed var(--divider-color, rgba(127, 127, 127, 0.25)); + pointer-events: none; + } + .tl-now { + position: absolute; + top: 0; + bottom: 0; + border-left: 1.5px dashed var(--secondary-text-color); + opacity: 0.7; + pointer-events: none; + } + .tl-hint { + position: absolute; + inset: 0; + display: flex; + align-items: center; + padding: 0 8px; + font-size: 0.75em; + color: var(--secondary-text-color); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .tl-for { + font-weight: 400; + color: var(--secondary-text-color); + } + .tl-wrap .x-axis { + position: relative; + grid-column: 2; + grid-row: 2; + height: 18px; + } + .tl-wrap .x-axis .axis-label { + top: 5px; + } + .frame.compact .tl-legend { + padding-bottom: 4px; + font-size: 0.8em; + } + .frame.compact .tl-wrap .x-axis { + height: 14px; + } + .frame.compact .tl-wrap .x-axis .axis-label { + top: 3px; + } + .frame.compact .tl-track, + .frame.compact .tl-bar { + top: 3px; + bottom: 3px; + border-radius: 4px; + } + `,thumbnail:()=>Z` + + + + + + + + + `,validate:function(e){if($a(e.period),void 0!==e.source&&"history"!==e.source)throw new Error('"source" must be history: a state timeline reads states, which statistics don\'t keep');if(void 0!==e.group_by)throw new Error('"group_by" does not apply to a state timeline');if(void 0!==e.bar_labels&&!ug.includes(e.bar_labels))throw new Error(`"bar_labels" must be one of: ${ug.join(", ")}`);if(void 0!==e.x_axis&&"boolean"!=typeof e.x_axis)throw new Error('"x_axis" must be true or false');const t=e.gridlines;if(void 0!==t&&("object"!=typeof t||null===t||Array.isArray(t)))throw new Error('"gridlines" must have "vertical" (true or false)');if(void 0!==t?.vertical&&"boolean"!=typeof t.vertical)throw new Error('"gridlines.vertical" must be true or false');!function(e,t){if(void 0===e)return;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error(`${t} must map states to colours, like "on: amber"`);for(const[o,a]of Object.entries(e))if("string"!=typeof a||""===a.trim())throw new Error(`${t}: the colour for "${o}" must be text`)}(e.state_colors,'"state_colors"');for(const o of e.entities??[]){if("object"!=typeof o)continue;const e=o;if(void 0!==e.group&&"string"!=typeof e.group)throw new Error(`"group" of ${o.entity} must be text`)}lr(e),Zn(e)},editor:function(e){const t=Fi(`timeline|${wi(e.config)}`,e.hass,()=>{return[...(t=e.hass,o=e.config,$i(t,o).filter(e=>!ng.has(e.name??"")&&!(e.schema??[]).some(e=>ng.has(e.name??"")))),{name:"bar_labels",selector:_i(e.hass,"bar_labels",["state","duration","both","none"])},{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}},{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]},{name:"timeline_legend",selector:_i(e.hass,"timeline_legend",["above","hidden"])}];var t,o});return R`${Pi(e,"chart",t,rg)}${cg(e)}`},entityFields:function(e){return{schema:Fi("timeline_entity",e,()=>[{name:"timeline_group",selector:{text:{}}}]),fields:dg}},disconnect(e){bn(e),Fn(e),ir(e)},render(e){const t=Date.now(),o=mn(e,t,{text:!0}),a=qn(e.raw),n=a.compact?"compact":"normal",r=vn(o,e),i=Math.min(o.range.end,t),s=o.series.map(e=>({series:e,spans:Kf(e.points,i),domain:e.entity.entityId.split(".")[0],measurement:!1}));for(const A of s)A.measurement=ag(A.spans);const l="hidden"!==a.header,c=(e,t)=>R`
${e}${t}
`,d="ready"!==o.status?xn(o,e):s.every(e=>e.spans.every(e=>Jf(e.state)))?R`
${_t(e.hass,"timeline_no_states")}
`:void 0;if(d)return c(r,d);const u=e.config.show.name,h=On(e),p=Math.max(0,...s.map(e=>e.series.entity.name.length)),m=u?Math.min(.35*h,7*p+10):0,f=Math.max(40,h-32-m),g=function(e,t,o){const a=Math.max(2,Math.min(8,Math.round(o/70))),n=Zo(Yo(t.hass)),{unit:r,ticks:i}=Vo(e.range,a,n);return{unit:r,ticks:i,x:$o([e.range.start,e.range.end],[0,1]),offset:n}}(o,e,f),v=!1!==e.raw.x_axis,y=e.raw.gridlines?.vertical??!0,b=sr(e.raw),_=new Map(s.map(e=>[e.series.index,e])),x=function(e,t){const o=[],a=new Set;for(const n of e){const r=t(n);if(void 0!==r){if(!a.has(r)){a.add(r),o.push({heading:r});for(const a of e)t(a)===r&&o.push({item:a})}}else o.push({item:n})}return o}(o.series.map(e=>_.get(e.index)),e=>(e=>{const t=e.entity.config.group;return"string"==typeof t&&""!==t.trim()?t.trim():void 0})(e.series)),w=o.range.end-o.range.start,$=o.live&&o.range.end>t&&t>o.range.start,k=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_timeline"),names:s.map(e=>e.series.entity.name).join(", ")}),M=b?cr(e,o.range,[Array.from({length:241},(e,t)=>({t:o.range.start+w*t/240,v:null})),...s.map(e=>e.spans.map(e=>({t:e.start,v:null})))]):void 0,z=`--tl-row:${hg[n]}px;--tl-heading:${pg[n]}px`;return c(R`${r}${l?function(e,t){const o=new Map;for(const n of e)if(!n.measurement)for(const e of n.spans){if(Jf(e.state))continue;const a=mg(t.hass,n.series,e.state);o.has(a)||o.set(a,{label:a,color:fg(n,e.state,t.raw)})}if(0===o.size)return W;const a=[...o.values()];return R`
+ ${[...a.filter(e=>!e.color.quiet),...a.filter(e=>e.color.quiet)].map(({label:e,color:t})=>R`${e}`)} +
`}(s,e):W}`,R`
+ ${u?R`
+ ${x.map(e=>"heading"in e?R`
${e.heading}
`:R`
+ ${e.item.series.entity.name} +
`)} +
`:W} + `}(s,o,e,f):W} +
+ ${v?R`
+ ${g.ticks.map(t=>{const o=g.x(t);return R`.95?"end":""}" style="left:${gg(o)}" + >${ea(t,g.unit,w,e.hass,g.offset)}`})} +
`:W} +
`)}},bg={current:"current_temperature",target:"temperature",low:"target_temp_low",high:"target_temp_high",action:"hvac_action"},_g=Object.keys(bg);const xg=["heat","cool","dry","fan"],wg={heating:"heat",preheating:"heat",cooling:"cool",defrosting:"cool",drying:"dry",fan:"fan"};function $g(e){return void 0===e?void 0:wg[e]}function kg(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return o>0?e[o-1]:void 0}const Mg=e=>(Math.round(100*e)/100).toString();yt("en",{editor_set_point:"Show the set point",editor_shading:"Shade while heating or cooling",editor_shade_fan:"Shade while only the fan runs",editor_current_color:"Temperature colour",editor_set_point_color:"Set point colour",editor_heating_color:"Heating colour",editor_cooling_color:"Cooling colour"});const zg=new Set(["source","group_by","aggregate"]),Ag=[...xi.filter(e=>!zg.has(e.name)),{name:"set_point",path:["set_point"],default:!0},{name:"shading",path:["shading"],default:!0},{name:"shade_fan",path:["shade_fan"],default:!1},{name:"curve",path:["curve"],default:"smooth"},{name:"line_width",path:["line_width"],default:At},{name:"current_color",path:["current_color"]},{name:"set_point_color",path:["set_point_color"]},{name:"heating_color",path:["heating_color"]},{name:"cooling_color",path:["cooling_color"]},...Si,...zi];function Sg(e){return $i(e.hass,e.config).filter(e=>!zg.has(e.name??"")&&!(e.schema??[]).some(e=>zg.has(e.name??"")))}yt("en",{editor_style_climate:"Climate",climate_set_point:"Set point",climate_range:"{low} – {high}",climate_not_thermostat:"{name} is not a thermostat. Pick a climate entity.",climate_action_heating:"Heating",climate_action_preheating:"Preheating",climate_action_cooling:"Cooling",climate_action_defrosting:"Defrosting",climate_action_drying:"Drying",climate_action_fan:"Fan",climate_action_idle:"Idle",climate_action_off:"Off"});const Eg="var(--state-climate-heat-color, #ff8100)",Cg="var(--state-climate-cool-color, #2b9af9)",jg="var(--state-climate-dry-color, #efbd07)",Ng="var(--state-climate-fan_only-color, #8a8a8a)",Lg=["set_point","shading","shade_fan"],Tg=["current_color","set_point_color","heating_color","cooling_color"];function Og(e,t,o){if(t.stateObj&&e?.formatEntityAttributeValue){const a=e.formatEntityAttributeValue(t.stateObj,bg.action,o);if(a)return a}const a=_t(e,`climate_action_${o}`);return a===`climate_action_${o}`?o:a}function Fg(e,t,o,a,n){return"number"==typeof t?e.text(t):"number"==typeof o&&"number"==typeof a?_t(n,"climate_range",{low:kn(o,e.decimals,n),high:e.text(a)}):"—"}const Pg=e=>Math.round(1e4*e)/100+"%";function Dg(e,t,o,a,n,r,i){if(void 0===i)return W;const s=n.x(i)/cn;if(!(s>=0&&s<=1))return W;const l=tr(s,Pn(e)),c=kg(o.current,i),d=kg(o.action,i)?.s,u=$g(d),h=e=>kg(e,i)?.v??void 0,p=c&&null!==c.v?n.y(c.v)/dn:-1,m=(e,t,o)=>R`
+ ${e}${t}${o} +
`;return R``}const Ug=(e,t=!1)=>R`
${e}
`;const Hg={layout:"chart",group:"history",height:240,styles:r` + ${$n} + ${Bn} + ${mr} + ${Yn} + .style-climate .current { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-climate .set-point { + stroke-dasharray: 6 5; + stroke-width: 2px; + } + .style-climate .range-band { + fill-opacity: 0.08; + } + .style-climate .action-span { + fill-opacity: 0.2; + } + .style-climate .set-point-swatch { + box-sizing: border-box; + height: 0; + border-radius: 0; + border-top: 3px dashed; + } + .style-climate .action-swatch { + border-radius: 2px; + opacity: 0.45; + } + `,thumbnail:()=>Z` + + + + `,validate(e){pn(e),Lo(e),lr(e),Ft(e),Zn(e),function(e){if(void 0!==e.source&&"history"!==e.source)throw new Error("\"source\" must be history: long-term statistics don't keep a thermostat's set point or action");if(void 0!==e.group_by)throw new Error('"group_by" is not available on a climate chart');for(const t of Lg)if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`"${t}" must be true or false`);for(const t of Tg)if(void 0!==e[t]&&("string"!=typeof e[t]||""===e[t]))throw new Error(`"${t}" must be a colour name or CSS colour`)}(e)},editor:function(e){const t=Fi(`climate|${wi(e.config)}`,e.hass,()=>[...Sg(e),{type:"grid",name:"",schema:[{name:"set_point",selector:{boolean:{}}},{name:"shading",selector:{boolean:{}}},{name:"shade_fan",selector:{boolean:{}}}]},{type:"grid",name:"",schema:[{name:"curve",selector:_i(e.hass,"curve",an)},{name:"line_width",selector:Vt}]},{type:"grid",name:"",schema:[{name:"current_color",selector:{text:{}}},{name:"set_point_color",selector:{text:{}}},{name:"heating_color",selector:{text:{}}},{name:"cooling_color",selector:{text:{}}}]},...Ei(e.hass),...Ai(e.hass)]);return Pi(e,"chart",t,Ag)},disconnect(e){bn(e),Fn(e),ir(e)},render(e){const t=qn(e.raw),o=function(e){const t=(t,o)=>Ce(e[t]??o);return{setPoint:!1!==e.set_point,shading:!1!==e.shading,shadeFan:!0===e.shade_fan,colors:{current:t("current_color","primary"),setPoint:t("set_point_color","red"),heat:t("heating_color",Eg),cool:t("cooling_color",Cg),dry:jg,fan:Ng}}}(e.raw),a=e.items[0],n=$a(e.raw.period),r=fa(e.hass),i=Date.now(),s=Ma(n,i,r,n.navigate?Aa(e.host,n):0),l={start:s.start,end:s.end},c=function(e,t){const o=t.config?.unit_system,a=e.config.unit??o?.temperature??"°C",n=e.config.decimals??1;return{unit:a,decimals:n,text:e=>"number"==typeof e&&Number.isFinite(e)?Mn(kn(e,n,t),a):"—"}}(a,e.hass),d=a.entityId.startsWith("climate."),u=function(e,t,o,a){const{show:n}=e.config;if(!n.name&&!n.value)return W;const r=t.stateObj?.attributes??{},i=t.entityId.startsWith("climate.")&&void 0!==t.stateObj&&!["unavailable","unknown"].includes(t.stateObj.state),s="string"==typeof r[bg.action]?r[bg.action]:void 0,l=$g(s),c=(e,t,o)=>R`
+ ${e} ${n.name?R`${t}`:W} + ${n.value?R`${o}`:W} +
`;return R`
+ ${c(R``,t.name,i?a.text(r[bg.current]):t.display)} + ${o.setPoint&&i?c(R``,_t(e.hass,"climate_set_point"),Fg(a,r[bg.target],r[bg.low],r[bg.high],e.hass)):W} + ${i&&s?R`
+ + ${Og(e.hass,t,s)} +
`:W} +
`}(e,a,d?o:{...o,setPoint:!1},c);if(!d)return Wn({...t,header:"above"},u,Ug(_t(e.hass,"climate_not_thermostat",{name:a.name})));const h=function(e){const t={};for(const o of _g)t[o]="action"===o?{entity:e,attribute:bg[o],text:!0}:{entity:e,attribute:bg[o]};return t}(a.entityId),p=on(e.host,e.requestUpdate).get(e.hass,Object.values(h),l,"history",{live:s.live}),m=function(e,t,o,a){const n={};for(const r of _g){const i=e.get(a(t[r]))??[],s=i[i.length-1],l="action"===r?void 0!==s?.s:null!=s?.v;n[r]=s&&l&&s.tn.start&&a.push(n),n=void 0);const e=$g(r.s);e&&o.includes(e)&&(n={kind:e,action:r.s,start:r.t,end:t})}return n&&t>n.start&&a.push({...n,end:Math.max(n.end,t)}),a}(m.action,Math.min(l.end,i),v):[],b=o.setPoint?[m.current,m.target,m.low,m.high]:[m.current];let _;if("loading"===p.status?_=Ug(_t(e.hass,"chart_loading")):"error"===p.status?_=Ug(_t(e.hass,"chart_error",{error:p.error??""}),!0):b.every(e=>e.every(e=>null===e.v))&&0===y.length&&(_=Ug(_t(e.hass,"chart_no_data"))),_)return Wn("over"===t.header?{...t,header:"above"}:t,g,_);const x=Dn(e,l,xo(b)),{scales:w}=x,$=e.raw.line_width??At,k=e.raw.curve??"smooth",M=Z`${y.map(e=>Z``)}`,z=function(e,t){const o=new Map(t.map(e=>[e.t,e.v]));return e.map(e=>{const t=o.get(e.t);return{t:e.t,v:null!==e.v&&"number"==typeof t?[e.v,t]:null}})}(m.low,m.high),A=o.setPoint?Z` + {if(!n.length)return;const r=n[n.length-1];if(e>r.t&&n.push({t:e,v:r.v}),n.length>1){const e=n.map(e=>[t(e.t),o(e.v[1])]),r=n.map(e=>[t(e.t),o(e.v[0])]);a+=`M${Mg(e[0][0])},${Mg(e[0][1])}`;for(let t=1;t=0;t--)a+=`V${Mg(r[t][1])}H${Mg(r[t][0])}`;a+="Z"}n=[]};for(const i of e)null===i.v?r(i.t):n.push({t:i.t,v:i.v});return n.length&&r(e[e.length-1].t),a}(z,w.x,w.y)} fill=${o.colors.setPoint}> + ${[m.target,m.low,m.high].map(e=>Z`{if(!o.length)return;const a=o[o.length-1];e>a.t&&o.push({t:e,v:a.v}),t.push(o),o=[]};for(const r of e){if(null===r.v){a(r.t);continue}const e=o[o.length-1];e&&e.v===r.v||o.push({t:r.t,v:r.v})}const n=e[e.length-1];return o.length&&a(n.t),t}(e).map(e=>sn(e.map(e=>[t.x(e.t),t.y(e.v)]),"step")).join("")}(e,w)} fill="none" stroke=${o.colors.setPoint}>`)}`:W,S=ln(ta(m.current,500)).map(e=>sn(e.map(e=>[w.x(e.t),w.y(e.v)]),k)).join(""),E=Z`${M}${A}`,C=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_climate"),names:a.name}),j=sr(e.raw);return Wn(t,g,In({axes:x,ctx:e,content:E,label:C,overlay:R`${"over"===t.header?Vn(g):W} + ${j?Dg(e,a,m,o,w,c,rr(e.host)):W}`,hover:j?cr(e,l,[m.current,m.target,m.low,m.high,m.action]):void 0}))}},Ig=[4,8,16];function Bg(e,t){const o=360/t;return Math.floor(Na(e)/o+.5)%t}function Rg(e,t){return Ea[e*(16/t)%16]}function Zg(e,t,o,a){if(t<0)return null;const n=e[t];return o-n.tMath.round(100*e)/100||0;function Vg(e,t){return[Wg(e*Math.sin(t)),Wg(-e*Math.cos(t))]}yt("en",{editor_sectors:"Directions",editor_sectors_4:"4 (N, E, S, W)",editor_sectors_8:"8 (N, NE, E…)",editor_sectors_16:"16 (N, NNE, NE…)",editor_calm:"Calm below (blank: 0.5 m/s, or the same in km/h, mph or knots)",editor_wind_roles:"The first entity is the wind direction, in degrees or compass points such as SW. The second is the wind speed: without one, the petals only show how often the wind came from each way.",editor_wind_now:"Direction: {direction}. Speed: {speed}.",editor_wind_none:"none",editor_wind_statistics:"Long-term statistics give each hour's average direction. That is only right across north when the sensor records direction as an angle (state class measurement_angle); otherwise an hour swinging between NNW and NNE averages to south. History is exact."});const Yg=xi.filter(e=>"group_by"!==e.name&&"aggregate"!==e.name).map(e=>"period_last"===e.name?{...e,default:30}:"period_unit"===e.name?{...e,default:"day"}:e),Gg=[...Yg,{name:"sectors",path:["sectors"],default:"16"},{name:"calm",path:["calm"]},{name:"tooltip",path:["tooltip"],default:!0},{name:"compact",path:["compact"],default:!1}];function Xg(e,t){if(void 0===t)return _t(e.hass,"editor_wind_none");const o="string"==typeof t?t:t.entity;return("string"==typeof t?void 0:t.name)??e.hass?.states[o]?.attributes?.friendly_name??o}yt("en",{editor_style_wind_rose:"Wind rose",wind_prevailing:"Prevailing",wind_mean_speed:"Mean speed",wind_strongest:"Strongest",wind_calm:"Calm",wind_speed:"Speed",wind_speed_unit:"Speed ({unit})",wind_share:"Of the time",wind_aria:"Wind rose, prevailing {direction} {share}",wind_aria_calm:"Wind rose, prevailing {direction} {share}, calm {calm}",wind_aria_still:"Wind rose, calm {calm}"});const Jg={last:30,unit:"day"};function Kg(e){const t=e.period;return null==t?$a(Jg):"object"!=typeof t||Array.isArray(t)?$a(t):$a({...Jg,...t})}function Qg(e){const t=e.sectors;if(null==t||""===t)return 16;const o="string"==typeof t?Number(t):t;if(!Ig.includes(o))throw new Error(`"sectors" must be one of: ${Ig.join(", ")}`);return o}function ev(e){const t=e.calm;if(null==t||""===t)return;const o="string"==typeof t?Number(t):t;if("number"!=typeof o||!Number.isFinite(o)||o<0)throw new Error('"calm" must be a number of 0 or more');return o}const tv=["color-mix(in srgb, var(--primary-color, #2f6fe4) 35%, var(--card-background-color, #fff))","color-mix(in srgb, var(--primary-color, #2f6fe4) 62%, var(--card-background-color, #fff))","var(--primary-color, #2f6fe4)","color-mix(in srgb, var(--primary-color, #2f6fe4) 68%, var(--primary-text-color, #212121))","color-mix(in srgb, var(--purple-color, #8b6cff) 80%, var(--primary-text-color, #212121))"],ov=(e,t)=>kn(e,Number.isInteger(e)?0:1,t.hass);function av(e,t,o){return Number.isFinite(e)?void 0===t?`${ov(e,o)}+`:`${ov(e,o)}–${ov(t,o)}`:void 0===t?"":`< ${ov(t,o)}`}function nv(e,t,o=!1){const a=100*e;return 0===a?`${kn(0,0,t.hass)}%`:`${kn(a,o||a<1?1:0,t.hass)}%`}const rv=new WeakMap;function iv(e,t){rv.get(e.host)!==t&&(void 0===t?rv.delete(e.host):rv.set(e.host,t),e.requestUpdate())}const sv=124;function lv(e,t,o,a){const{rose:n,n:r,bands:i,rings:s}=e,l=2*Math.PI/r,c=.09*l,d=sr(a.raw),u=n.sectors.map((o,s)=>{if(n.totals[s]<=0)return W;const u=s*l-l/2+c,h=s*l+l/2-c;let p=0;const m=o.map((t,o)=>{if(t<=0)return W;const a=function(e,t,o,a){if(t<=e||a<=o)return"";const n=a-o>Math.PI?1:0,[r,i]=Vg(t,o),[s,l]=Vg(t,a),c=`M${r} ${i}A${t} ${t} 0 ${n} 1 ${s} ${l}`;if(e<=0)return`${c}L0 0Z`;const[d,u]=Vg(e,a),[h,p]=Vg(e,o);return`${c}L${d} ${u}A${e} ${e} 0 ${n} 0 ${h} ${p}Z`}(e.radius(p),e.radius(p+t),u,h);return p+=t,Z``}),f=Rg(s,r),g=t.index>=0;return Z`iv(a,s):W} + @pointerleave=${d?()=>iv(a,void 0):W} + @focus=${d?()=>iv(a,s):W} + @blur=${d?()=>iv(a,void 0):W} + >${m}`}),h=function(e){const t=e.length;let o=0,a=1/0;for(let n=0;n{const[o,a]=Vg(113,t*Math.PI/2);return Z`${e}`});return R` + ${s.map(t=>Z``)} + + + + ${u} + + ${p} + ${s.map(t=>{const[o,n]=Vg(e.radius(t/100),h),r=Math.sin(h)>=0?"start":"end";return Z`${kn(t,Number.isInteger(t)?0:1,a.hass)}%`})} + `}function cv(e,t,o,a){const n=void 0!==o&&o.index>=0;return R`
+ ${e} + ${t} +
`}function dv(e,t,o,a){const n=(e,t)=>_t(a.hass,e,t),{rose:r}=e,i=r.prevailing<0?"—":`${Rg(r.prevailing,e.n)} · ${nv(r.totals[r.prevailing],a)}`,s=e=>null===e?"—":Mn(kn(e,o?.decimals??1,a.hass),o?.unit??"");return R`
+
+ ${cv(n("wind_prevailing"),i,t.direction,"prevailing")} + ${o?R`${cv(n("wind_mean_speed"),s(r.meanSpeed),t.speed,"mean")} + ${cv(n("wind_strongest"),s(function(e){let t=null;for(const o of e){const e="number"==typeof o.max&&Number.isFinite(o.max)?o.max:o.v;null!==e&&(null===t||e>t)&&(t=e)}return t}(o.points)),t.speed,"strongest")} + ${cv(n("wind_calm"),nv(r.calm,a),t.speed,"calm")}`:W} +
+ ${o?R`
+
${o.unit?n("wind_speed_unit",{unit:o.unit}):n("wind_speed")}
+
+ ${e.bands.map(e=>R`
+ + ${e.label} +
`)} +
+
`:W} +
`}const uv={layout:"chart",group:"distribution",height:280,styles:r` + ${$n} + ${mr} + ${Yn} + .wind { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + } + .rose-wrap { + position: relative; + flex: 1 1 200px; + max-width: 270px; + aspect-ratio: 1; + } + .rose { + display: block; + width: 100%; + height: 100%; + overflow: visible; + } + .rose .ring { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-dasharray: 3 3; + } + .rose .rim, + .rose .spoke { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose .calm-circle { + fill: var(--card-background-color, #fff); + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .rose text { + font-family: inherit; + fill: var(--secondary-text-color); + text-anchor: middle; + font-size: 12px; + } + .rose .compass.north { + fill: var(--primary-text-color); + font-weight: 500; + } + .rose .ring-label { + font-size: 10px; + paint-order: stroke; + stroke: var(--card-background-color, #fff); + stroke-width: 3px; + stroke-linejoin: round; + } + .rose .petal { + outline: none; + transform-box: view-box; + transform-origin: 0 0; + transform: scale(0); + transition: transform var(--cd-duration) var(--cd-easing); + } + :host([ready]) .rose .petal { + transform: scale(1); + } + .rose .petal.entity { + cursor: pointer; + } + .rose .petal:focus-visible path { + stroke: var(--primary-text-color); + stroke-width: 1.5px; + } + .rose-tip { + top: 0; + white-space: nowrap; + } + .tooltip.rose-tip.right { + transform: translate(10px, -50%); + } + .tooltip.rose-tip.left { + transform: translate(calc(-100% - 10px), -50%); + } + .readout { + flex: 1 1 150px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + } + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 8px 16px; + } + .stat { + display: flex; + flex-direction: column; + min-width: 0; + border-radius: 4px; + } + .stat.entity { + cursor: pointer; + } + .stat:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .stat-label { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .stat-value { + font-size: 1.25em; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } + .keys { + display: flex; + gap: 4px; + margin-top: 6px; + } + .key { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + .key-swatch { + width: 100%; + height: 10px; + border-radius: 2px; + } + .key-label { + font-size: 11px; + color: var(--secondary-text-color); + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .frame.compact .rose-wrap { + max-width: 170px; + flex-basis: 150px; + } + .frame.compact .wind { + gap: 4px 12px; + } + .frame.compact .readout { + gap: 8px; + } + .frame.compact .stats { + gap: 4px 12px; + } + .frame.compact .stat-value { + font-size: 1.05em; + } + `,thumbnail:()=>Z` + + + + + + `,validate(e){pn(e),Kg(e),lr(e),Zn(e),Qg(e),ev(e)},editor:function(e){const t=Fi(`wind_rose|${wi(e.config)}`,e.hass,()=>[...$i(e.hass,e.config).filter(e=>!e.schema?.some(e=>"group_by"===e.name)),{type:"grid",name:"",schema:[{name:"sectors",selector:_i(e.hass,"sectors",Ig.map(String))},{name:"calm",selector:{number:{min:0,mode:"box",step:"any"}}}]},{type:"grid",name:"",schema:[{name:"tooltip",selector:{boolean:{}}},{name:"compact",selector:{boolean:{}}}]}]),o=e.config.entities??[],a={...e,config:(n=e.config,"number"==typeof n.sectors?{...n,sectors:String(n.sectors)}:n),commit:t=>e.commit(function(e){if("string"!=typeof e.sectors)return e;const t=Number(e.sectors);return{...e,sectors:Number.isFinite(t)?t:e.sectors}}(t))};var n;const r="margin: 0 0 12px; color: var(--secondary-text-color); font-size: 0.875em; line-height: 1.4;";return R`

+ ${_t(e.hass,"editor_wind_roles")} +
${_t(e.hass,"editor_wind_now",{direction:Xg(e,o[0]),speed:Xg(e,o[1])})} +

+ ${"statistics"===e.config.source?R`

${_t(e.hass,"editor_wind_statistics")}

`:W} + ${Pi(a,"chart",t,Gg)}`},disconnect(e){bn(e),rv.delete(e)},render(e){const t={...qn(e.raw),header:"above"},o=function(e){const t=e.config.entities.length?e.config.entities:e.items.map(e=>e.config),o=t=>t&&{id:t.entity,entity:t,index:e.items.findIndex(e=>e.entityId===t.entity)};return{direction:o(t[0]),speed:o(t[1])}}(e),a=o.direction;if(!a)return R``;const n=function(e,t,o,a=Date.now()){const n=Kg(e.raw),r=e.raw.source??"history",i=fa(e.hass),s=Ma(n,a,i,n.navigate?Aa(e.host,n):0),l={start:s.start,end:s.end},c=[{entity:t.id,attribute:t.entity.attribute,bearing:!0},...o?[{entity:o.id,attribute:o.entity.attribute}]:[]],d="statistics"===r?function(e){return{period:e.end-e.start<=1728e5?"5minute":"hour",types:["mean","max"],value:"mean"}}(l):void 0,u=d?Ga[d.period]:1/0,h=d?{start:l.start-u,end:l.end}:l,p=on(e.host,e.requestUpdate).get(e.hass,c,h,r,{live:s.live,statistics:d}),m=e=>p.data.get(Ra(e))??[],f=m(c[0]),g=o?m(c[1]):void 0;return{frame:{status:p.status,error:p.error,range:l,period:n,source:r,series:[],live:s.live,back:s.back,zone:i},direction:f,speed:g?.some(e=>null!==e.v)?g:void 0,until:Math.min(l.end,a),maxHold:u}}(e,a,o.speed),{frame:r}=n,i=vn(r,e),s=R`
${_t(e.hass,"chart_no_data")}
`,l="ready"!==r.status?xn(r,e):n.direction.every(e=>null===e.v)?s:void 0;if(l)return Wn(t,i,l);const c=Qg(e.raw),d=n.speed?o.speed:void 0,u=d?d.entity.unit??(d.entity.attribute?"":String(e.hass.states[d.id]?.attributes?.unit_of_measurement??"")):void 0,h=function(e){switch((e??"").trim().toLowerCase()){case"km/h":case"kmh":case"kph":return{from:[0,12,20,29,39],calm:1.8};case"mph":return{from:[0,8,13,19,25],calm:1.1};case"kn":case"kt":case"kts":case"knot":case"knots":return{from:[0,7,11,17,22],calm:1};case"ft/s":return{from:[0,10,20,30,40],calm:1.6};default:return{from:[0,3,6,9,12],calm:.5}}}(u),p=d?function(e,t,o){return e!==st&&e.length>0?e.map((t,a)=>({from:t.from,color:Ce(t.color),label:t.label??av(t.from,e[a+1]?.from,o)})):t.map((e,a)=>({from:e,color:tv[a%tv.length],label:av(e,t[a+1],o)}))}(os(d.entity.severity,e.hass),h.from,e):[{from:0,color:"var(--primary-color, #2f6fe4)",label:""}],m=d?ev(e.raw)??h.calm:0,f=function(e,t,o,a=1/0){const n=new Set([o]);for(const c of t?[e,t]:[e])for(const e of c)n.add(e.t),Number.isFinite(a)&&n.add(e.t+a);const r=[...n].filter(e=>e<=o).sort((e,t)=>e-t),i=[];let s=-1,l=-1;for(let c=0;c+1new Array(n).fill(0));let i=0,s=0,l=0,c=0;for(const m of e){if(i+=m.ms,null!==m.speed&&(l+=m.ms,c+=m.speed*m.ms,m.speedi>0?e/i:0,u=r.map(e=>e.map(d)),h=u.map(e=>e.reduce((e,t)=>e+t,0));let p=-1;return h.forEach((e,t)=>{e>0&&(p<0||e>h[p])&&(p=t)}),{total:i,calm:d(s),sectors:u,totals:h,prevailing:p,meanSpeed:l>0?c/l:null}}(f,c,p.map(e=>e.from),m);if(g.total<=0)return Wn(t,i,s);const v=function(e){if(!(e>0))return[];const t=e/3,o=10**Math.floor(Math.log10(t)),a=[1,2,2.5,5,10].map(e=>e*o).find(e=>e>=t-1e-9)??10*o,n=Math.max(1,Math.ceil(e/a-1e-9));return Array.from({length:n},(e,t)=>Math.round((t+1)*a*1e3)/1e3)}(100*Math.max(0,...g.totals)),y=(v[v.length-1]??100)/100,b={rose:g,n:c,bands:p,rings:v,radius:e=>10+e/y*90},_=t=>nv(t,e),x={direction:g.prevailing<0?"":Rg(g.prevailing,c),share:g.prevailing<0?"":_(g.totals[g.prevailing]),calm:_(g.calm)},w=_t(e.hass,g.prevailing<0?"wind_aria_still":d?"wind_aria_calm":"wind_aria",x),$=d?{unit:u??"",decimals:d.index>=0?e.items[d.index].decimals:1,points:n.speed??[]}:void 0;return Wn(t,i,R`
+
+ ${lv(b,a,w,e)} ${function(e,t,o){const a=rv.get(o.host);if(!(void 0!==a&&sr(o.raw)&&e.rose.totals[a]>0))return W;const n=2*a*Math.PI/e.n,[r,i]=Vg(e.radius(e.rose.totals[a]),n),s=r>0?"left":"right",l=void 0===t?[{color:e.bands[0].color,name:_t(o.hass,"wind_share"),share:e.rose.totals[a]}]:e.rose.sectors[a].map((o,a)=>({color:e.bands[a].color,name:Mn(e.bands[a].label,t),share:o})).filter(e=>e.share>0);return R``}(b,$?$.unit:void 0,e)} +
+ ${dv(b,{direction:a,speed:d},$,e)} +
`)}},hv=2*Math.PI,pv=1440,mv=6e4,fv=["on","open","opening","closing","home","playing","heating","cooling","drying","active","running","cleaning","detected"],gv=new Set(["unavailable","unknown"]);function vv(e){return void 0===e||""===e||gv.has(e)}function yv(e,t,o,a){const n=e[e.length-1];n&&n.end===t?n.end=o:e.push(a?{start:t,end:o,carried:a}:{start:t,end:o})}function bv(e,t){const o=[];for(const a of e){const e=Math.max(t.start,a.start),n=Math.min(t.end,a.end);if(n<=e)continue;const r=!0===a.carried||a.start!e.carried).length}function xv(e,t,o){return(e+la(e,o)-(t+la(t,o)))/mv}function wv(e,t,o){const a=e.start,n=e.end,r=la(a,o),i=la(Math.max(a,n-1e3),o),s=t+la(t,o),l=(e,t)=>(e=>Math.min(pv,Math.max(0,e)))((e+t-s)/mv);if(r===i)return[[l(a,r),l(n,r)]];const c=function(e,t,o){const a=la(e,o);for(;t-e>1e3;){const n=Math.floor((e+t)/2);la(n,o)===a?e=n:t=n}return 1e3*Math.floor(t/1e3)}(a,n-1e3,o);return[[l(a,r),l(c,r)],[l(c,i),l(n,i)]].filter(([e,t])=>t>e)}function $v(e){return e/pv*hv}function kv(e,[t,o]){const a=Math.max(0,Math.ceil(t-.5)),n=Math.min(pv,Math.ceil(o-.5));for(let r=a;re+t,0);return Av.has(t)?o:o/e.length}function Ev(e){return Av.has(e)}function Cv(e){const t=Math.max(0,Math.round(e/mv)),o=Math.floor(t/60),a=t-60*o;return 0===o?`${a} m`:a?`${o} h ${a} m`:`${o} h`}function jv(e,t){return[Nv(e*Math.sin(t)),Nv(-e*Math.cos(t))]}function Nv(e){return Math.round(100*e)/100||0}function Lv(e,t,o,a){if(t<=0||a<=o)return"";const n=Math.max(0,e);if(a-o>=hv-1e-6){const e=(e,t)=>{const[o,a]=jv(e,0),[n,r]=jv(e,Math.PI);return`M${o} ${a}A${e} ${e} 0 1 ${t} ${n} ${r}A${e} ${e} 0 1 ${t} ${o} ${a}Z`};return n>0?`${e(t,1)}${e(n,0)}`:e(t,1)}const r=a-o>Math.PI?1:0,[i,s]=jv(t,o),[l,c]=jv(t,a),d=`M${i} ${s}A${t} ${t} 0 ${r} 1 ${l} ${c}`;if(0===n)return`${d}L0 0Z`;const[u,h]=jv(n,a),[p,m]=jv(n,o);return`${d}L${u} ${h}A${n} ${n} 0 ${r} 0 ${p} ${m}Z`}yt("en",{editor_days:"Days (more than 1 shows the average day)",editor_center:"In the middle",editor_day_clock_center_total:"Time on",editor_day_clock_center_now:"The time now",editor_day_clock_center_none:"Nothing",editor_bucket:"One bar for every",editor_bucket_15:"15 minutes",editor_bucket_30:"30 minutes",editor_bucket_60:"Hour",editor_aggregate_auto:"Automatic (change for meters, else average)",editor_ring_color:"Colour",editor_active:"Counts as on (empty: on, open, heating, home…)"});const Tv=[{name:"days",path:["days"],default:1},{name:"period_offset",path:["period","offset"],default:0},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"center",path:["center"],default:"total"},{name:"bucket",path:["bucket"],default:"60"},{name:"aggregate",path:["aggregate"],default:"auto"},{name:"tooltip",path:["tooltip"],default:!0}];const Ov=[{type:"grid",name:"",schema:[{name:"ring_color",selector:{text:{}}},{name:"active",selector:{text:{multiple:!0}}}]}],Fv=[{name:"ring_color",path:["color"]},{name:"active",path:["active"]}];yt("en",{editor_style_day_clock:"24-hour clock",day_clock_times_one:"once",day_clock_times:"{n} times",day_clock_a_day:"{text} a day",day_clock_busiest:"{total} · busiest {time}",day_clock_peak:"peak {value} at {time}",day_clock_state:"{name} {state}",day_clock_per_day:"{label} per day",day_clock_no_data:"Nothing recorded",day_clock_now:"Now",day_clock_on:"on",day_clock_average:"Average day · last {n} days",day_clock_on_days:"on {on} of {of} days",day_clock_so_far:"{duration} so far",day_clock_range:"{from} – {to}",day_clock_average_value:"{value} on average"});const Pv=["total","now","none"],Dv=new Set(["alarm_control_panel","automation","binary_sensor","calendar","climate","cover","device_tracker","fan","humidifier","input_boolean","lawn_mower","light","lock","media_player","person","remote","schedule","script","siren","switch","timer","vacuum","valve","water_heater"]),Uv=e=>e.split(".")[0];function Hv(e){const t=e.config.active;if("string"==typeof t&&""!==t.trim()||Array.isArray(t)&&t.length>0)return"state";if(Dv.has(Uv(e.entityId)))return"state";const o=e.stateObj?.attributes??{};return e.config.attribute||void 0===o.unit_of_measurement&&void 0===o.state_class?e.available?"numeric":"state":"numeric"}function Iv(e,t){return e.config.attribute?e.config.attribute:"state"===t&&"climate"===Uv(e.entityId)?"hvac_action":void 0}function Bv(e,t){if(void 0!==t.aggregate)return t.aggregate;const o=e.stateObj?.attributes?.state_class;return"total_increasing"===o||"total"===o?"change":"mean"}function Rv(e,t,o,a){const n=t.stateObj;let r=o.replace(/_/g," ");if(n){const t=a?e.formatEntityAttributeValue?.(n,a,o):e.formatEntityState?.(n,o);t&&(r=t)}return r.toLocaleLowerCase()}function Zv(e,t){const o=Math.round(e)%pv,a=Date.UTC(2e3,0,1,Math.floor(o/60),o%60);return new Intl.DateTimeFormat(t?.locale?.language??"en",{hour:"numeric",minute:"2-digit",hour12:Go(t),timeZone:"UTC"}).format(a)}function qv(e,t,o){return Mn(kn(e,t.decimals,o),t.unit)}function Wv(e,t=Date.now()){const{raw:o,hass:a}=e,n=o.days??1,r=Number(o.bucket??60),i=function(e,t){const o=$a(e.period);return 1===t?{last:1,unit:"day",calendar:"day",offset:o.offset,navigate:o.navigate,until:"end"}:{last:t,unit:"day",offset:o.offset,navigate:o.navigate,until:"end"}}(o,n),s=Math.max(0,i.offset+(i.navigate?Aa(e.host,i):0)),l=fa(a),c=function(e,t,o,a){const n=ha("day",ua("day",e,t),-a*o,t),r=[];for(let i=o-1;i>=-1;i--)r.push(ha("day",n,-i,t));return r}(t,l,n,s),d=0===s,u=c[c.length-1],h=Math.min(u,t),p=e.items.map(Hv),m=e.items.map((e,t)=>({entity:e.entityId,attribute:Iv(e,p[t]),text:!0})),f=on(e.host,e.requestUpdate).get(a,m,{start:c[0],end:d?t:u},"history",{live:d}),g={start:c[0],end:h},v=Math.max(1,c.slice(0,-1).filter(e=>e{const i=p[t],s=e.config,d=e.stateObj?f.data.get(Ra(m[t]))??[]:[],u={index:t,entity:e,kind:i,color:Ce(s.color??un[t%un.length]),missing:!e.stateObj,hasData:!1,intervals:[],runs:[],slots:[],heights:[],aggregate:Bv(e,o),totalMs:0,count:0,stat:""};if("state"===i){const e=function(e){if("string"==typeof e&&""!==e.trim())return new Set([e.trim()]);if(Array.isArray(e)){const t=e.filter(e=>"string"==typeof e&&""!==e.trim()).map(e=>e.trim());if(t.length)return new Set(t)}return new Set(fv)}(s.active),t=function(e,t,o){const a={active:[],known:[]};for(let n=0;n0,u.word=t.word;const o=t.active;1===n?u.intervals=o:u.runs=function(e,t,o,a){const n=new Uint16Array(pv),r=new Uint16Array(pv);for(let s=0;s+1e+t.end-t.start,0)/v;let r=0;for(let a=0;a+11===n?e:_t(a,"day_clock_a_day",{text:e}),h=[i(Cv(u.totalMs))];u.count>0&&h.push(i(function(e,t){const o=Math.round(10*t)/10;return 1===o?_t(e,"day_clock_times_one"):_t(e,"day_clock_times",{n:o})}(a,u.count))),u.stat=h.join(" · ")}else{const t=d.map(e=>({...e,v:Ta(e.s),s:void 0}));u.slots=function(e,t,o,a,n,r){const i=new Map;for(let s=0;s+1=n)break;const c=Va(e,zv(l,o),a,n),d=new Map;for(const e of c){if(null===e.v)continue;const t=Math.floor(xv(e.t,l.start,r)/o);t<0||t*o>=pv||d.set(t,[...d.get(t)??[],e.v])}for(const[e,t]of d)i.set(e,[...i.get(e)??[],Sv(t,a)])}return[...i.entries()].sort((e,t)=>e[0]-t[0]).map(([e,t])=>({index:e,v:t.reduce((e,t)=>e+t,0)/t.length,days:t.length}))}(t,c,r,u.aggregate,h,l.timeZone);const o=Ev(u.aggregate);if(u.heights=function(e,t){if(0===e.length)return[];const o=e.map(e=>e.v);if(t){const e=Math.max(...o.map(Math.abs));return o.map(t=>e>0?Math.abs(t)/e:0)}const a=Math.min(...o),n=Math.max(...o);return o.map(e=>n>a?.15+.85*(e-a)/(n-a):1)}(u.slots,o),u.hasData=u.slots.length>0,u.hasData){const t=u.slots.reduce((e,t)=>t.v>e.v?t:e),i=Zv(t.index*r,a);if(o){const t=qv(u.slots.reduce((e,t)=>e+t.v,0),e,a);u.stat=_t(a,"day_clock_busiest",{total:1===n?t:_t(a,"day_clock_a_day",{text:t}),time:i})}else u.stat=_t(a,"day_clock_peak",{value:qv(t.v,e,a),time:i})}}return u.missing?u.stat=_t(a,"entity_not_found"):u.hasData||"ready"!==f.status||(u.stat=_t(a,"day_clock_no_data")),u});return{status:f.status,error:f.error,rings:y,bounds:c,days:n,bucket:r,live:d,now:t,period:i,back:s,zone:l}}const Vv=new WeakMap;function Yv(e){let t=Vv.get(e);return t||(t={},Vv.set(e,t)),t}function Gv(e,t,o){const a=Yv(e.host);void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0,a.ring===t&&a.item===o||(a.ring=t,a.item=o,e.requestUpdate())}const Xv=new WeakMap;function Jv(e,t){if(Xv.has(e.host))return;const o=6e4-t%6e4+50;Xv.set(e.host,setTimeout(()=>{Xv.delete(e.host),"undefined"!=typeof document&&"hidden"===document.visibilityState?Jv(e,Date.now()):e.requestUpdate()},o))}const Kv=-132,Qv=-120,ey=264,ty=240,oy=92;function ay(e,t,o){const a=Vv.get(e.host);if(void 0===a?.ring||void 0===a.item)return W;const n=t.rings[a.ring],r=o[a.ring]?.[a.item];if(!n||!r)return W;const{time:i,value:s}=function(e,t,o,a){const n=(e,t)=>_t(a,"day_clock_range",{from:e,to:t});if("numeric"===e.kind){const r=e.slots[t],i=n(Zv(r.index*o.bucket,a),Zv((r.index+1)*o.bucket,a)),s=qv(r.v,e.entity,a);return{time:i,value:1===o.days?s:_t(a,"day_clock_average_value",{value:s})}}if(o.days>1){const o=e.runs[t];return{time:n(Zv(o.from,a),Zv(o.to,a)),value:_t(a,"day_clock_on_days",{on:o.on,of:o.of})}}const r=e.intervals[t],i=o.live&&r.end>=o.now-1e3,s=i?_t(a,"day_clock_now").toLocaleLowerCase():Ko(r.end,a),l=Cv(r.end-r.start);return{time:n(Ko(r.start,a),s),value:i?_t(a,"day_clock_so_far",{duration:l}):l}}(n,r.item,t,e.hass),[l,c]=jv(r.r+4,r.angle);return R``}function ny(e,t,o){const a=t.toLocaleLowerCase().includes(o.toLocaleLowerCase())?_t(e,"day_clock_on"):o;return _t(e,"day_clock_state",{name:t,state:a})}function ry(e,t){const o=e.entity;if(!o.stateObj)return o.display;if("numeric"===e.kind)return _t(t,"day_clock_state",{name:o.name,state:o.display});const a=Iv(o,"state"),n=a?o.stateObj.attributes?.[a]:o.stateObj.state;return"string"!=typeof n?_t(t,"day_clock_state",{name:o.name,state:o.display}):ny(t,o.name,Rv(t,o,n,a))}function iy(e){return e.rings.find(e=>!e.missing)}function sy(e,t,o){const a=Math.min(o,t/Math.max(1,.56*e.length)),n=.62*o;if(a>=n)return{size:a,text:e};const r=Math.max(3,Math.floor(t/(.56*n))-1);return{size:n,text:`${e.slice(0,r).trimEnd()}…`}}function ly(e,t,o){const a=function(e,t){const o=e.raw.center??"total";if("none"===o||0===t.rings.length)return;const{hass:a}=e,n=iy(t);if("now"===o&&t.live&&1===t.days&&n)return[Ko(t.now,a),ry(n,a)];const r=t.rings.find(e=>"state"===e.kind&&e.hasData);if(r){const e=r.word?Rv(a,r.entity,r.word,Iv(r.entity,"state")):_t(a,"day_clock_on"),o=ny(a,r.entity.name,e);return[Cv(r.totalMs),1===t.days?o:_t(a,"day_clock_per_day",{label:o})]}const i=t.rings.find(e=>"numeric"===e.kind&&e.hasData);if(!i)return;const s=Ev(i.aggregate)?qv(i.slots.reduce((e,t)=>e+t.v,0),i.entity,a):i.entity.display;return[s,i.entity.name]}(e,t);if(!a)return W;const n=Math.min(18,.4*o),r=Math.min(11,.25*o),i=(1.05*n+1.3*r)/2,s=2*Math.sqrt(Math.max(0,o*o-i*i))-8,l=sy(a[0],s,n),c=sy(a[1],s,r),d=.3*l.size+c.size,u=(-.75*l.size+d+.3*c.size)/2;return Z``}function cy(e,t){const o=function(e,t=92,o=44){const a=e.map(e=>"state"===e?15:22),n=a.reduce((e,t)=>e+t,0)+4*Math.max(0,e.length-1),r=t-o,i=n>r?r/n:1,s=[];let l=t;for(const c of a){const e=c*i;s.push({r0:l-e,r1:l}),l-=e+4*i}return{rings:s,hole:s.length?s[s.length-1].r0:t}}(t.rings.map(e=>e.kind),oy),a=sr(e.raw),n=t.rings.map((e,a)=>{const{r0:n,r1:r}=o.rings[a];return"state"===e.kind?function(e,t,o,a){const n=t.zone.timeZone;return 1===t.days?e.intervals.map((e,r)=>{const i=wv(e,t.bounds[0],n),s=i.map(([e,t])=>Lv(o,a,$v(e),$v(t))).join("");return{d:s,angle:$v(i.length?(i[0][0]+i[i.length-1][1])/2:0),r:a,item:r}}):e.runs.map((e,t)=>({d:Lv(o,a,$v(e.from),$v(e.to)),angle:$v((e.from+e.to)/2),r:a,opacity:Math.max(.12,e.on/Math.max(1,e.of)),item:t}))}(e,t,n,r):function(e,t,o,a){const n=pv/t.bucket,r=Math.min(.03,hv/n/8),i=Ev(e.aggregate),s=[];return e.slots.forEach((t,l)=>{if(i&&0===t.v)return;const c=t.index/n*hv+r,d=(t.index+1)/n*hv-r,u=o+Math.max(.04,e.heights[l])*(a-o);s.push({d:Lv(o,u,c,d),angle:(c+d)/2,r:u,item:l})}),s}(e,t,n,r)}),r=Vv.get(e.host),i=t.rings.map((i,s)=>{const{r0:l,r1:c}=o.rings[s],d=1===t.days&&n[s].length<=48,u="state"===i.kind?Z``:Z``;return Z` + ${u} + ${n[s].map((t,o)=>Z`Gv(e,s,o):W} + @pointerdown=${a?()=>Gv(e,s,o):W} + @pointerleave=${a?t=>function(e,t){if("touch"!==t.pointerType&&"pen"!==t.pointerType)return void Gv(e,void 0,void 0);const o=Yv(e.host);void 0!==o.timer&&clearTimeout(o.timer),o.timer=setTimeout(()=>{o.timer=void 0,Gv(e,void 0,void 0)},or)}(e,t):W} + @focus=${a?()=>Gv(e,s,o):W} + @blur=${a?()=>Gv(e,void 0,void 0):W} + >`)} + `}),s=function(e){const t=e?.locale?.language??"en";let o=Go(e);if(void 0===o)try{o=!0===new Intl.DateTimeFormat(t,{hour:"numeric"}).resolvedOptions().hour12}catch{o=!1}return o?[0,6,12,18].map(e=>new Intl.DateTimeFormat(t,{hour:"numeric",hour12:!0,timeZone:"UTC"}).format(Date.UTC(2e3,0,1,e))):["00","06","12","18"]}(e.hass),l=Array.from({length:24},(e,t)=>{const o=t/24*hv,a=t%6==0,[n,r]=jv(95,o),[i,s]=jv(oy+(a?9:6),o);return Z``}),c=Z` + ${s[0]} + ${s[1]} + ${s[2]} + ${s[3]}`;let d=W;if(t.live&&1===t.days){const e=$v(xv(t.now,t.bounds[0],t.zone.timeZone)),[a,n]=jv(o.hole,e),[r,i]=jv(101,e);d=Z` + + + `}const u=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_day_clock"),names:t.rings.map(e=>e.entity.name).join(", ")});return R`
+ + ${l}${c} + ${i} + ${d} ${ly(e,t,o.hole)} + + ${a?ay(e,t,n):W} +
`}function dy(e,t,o=!1){return R`
+ ${e} +
${t}
+
`}const uy={layout:"chart",group:"history",height:280,styles:r` + ${$n} + ${mr} + .dc-body { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 20px; + padding-bottom: 4px; + } + .dc-dial { + position: relative; + flex: 0 1 280px; + min-width: 200px; + aspect-ratio: 264 / 240; + } + .dc-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + } + .dc-track { + fill: var(--cd-track); + } + .dc-base { + fill: none; + stroke: var(--divider-color, rgba(127, 127, 127, 0.3)); + stroke-width: 1; + } + .dc-mark { + outline: none; + transition: fill-opacity 0.15s ease; + } + .dc-ring:hover .dc-mark:not(.hover), + .dc-ring:focus-within .dc-mark:not(.hover) { + fill-opacity: 0.75; + } + .dc-mark.hover { + stroke: var(--primary-text-color); + stroke-width: 1.2; + paint-order: stroke; + } + .dc-ring { + cursor: pointer; + outline: none; + } + .dc-ring:focus-visible .dc-track, + .dc-ring:focus-visible .dc-base { + stroke: var(--primary-color); + stroke-width: 1.5; + } + .dc-tick { + stroke: var(--secondary-text-color); + stroke-opacity: 0.5; + stroke-width: 1; + } + .dc-tick.major { + stroke-opacity: 1; + stroke-width: 1.5; + } + .dc-label { + fill: var(--secondary-text-color); + font-size: 10px; + } + .dc-future { + fill: var(--card-background-color, #fff); + fill-opacity: 0.55; + pointer-events: none; + } + .dc-hand { + stroke: var(--primary-text-color); + stroke-width: 2; + stroke-linecap: round; + pointer-events: none; + } + .dc-hand-dot { + fill: var(--primary-text-color); + } + .dc-centre text { + text-anchor: middle; + pointer-events: none; + } + .dc-centre-value { + fill: var(--primary-text-color); + font-weight: 500; + } + .dc-centre-label { + fill: var(--secondary-text-color); + } + .dc-marks { + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .dc-marks { + opacity: 1; + } + :host([static]) .dc-marks, + :host([static]) .dc-mark { + transition: none; + } + .dc-legend { + flex: 1 1 160px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; + } + .dc-item { + display: flex; + gap: 8px; + align-items: baseline; + min-width: 0; + } + .dc-swatch { + flex: none; + width: 10px; + height: 10px; + border-radius: 2px; + } + .dc-text { + min-width: 0; + } + .dc-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .dc-stat { + color: var(--secondary-text-color); + font-size: 0.875em; + } + .dc-item.missing .dc-name { + color: var(--secondary-text-color); + } + .dc-foot { + margin-top: 4px; + } + .dc-now { + font-weight: 500; + font-size: 1.1em; + } + .dc-tip { + position: absolute; + pointer-events: none; + z-index: 1; + white-space: nowrap; + } + .dc-tip.tip-right { + transform: translate(6px, 0); + } + .dc-tip.tip-left { + transform: translate(calc(-100% - 6px), 0); + } + .dc-tip.tip-right.tip-up { + transform: translate(6px, -100%); + } + .dc-tip.tip-left.tip-up { + transform: translate(calc(-100% - 6px), -100%); + } + `,thumbnail:()=>Z` + + + + + + `,validate:function(e){lr(e),$a(e.period);const t=e.days;if(void 0!==t&&("number"!=typeof t||!Number.isInteger(t)||t<1||t>31))throw new Error('"days" must be a whole number from 1 to 31');if(void 0!==e.center&&!Pv.includes(e.center))throw new Error(`"center" must be one of: ${Pv.join(", ")}`);if(void 0!==e.bucket&&!Mv.includes(Number(e.bucket)))throw new Error(`"bucket" must be one of: ${Mv.join(", ")}`);if(void 0!==e.aggregate&&!qa.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${qa.join(", ")}`);for(const o of e.entities??[]){if("object"!=typeof o||null===o)continue;const e=o.active;if(void 0===e)continue;const t="string"==typeof e&&""!==e.trim()||Array.isArray(e)&&e.every(e=>"string"==typeof e);if(!t)throw new Error('"active" must be a state or a list of states')}},editor:function(e){var t;return Pi({...e,config:(t=e.config,"number"==typeof t.bucket?{...t,bucket:String(t.bucket)}:t),commit:t=>e.commit(function(e){return"string"==typeof e.bucket?{...e,bucket:Number(e.bucket)}:e}(t))},"chart",Fi("day_clock",e.hass,()=>{return[{type:"grid",name:"",schema:[{name:"days",selector:{number:{min:1,max:31,mode:"box"}}},{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"center",selector:_i(t=e.hass,"day_clock_center",["total","now","none"])},{type:"grid",name:"",schema:[{name:"bucket",selector:_i(t,"bucket",Mv.map(String))},{name:"aggregate",selector:_i(t,"aggregate",["auto",...qa])}]},{name:"tooltip",selector:{boolean:{}}}];var t}),Tv)},entityFields:function(){return{schema:Ov,fields:Fv}},disconnect(e){bn(e),function(e){const t=Xv.get(e);void 0!==t&&clearTimeout(t),Xv.delete(e)}(e);const t=Vv.get(e);void 0!==t?.timer&&clearTimeout(t.timer),Vv.delete(e)},render(e){const t=Wv(e);t.live&&Jv(e,t.now);const o=vn({period:t.period,range:{start:t.bounds[0],end:t.bounds[t.bounds.length-1]},back:t.back,zone:t.zone},e,t.now);return"loading"===t.status?dy(o,_t(e.hass,"chart_loading")):"error"===t.status?dy(o,_t(e.hass,"chart_error",{error:t.error??""}),!0):t.rings.every(e=>!e.hasData)?dy(o,_t(e.hass,"chart_no_data")):R`
+ ${o} +
${cy(e,t)} ${function(e,t){const{hass:o}=e;let a=W;return t.days>1?a=R`
+
${_t(o,"day_clock_average",{n:t.days})}
+
`:t.live&&iy(t)&&(a=R`
+
${_t(o,"day_clock_now")}
+
${Ko(t.now,o)} · ${ry(iy(t),o)}
+
`),R`
+ ${t.rings.map(e=>R`
+ +
+
${e.entity.name}
+
${e.stat}
+
+
`)} + ${a} +
`}(e,t)}
+
`}};function hy(e,t){if(null!=e&&""!==e){if("number"==typeof e&&Number.isFinite(e))return{value:e,soft:!1};if("string"==typeof e){const t=e.trim(),o=t.startsWith("~"),a=Number(o?t.slice(1):t);if(""!==t&&"~"!==t&&Number.isFinite(a))return{value:a,soft:o}}throw new Error(`"${t}" must be a number, or ~ and a number for a soft limit (e.g. "~0")`)}}function py(e,t={}){let[o,a]=e??[t.min?.value??0,t.max?.value??1];if(t.fromZero&&(o>0&&(o=0),a<0&&(a=0)),void 0!==t.minRange&&a-os.value?r:s.value),r<=n&&(r=n+1),[n,r]}function my(e,t,o){const a=[];for(const c of e){const e=Math.min(c.end??c.t,o);if(c.t>=o)break;a.push({...c,start:c.t,end:e,t:Math.round((c.t+e)/2)})}const n=a.filter(e=>null!==e.v);if(0===n.length)return a;const r=a[0],i=a[a.length-1],s=[...a];null!==r.v&&r.t>t.start&&s.unshift({t:t.start,v:r.v,carried:!0});const l=Math.min(t.end,o);return null!==i.v&&i.t({type:"grid",name:"",schema:e}),yy=e=>({name:e,selector:{boolean:{}}}),by=(e,t,o,a="any")=>({name:e,selector:{number:{mode:"box",step:a,...void 0!==t?{min:t}:{},...void 0!==o?{max:o}:{}}}}),_y=e=>({name:e,selector:{text:{}}});function xy(e){const t={};for(const o of["y_min","y_max","y_min_secondary","y_max_secondary"])"number"==typeof e[o]&&(t[o]=String(e[o]));return t}const wy=[{name:"series_color",path:["color"]},{name:"curve",path:["curve"]},{name:"line_width",path:["line_width"]},{name:"entity_aggregate",path:["aggregate"]},{name:"y_axis_side",path:["y_axis"],default:"primary"},{name:"fixed_value",path:["fixed_value"],default:!1},{name:"show_graph",path:["show_graph"],default:!0},{name:"show_line",path:["show_line"],default:!0},{name:"show_fill",path:["show_fill"],default:!0},{name:"show_points",path:["show_points"]},{name:"show_legend_entity",path:["show_legend"],default:!0},{name:"show_legend_state",path:["show_legend_state"],default:!1},{name:"show_indicator",path:["show_indicator"],default:!1},{name:"show_state",path:["show_state"],default:!1}];yt("en",{editor_style_trend:"Trend",trend_min:"Low",trend_avg:"Average",trend_max:"High"});const $y=["line","bar","none"],ky=["solid","fade","none"],My=["hover","always","never"],zy=["default","left","right","center"],Ay=["right","left","state"],Sy=["left","right","center"],Ey=["smooth","hard"];function Cy(e,t){return void 0===e?t:!0===e?"always":!1===e?"never":e}function jy(e){return void 0===e||!0===e?"fade":!1===e?"none":e}const Ny=e=>"number"==typeof e&&Number.isFinite(e)?e:void 0;function Ly(e){return{graph:e.graph??"line",fill:jy(e.fill),points:Cy(e.points,"hover"),labels:Cy(e.labels,"hover"),pointsPerHour:Ny(e.points_per_hour)??.5,height:Ny(e.height)??150,lineWidth:Ny(e.line_width)??2.5,barSpacing:Ny(e.bar_spacing)??4,transition:e.color_transition??"smooth",extrema:!0===e.extrema,average:!0===e.average,legend:!1!==e.legend,factor:Math.pow(10,Ny(e.value_factor)??0),alignHeader:e.align_header??"default",alignIcon:e.align_icon??"right",alignState:e.align_state??"left",fontSize:Ny(e.font_size)??100,fontSizeHeader:Ny(e.font_size_header),colorName:!0===e.color_name,colorValue:!0===e.color_value,colorIcon:!0===e.color_icon,group:!0===e.group,iconImage:"string"==typeof e.icon_image&&""!==e.icon_image?e.icon_image:void 0,primary:{min:hy(e.y_min,"y_min"),max:hy(e.y_max,"y_max"),minRange:Ny(e.min_range)},secondary:{min:hy(e.y_min_secondary,"y_min_secondary"),max:hy(e.y_max_secondary,"y_max_secondary"),minRange:Ny(e.min_range_secondary)}}}function Ty(e,t,o){if(void 0!==e&&!t.includes(e))throw new Error(`"${o}" must be one of: ${t.join(", ")}`)}function Oy(e,t,o,a=1/0,n=!1){if(void 0!==e&&("number"!=typeof e||!Number.isFinite(e)||ea||n&&!Number.isInteger(e))){throw new Error(`"${t}" must be ${n?"a whole number":"a number"} ${a===1/0?`${o} or more`:`from ${o} to ${a}`}`)}}function Fy(e,t){if(void 0!==e&&"boolean"!=typeof e)throw new Error(`"${t}" must be true or false`)}function Py(e,t,o){const a=Math.min(e.range.end,Date.now()),n="bar"===o.graph,r=n&&0===o.pointsPerHour?.5:o.pointsPerHour,i=e.group?[]:function(e,t,o=500){const a=e.end-e.start;if(a<=0||t<=0)return[];const n=Math.max(1,Math.min(o,Math.round(a/36e5*t))),r=a/n,i=[];for(let s=0;s<=n;s++)i.push(s===n?e.end:Math.round(e.start+s*r));return i}(e.range,r,n?96:500),s=t.raw.aggregate??"mean";return e.series.map(t=>{const n=t.entity.config;let r=t.points;n.fixed_value&&null!==t.entity.value?r=[{t:e.range.start,v:t.entity.value},{t:a,v:t.entity.value}]:e.group?r=my(t.points,e.range,a):i.length&&(r=my(function(e,t,o,a,n){if(t.length<2)return e;let r=e;const i=e[e.length-1],s=e[e.length-2];return"history"===a&&i&&s&&i.t===n&&i.v===s.v&&(r=e.slice(0,-1)),"statistics"===a?Ya(r,t,o,n):Va(r,t,o,n)}(t.points,i,n.aggregate??s,e.source,a),e.range,a)),1!==o.factor&&(r=r.map(e=>null===e.v?e:{...e,v:e.v*o.factor}));const l=void 0!==n.color?Ce(n.color):void 0,c=!l&&(e=>e.config.severity!==st)(t.entity),d=null===t.entity.value?null:t.entity.value*o.factor,u=l??(c&&null!==d?Ce(Te(d,t.entity.bands).color):t.color);return{index:t.index,entity:t.entity,own:n,points:r,color:c?void 0:l??t.color,current:u,curve:t.curve,secondary:"secondary"===n.y_axis,graphed:!1!==n.show_graph}})}function Dy(e){let t=1/0,o=-1/0;for(const a of e)for(const e of a.points)null!==e.v&&(e.vo&&(o=e.v));return t===1/0?void 0:[t,o]}const Uy=e=>Math.round(1e4*e)/100+"%";function Hy(e,t,o){return e.filter(({series:e})=>void 0===e.color).map(({series:e})=>{const a=function(e,t,o){const[a,n]=t,r=e=>Math.min(1,Math.max(0,(e-a)/(n-a))),i=[];return e.forEach((t,a)=>{const n=Ce(t.color),s=Number.isFinite(t.from)?r(t.from):0,l=a+1 + ${a.map(e=>Z``)} + `})}const Iy=e=>e.color??`url(#cd-trend-${e.index})`;function By(e,t){return void 0!==e.start&&void 0!==e.end&&e.end>e.start?`${Ko(e.start,t.hass)} – ${Ko(e.end,t.hass)}`:Ko(e.t,t.hass)}function Ry(e,t,o,a){const n=o.colorIcon?`color:${a}`:"";return o.iconImage?R``:R` + + `}function Zy(e,t,o,a){if(!t.extrema&&!t.average)return W;const n=function(e,t){let o,a,n=0,r=0;for(let i=0;ia.v)&&(a={t:s.t,v:s.v})),t){const t=e[i+1],o=t?t.t-s.t:0;n+=s.v*o,r+=o}else s.carried||(n+=s.v,r+=1)}if(o&&a)return{min:o,max:a,mean:r>0?n/r:(o.v+a.v)/2}}(e.points,a);if(!n)return W;const r=(t,a,n)=>R`${_t(o.hass,t)} ${(t=>Mn(kn(t,e.entity.decimals,o.hass),e.entity.unit))(a)}${void 0!==n?R` · ${Ko(n,o.hass)}`:W}`;return R`
+ ${t.extrema?r("trend_min",n.min.v,n.min.t):W} + ${t.average?r("trend_avg",n.mean):W} + ${t.extrema?r("trend_max",n.max.v,n.max.t):W} +
`}const qy={layout:"chart",group:"history",height:170,gridOptions:{columns:6,min_columns:3,min_rows:2},styles:r` + ${$n} + ${mr} + .content.style-trend { + padding: 0; + flex: 1; + display: flex; + flex-direction: column; + } + .content.style-trend > .chart { + flex: 1; + display: flex; + flex-direction: column; + } + /* The graph sits on the card's bottom edge, its corners cut by the card's rounding. */ + ha-card:has(> .content.style-trend) { + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; + } + ha-card:has(> .content.style-trend .trend.group) { + box-shadow: none; + border: none; + background: none; + } + .trend { + flex: 1; + display: flex; + flex-direction: column; + } + .trend-top { + padding: 16px 16px 0; + } + .trend.group .trend-top { + padding: 0 0 0; + } + .trend.no-graph .trend-top { + padding-bottom: 16px; + } + .trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + font-size: 14px; + } + .trend-header.icon-left { + flex-direction: row-reverse; + justify-content: flex-end; + } + .trend-header.align-left { + justify-content: flex-start; + } + .trend-header.align-right { + justify-content: flex-end; + } + .trend-header.align-center { + justify-content: center; + } + .trend-name { + font-size: 1.15em; + font-weight: 500; + line-height: 1.4em; + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .trend-icon { + flex: none; + display: inline-flex; + color: var(--state-icon-color, #44739e); + } + .trend-icon ha-state-icon { + --mdc-icon-size: 1.7em; + width: 1.7em; + height: 1.7em; + } + .trend-icon img { + width: 1.7em; + height: 1.7em; + object-fit: contain; + } + .trend-states { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0 16px; + min-width: 0; + padding-bottom: 4px; + } + .trend-states.align-right { + flex-direction: row-reverse; + } + .trend-states.align-center { + justify-content: space-evenly; + } + .trend-states .trend-icon { + align-self: center; + font-size: 1.15em; + } + .trend-state { + position: relative; + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; + cursor: pointer; + border-radius: 4px; + } + .trend-state:not(.small) { + margin-top: 4px; + } + .trend-value { + font-size: 2.25em; + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.01em; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + .trend-unit { + font-size: 1.15em; + font-weight: 400; + color: var(--secondary-text-color); + white-space: nowrap; + } + /* While hovering, the reading's time sits after the value. */ + .trend-when { + align-self: center; + margin-left: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + } + .trend-secondary { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + min-width: 0; + padding-top: 8px; + } + .trend-states.align-right .trend-secondary { + align-items: flex-start; + } + .trend-state.small { + align-items: center; + gap: 6px; + } + .trend-state.small .trend-value { + font-size: 1em; + font-weight: 500; + letter-spacing: 0; + } + .trend-state.small .trend-unit { + font-size: 0.875em; + } + .trend-state .swatch { + align-self: center; + } + /* Low, average and high, as the line chart's highs and lows. */ + .trend-info { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 2px 0 8px; + } + .trend-chip { + height: 20px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 10px; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-chip.trend_avg { + color: var(--secondary-text-color); + background: color-mix(in srgb, var(--primary-text-color) 6%, var(--card-background-color, #fff)); + } + .trend-chip-type { + font-weight: 500; + } + .trend-chip-time { + color: var(--secondary-text-color); + } + .trend-legend { + padding: 0 0 6px; + } + .trend .entity:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + .trend-graph { + position: relative; + margin-top: auto; + width: 100%; + max-height: 400px; + } + .trend-graph.hoverable { + touch-action: pan-y; + cursor: crosshair; + } + .trend-graph.message { + display: flex; + align-items: center; + justify-content: center; + color: var(--secondary-text-color); + font-size: 0.875em; + min-height: 48px; + } + .trend-graph.message.error { + color: var(--error-color, #db4437); + } + .trend-graph .plot { + overflow: hidden; + } + .style-trend .line { + stroke-linecap: round; + stroke-linejoin: round; + } + .style-trend .dots { + stroke-linecap: round; + } + .style-trend .fill.solid { + fill-opacity: 0.2; + } + /* Columns as on the column chart: rounded at the outer end, growing in from the base. */ + .trend-cols { + position: absolute; + inset: 0; + pointer-events: none; + } + .trend-col { + position: absolute; + box-sizing: border-box; + border-radius: 4px 4px 0 0; + transform-origin: 50% 100%; + animation: cd-trend-grow var(--cd-duration) var(--cd-easing); + } + .trend-col.down { + border-radius: 0 0 4px 4px; + transform-origin: 50% 0; + } + :host([static]) .trend-col { + animation: none; + } + @keyframes cd-trend-grow { + from { + transform: scaleY(0); + } + } + .trend-labels-wrap { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 1; + } + .trend-labels-wrap.labels-hover { + opacity: 0; + } + .trend-graph:hover .trend-labels-wrap.labels-hover, + .trend-graph:active .trend-labels-wrap.labels-hover { + opacity: 1; + } + .trend-labels { + position: absolute; + top: 0; + bottom: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + padding: 6px 8px; + box-sizing: border-box; + } + .trend-labels.primary { + left: 0; + } + .trend-labels.secondary { + right: 0; + align-items: flex-end; + } + /* Tinted with the series colour, as the line chart's high and low labels. */ + .trend-labels span { + height: 18px; + box-sizing: border-box; + padding: 0 6px; + border-radius: 9px; + font-size: 11px; + line-height: 18px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + color: var(--primary-text-color); + background: color-mix(in srgb, var(--series) 16%, var(--card-background-color, #fff)); + } + .trend-graph .crosshair { + z-index: 1; + pointer-events: none; + } + .trend-graph .hover-dot { + z-index: 2; + pointer-events: none; + } + .bar-hover { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 7%, transparent); + pointer-events: none; + } + `,thumbnail:()=>Z` + + + + + `,validate:function(e){pn(e),lr(e),Ty(e.graph,$y,"graph"),Ty(e.fill,[...ky,!0,!1],"fill"),Ty(e.points,[...My,!0,!1],"points"),Ty(e.labels,[...My,!0,!1],"labels"),Ty(e.color_transition,Ey,"color_transition"),Ty(e.align_header,zy,"align_header"),Ty(e.align_icon,Ay,"align_icon"),Ty(e.align_state,Sy,"align_state"),Oy(e.points_per_hour,"points_per_hour",0,60),Oy(e.height,"height",20,1e3),Oy(e.line_width,"line_width",St,Et),Oy(e.bar_spacing,"bar_spacing",0,50),Oy(e.value_factor,"value_factor",-9,9,!0),Oy(e.font_size,"font_size",25,400),Oy(e.font_size_header,"font_size_header",6,60),Oy(e.min_range,"min_range",0),Oy(e.min_range_secondary,"min_range_secondary",0);for(const t of["extrema","average","legend","color_name","color_value","group"])Fy(e[t],t);if(void 0!==e.icon_image&&"string"!=typeof e.icon_image)throw new Error('"icon_image" must be a URL');Ly(e);for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t,o=t=>`"${t}" of ${e.entity}`;fy(e.aggregate,o("aggregate")),Ty(e.y_axis,["primary","secondary"],`y_axis of ${e.entity}`),Oy(e.line_width,`line_width of ${e.entity}`,St,Et);for(const t of["fixed_value","show_state","show_indicator","show_graph","show_line","show_fill","show_points","show_legend","show_legend_state"])if(void 0!==e[t]&&"boolean"!=typeof e[t])throw new Error(`${o(t)} must be true or false`)}},editor:function(e){const t=Fi(`trend|${wi(e.config)}`,e.hass,()=>{return t=e.hass,o=e.config,[...$i(t,o),by("points_per_hour",0,60),vy({name:"graph",selector:_i(t,"graph",["line","bar","none"])},{name:"fill",selector:_i(t,"fill",["fade","solid","none"])},{name:"curve",selector:_i(t,"curve",an)},{name:"line_width",selector:Vt},{name:"points",selector:_i(t,"points",["hover","always","never"])},{name:"labels",selector:_i(t,"labels",["hover","always","never"])},by("height",20,1e3,1),by("bar_spacing",0,50,1)),{name:"color_transition",selector:_i(t,"color_transition",["smooth","hard"])},vy(yy("extrema"),yy("average"),yy("legend"),yy("trend_tooltip")),vy(_y("trend_y_min"),_y("trend_y_max"),by("min_range",0)),vy(_y("y_min_secondary"),_y("y_max_secondary"),by("min_range_secondary",0)),vy({name:"align_header",selector:_i(t,"align_header",["default","left","right","center"])},{name:"align_icon",selector:_i(t,"align_icon",["right","left","state"])},{name:"align_state",selector:_i(t,"align_state",["left","right","center"])},by("font_size",25,400,1),by("font_size_header",6,60,1),by("value_factor",-9,9,1)),vy(yy("color_name"),yy("trend_color_icon"),yy("color_value"),yy("group")),{name:"icon_image",selector:{text:{}}}];var t,o}),o={...e,config:{...e.config,...xy(e.config)},commit:t=>e.commit(function(e){let t=e;for(const o of["y_min","y_max","y_min_secondary","y_max_secondary"]){const e=t[o];"string"==typeof e&&""!==e.trim()&&!e.trim().startsWith("~")&&Number.isFinite(Number(e))?t={...t,[o]:Number(e)}:""===e&&(t={...t},delete t[o])}return t}(t))};return Pi(o,"chart",t,gy,{legend:"editor_trend_legend",group:"editor_trend_group"})},entityFields:function(e){return{schema:Fi("trend-entity",e,()=>function(e){return[vy({name:"series_color",selector:{text:{}}},{name:"curve",selector:_i(e,"curve",an)},{name:"line_width",selector:Vt},{name:"entity_aggregate",selector:_i(e,"aggregate",qa)},{name:"y_axis_side",selector:_i(e,"y_axis_side",["primary","secondary"])}),vy(yy("show_state"),yy("show_indicator"),yy("show_graph"),yy("show_line"),yy("show_fill"),yy("show_points"),yy("show_legend_entity"),yy("show_legend_state"),yy("fixed_value"))]}(e)),fields:wy}},disconnect(e){bn(e),Fn(e),ir(e)},render:function(e){const t=Ly(e.raw),{show:o}=e.config,a=mn(e),n=Py(a,e,t),r=n[0],i=r.entity,s=n.filter(e=>e.graphed),l=function(e,t){if(void 0!==t)return e.map(e=>{const o=e.points.filter(e=>!e.carried),a=ar(o,t);return a<0?void 0:o[a]})}(n,sr(e.raw)&&"ready"===a.status?rr(e.host):void 0),c=t.colorName?`color:${r.current}`:"",d=o.icon&&"state"!==t.alignIcon?Ry(i,e,t,r.current):W,u=o.name||d!==W?R`
+ ${o.name?R`${i.name}`:W} ${d} +
`:W,h=(o,a)=>{const n=l?.[a],r=n?n.v:o.entity.value,i=n?{number:null===n.v?"—":kn(n.v,o.entity.decimals,e.hass),unit:o.entity.unit}:function(e,t,o,a){return null===e?{number:t.display,unit:""}:{number:kn(e*o,t.decimals,a),unit:t.unit}}(r,o.entity,t.factor,e.hass);return{parts:i,color:n&&null!==n.v&&void 0===o.color?Ce(Te(n.v,o.entity.bands).color):o.current,when:n?By(n,e):void 0}},p=h(r,0),m=o.value,f=n.slice(1).filter(e=>!0===e.own.show_state),g=m||f.length||"state"===t.alignIcon?R`
+ ${o.icon&&"state"===t.alignIcon?Ry(i,e,t,r.current):W} + ${m?R`
+ ${r.own.show_indicator?R``:W} + ${p.parts.number}${p.parts.unit?R`${p.parts.unit}`:W} + ${p.when?R`${p.when}`:W} +
`:W} + ${f.length?R`
+ ${f.map(e=>{const o=h(e,e.index);return R`
+ ${!1!==e.own.show_indicator?R``:W} + ${o.parts.number}${o.parts.unit?R`${o.parts.unit}`:W} +
`})} +
`:W} +
`:W,v=s.filter(e=>!1!==e.own.show_legend),y=t.legend&&s.length>1&&v.length?R`
+ ${v.map(t=>R`
+ + ${function(e,t){const o=t.raw.name;return void 0===o||e.entity.name!==o?e.entity.name:e.entity.stateObj?.attributes.friendly_name??e.entity.entityId}(t,e)} + ${t.own.show_legend_state?R`${t.entity.display}`:W} +
`)} +
`:W,b=vn(a,e),_="none"===t.graph?W:function(e,t,o,a,n){const r=`aspect-ratio:500 / ${a.height}`;if("ready"!==t.status||o.every(e=>e.points.every(e=>null===e.v))){const o="loading"===t.status?"chart_loading":"error"===t.status?"chart_error":"chart_no_data";return R`
+ ${_t(e.hass,o,{error:t.error??""})} +
`}const i="bar"===a.graph,s=new Map;for(const b of[!1,!0]){const e=o.filter(e=>e.secondary===b);e.length&&s.set(b,py(Dy(e),{...b?a.secondary:a.primary,fromZero:i}))}const l=$o([t.range.start,t.range.end],[0,cn]),c=o.map(e=>({series:e,y:$o(s.get(e.secondary),[dn,0])})),d=i?Z``:Z`${c.map(e=>function({series:e,y:t},o,a){const n=ln(e.points).map(a=>{const n=a.map(e=>[o(e.t),t(e.v)]);return{pts:n,d:sn(n,e.curve)}}),r=!1!==e.own.show_line,i="none"!==a.fill&&!1!==e.own.show_fill,s=e.own.line_width??a.lineWidth,l=e.own.show_points??"always"===a.points,c=e.points.filter(e=>null!==e.v&&!e.carried);return Z` + ${i?Z``${t}L${e[e.length-1][0]},200L${e[0][0]},200Z`).join("")} + fill=${Iy(e)} + mask=${"fade"===a.fill?"url(#cd-trend-fade)":W} + >`:W} + ${r?Z`e.d).join("")} fill="none" stroke=${Iy(e)} + style="stroke-width:${s}px">`:W} + ${l&&c.length<=200?Z`[o(e.t),t(e.v)]))} fill="none" + stroke=${Iy(e)} style="stroke-width:${Lt(s)}px">`:W} + `}(e,l,a))}`,u=Z`${Hy(c,s,a)} + + + + + + + `,h=_t(e.hass,"chart_aria",{style:_t(e.hass,"editor_style_trend"),names:o.map(e=>e.entity.name).join(", ")}),p=t=>{const n=o.filter(e=>e.secondary===t),r=Dy(n);if(!r)return W;const i=t?a.secondary:a.primary,s=i.min&&!i.min.soft?i.min.value:r[0],l=i.max&&!i.max.soft?i.max.value:r[1],c=n[0].entity,d=t=>Mn(kn(t,c.decimals,e.hass),c.unit);return R`
+ ${d(l)}${d(s)} +
`},m="never"===a.labels?W:R`
${p(!1)}${p(!0)}
`,f=n&&!i&&n[o[0].index]?R`
`:W,g=n&&!i?c.map(({series:e,y:t})=>{const o=n[e.index];if(!o||null===o.v||!1===e.own.show_points)return W;const a=t(o.v)/dn;if(!(a>=0&&a<=1))return W;const r=e.color??Ce(Te(o.v,e.entity.bands).color);return R``}):W,v=n&&i&&n[o[0].index]?(()=>{const e=n[o[0].index];return void 0===e.start||void 0===e.end?W:R`
`})():W,y=sr(e.raw)?cr(e,t.range,o.map(e=>e.points.filter(e=>!e.carried))):void 0;return R`
+ ${wn(d,h,u)} ${i?function(e,t,o){const a=e.length,n=e.flatMap(({series:e,y:n},r)=>{const i=Math.min(dn,Math.max(0,n(0)));return e.points.filter(e=>null!==e.v&&!e.carried&&void 0!==e.start&&void 0!==e.end).map(s=>{const l=t(s.start)/cn,c=(t(s.end)-t(s.start))/cn,d=Math.min(dn,Math.max(0,n(s.v))),u=d>i,h=e.color??Ce(Te(s.v,e.entity.bands).color),p=`(${Uy(c)} - ${o}px) / ${a}`;return R`
`})});return R`
${n}
`}(c,l,a.barSpacing):W} ${m} ${v} + ${f} ${"never"===a.points?W:g} +
`}(e,a,s,t,l);return R`
+
+ ${u} ${b===W?W:R`
${b}
`} ${g} + ${Zy(r,t,e,"history"===a.source&&!a.group&&0===t.pointsPerHour)} + ${y} +
+ ${_} +
`}},Wy=["bars","gauges","history","totals","compare","distribution"];function Vy(e){return"chart"===e.layout}const Yy={bar:rs,thin:ou,segmented:eu,tank:tu,ring:qd,needle:cd,arc:Wd,line:ic,column:Sl,area:Yi,combined:fc,pie:Pd,rings:Jd,pictograph:Cd,waffle:cu,histogram:Xc,scatter:Kd,bubble:rl,heatmap:Pc,box:Ls,funnel:yu,radar:Vu,polar_area:Ku,treemap:bh,sunburst:Ph,icicle:Dh,grouped_bars:Kh,bullet:ap,lollipop:lp,waterfall:vp,slope:Sp,dumbbell:Zp,range_area:Xp,sparkline:nm,streamgraph:bm,candlestick:Em,violin:yf,beeswarm:jf,sankey:Gf,timeline:yg,climate:Hg,wind_rose:uv,day_clock:uy,trend:qy},Gy="important",Xy=" !"+Gy,Jy=me(class extends fe{constructor(e){if(super(e),e.type!==de||"style"!==e.name||e.strings?.length>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(e){return Object.keys(e).reduce((t,o)=>{const a=e[o];return null==a?t:t+`${o=o.includes("-")?o:o.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${a};`},"")}update(e,[t]){const{style:o}=e.element;if(void 0===this.ft)return this.ft=new Set(Object.keys(t)),this.render(t);for(const a of this.ft)null==t[a]&&(this.ft.delete(a),a.includes("-")?o.removeProperty(a):o[a]=null);for(const a in t){const e=t[a];if(null!=e){this.ft.add(a);const t="string"==typeof e&&e.endsWith(Xy);a.includes("-")||t?o.setProperty(a,t?e.slice(0,-11):e,t?Gy:""):o[a]=e}}return q}}),Ky=Object.keys(Fe),Qy=Object.keys(Ee),eb="custom",tb={text:{}},ob={entity:{domain:["sensor","input_number","number"]}}; /** * @license * Copyright 2018 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Vl(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const ql={icon:{}};function Wl(e){return void 0===e?"none":Dl.find(t=>Ne[t]&&Bt(Ne[t],e))??"custom"}function Zl(e){return Hl.includes(e)}class Yl extends le{constructor(){super(),this._customOpen=new Set,this._entityOpen=new Set}willUpdate(e){e.has("bands")&&(this._customOpen.size&&(this._customOpen=new Set),this._entityOpen.size&&(this._entityOpen=new Set))}_entityValue(e){const t=this.hass?.states[e]?.state,n=void 0===t||""===t.trim()?NaN:Number(t);return Number.isFinite(n)?n:void 0}_t(e){return at(this.hass,e)}_getSelectors(){const e=`${ot(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=Dl.map(e=>({value:e,label:this._t(`editor_preset_${e}`)})),n=e=>({select:{mode:"dropdown",options:e}});return this._selectors={key:e,preset:n(t),presetCustom:n([...t,{value:Bl,label:this._t("editor_preset_custom")}]),color:n([...Hl,Bl].map(e=>({value:e,label:this._t(`editor_color_${e}`)}))),from:{number:{mode:"box",step:"any",...this.unit?{unit_of_measurement:this.unit}:{}}}},this._selectors}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e&&e.length?e:void 0}}))}_presetChanged(e){e.stopPropagation();const t=e.detail?.value;if(!t||t===Bl||!Dl.includes(t))return;if(t===Wl(this.bands))return;const n=Ne[t];this._emit(n?structuredClone(n):void 0)}_updateBand(e,t,n){const o=[...this.bands??[]],a={...o[e]};null==n||""===n?delete a[t]:a[t]=n,o[e]=a,this._emit(o)}_fromChanged(e,t){t.stopPropagation();const n=Number(t.detail?.value);""!==t.detail?.value&&null!=t.detail?.value&&Number.isFinite(n)&&this._updateBand(e,"from",n)}_fromEntityChanged(e,t){t.stopPropagation();const n=t.detail?.value;"string"==typeof n&&""!==n&&this._updateBand(e,"from",n)}_toggleFromMode(e){const t=this.bands?.[e]?.from,n=new Set(this._entityOpen);Vl(t)?this._updateBand(e,"from",this._entityValue(t)??0):n.delete(e)||n.add(e),this._entityOpen=n}_colorChanged(e,t){t.stopPropagation();const n=t.detail?.value,o=this.bands?.[e]?.color??"primary";if(!n)return;const a=new Set(this._customOpen);n!==Bl?(a.delete(e),this._customOpen=a,n!==o&&this._updateBand(e,"color",n)):Zl(o)&&(this._customOpen=a.add(e))}_customColorChanged(e,t){t.stopPropagation();const n="string"==typeof t.detail?.value?t.detail.value.trim():"";if(!n)return;const o=new Set(this._customOpen);o.delete(e),this._customOpen=o,this._updateBand(e,"color",n)}_iconChanged(e,t){t.stopPropagation(),this._updateBand(e,"icon",t.detail?.value)}_labelChanged(e,t){t.stopPropagation(),this._updateBand(e,"label",t.detail?.value)}_removeBand(e){const t=[...this.bands??[]];t.splice(e,1),this._emit(t)}_addBand(){const e=[...this.bands??[]],t=e[e.length-1]?.from,n="number"==typeof t?t:Vl(t)?this._entityValue(t)??NaN:"string"==typeof t&&""!==t.trim()?Number(t):NaN;e.push({from:Number.isFinite(n)?n+10:0,color:"primary"}),this._emit(e)}_renderBand(e,t,n){const o=!Zl(e.color)||this._customOpen.has(t),a=Vl(e.from)?e.from:void 0,r=void 0!==a||this._entityOpen.has(t);return R`
- + */function ab(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const nb={icon:{}};function rb(e){return void 0===e?"none":Ky.find(t=>Fe[t]&&uo(Fe[t],e))??"custom"}function ib(e){return Qy.includes(e)}class sb extends le{constructor(){super(),this._customOpen=new Set,this._entityOpen=new Set}willUpdate(e){e.has("bands")&&(this._customOpen.size&&(this._customOpen=new Set),this._entityOpen.size&&(this._entityOpen=new Set))}_entityValue(e){const t=this.hass?.states[e]?.state,o=void 0===t||""===t.trim()?NaN:Number(t);return Number.isFinite(o)?o:void 0}_t(e){return _t(this.hass,e)}_getSelectors(){const e=`${bt(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=Ky.map(e=>({value:e,label:this._t(`editor_preset_${e}`)})),o=e=>({select:{mode:"dropdown",options:e}});return this._selectors={key:e,preset:o(t),presetCustom:o([...t,{value:eb,label:this._t("editor_preset_custom")}]),color:o([...Qy,eb].map(e=>({value:e,label:this._t(`editor_color_${e}`)}))),from:{number:{mode:"box",step:"any",...this.unit?{unit_of_measurement:this.unit}:{}}}},this._selectors}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e&&e.length?e:void 0}}))}_presetChanged(e){e.stopPropagation();const t=e.detail?.value;if(!t||t===eb||!Ky.includes(t))return;if(t===rb(this.bands))return;const o=Fe[t];this._emit(o?structuredClone(o):void 0)}_updateBand(e,t,o){const a=[...this.bands??[]],n={...a[e]};null==o||""===o?delete n[t]:n[t]=o,a[e]=n,this._emit(a)}_fromChanged(e,t){t.stopPropagation();const o=Number(t.detail?.value);""!==t.detail?.value&&null!=t.detail?.value&&Number.isFinite(o)&&this._updateBand(e,"from",o)}_fromEntityChanged(e,t){t.stopPropagation();const o=t.detail?.value;"string"==typeof o&&""!==o&&this._updateBand(e,"from",o)}_toggleFromMode(e){const t=this.bands?.[e]?.from,o=new Set(this._entityOpen);ab(t)?this._updateBand(e,"from",this._entityValue(t)??0):o.delete(e)||o.add(e),this._entityOpen=o}_colorChanged(e,t){t.stopPropagation();const o=t.detail?.value,a=this.bands?.[e]?.color??"primary";if(!o)return;const n=new Set(this._customOpen);o!==eb?(n.delete(e),this._customOpen=n,o!==a&&this._updateBand(e,"color",o)):ib(a)&&(this._customOpen=n.add(e))}_customColorChanged(e,t){t.stopPropagation();const o="string"==typeof t.detail?.value?t.detail.value.trim():"";if(!o)return;const a=new Set(this._customOpen);a.delete(e),this._customOpen=a,this._updateBand(e,"color",o)}_iconChanged(e,t){t.stopPropagation(),this._updateBand(e,"icon",t.detail?.value)}_labelChanged(e,t){t.stopPropagation(),this._updateBand(e,"label",t.detail?.value)}_removeBand(e){const t=[...this.bands??[]];t.splice(e,1),this._emit(t)}_addBand(){const e=[...this.bands??[]],t=e[e.length-1]?.from,o="number"==typeof t?t:ab(t)?this._entityValue(t)??NaN:"string"==typeof t&&""!==t.trim()?Number(t):NaN;e.push({from:Number.isFinite(o)?o+10:0,color:"primary"}),this._emit(e)}_renderBand(e,t,o){const a=!ib(e.color)||this._customOpen.has(t),n=ab(e.from)?e.from:void 0,r=void 0!==n||this._entityOpen.has(t);return R`
+ this._fromChanged(t,e)} > @@ -2194,8 +6749,8 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="color" .hass=${this.hass} .label=${this._t("editor_band_color")} - .selector=${n.color} - .value=${o?Bl:e.color} + .selector=${o.color} + .value=${a?eb:e.color} @value-changed=${e=>this._colorChanged(t,e)} > this._labelChanged(t,e)} > @@ -2212,7 +6767,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="icon" .hass=${this.hass} .label=${this._t("editor_band_icon")} - .selector=${ql} + .selector=${nb} .value=${e.icon??""} @value-changed=${e=>this._iconChanged(t,e)} > @@ -2235,31 +6790,31 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de data-role="from-entity" .hass=${this.hass} .label=${this._t("editor_band_from_entity_label")} - .selector=${Rl} - .value=${a??""} + .selector=${ob} + .value=${n??""} @value-changed=${e=>this._fromEntityChanged(t,e)} >`:W} - ${o?R`this._customColorChanged(t,e)} >`:W} -
`}render(){const e=this._getSelectors(),t=Wl(this.bands);return R``}render(){const e=this._getSelectors(),t=rb(this.bands);return R` - ${(this.bands??[]).map((t,n)=>this._renderBand(t,n,e))} - `}}Yl.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},Yl.styles=r` + ${(this.bands??[]).map((t,o)=>this._renderBand(t,o,e))} + `}}sb.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},sb.styles=r` :host { display: block; } @@ -2326,7 +6881,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de outline: 2px solid var(--primary-color, #03a9f4); outline-offset: 2px; } - `,customElements.get("cd-bands-editor")||customElements.define("cd-bands-editor",Yl);const Gl={number:{mode:"box",step:"any"}},Xl={entity:{domain:["sensor","input_number","number"]}};function Jl(e){return null==e||""===e?"default":"number"==typeof e||""!==e.trim()&&Number.isFinite(Number(e))?"number":"entity"}class Kl extends le{willUpdate(e){e.has("value")&&"default"!==Jl(this.value)&&(this._mode=void 0)}_modeSelectorFor(){const e=(e,t)=>at(this.hass,e,t),t=[{value:"default",label:e("editor_limit_default",{value:this.defaultLabel??""})},{value:"number",label:e("editor_limit_number")},{value:"entity",label:e("editor_limit_entity")}],n=t.map(e=>e.label).join("|");return this._modeSelector?.key!==n&&(this._modeSelector={key:n,selector:{select:{mode:"dropdown",options:t}}}),this._modeSelector.selector}get _currentMode(){return this._mode??Jl(this.value)}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e}}))}_modeChanged(e){e.stopPropagation();const t=e.detail?.value;t&&t!==this._currentMode&&(this._mode=t,void 0!==this.value&&this._emit(void 0))}_numberChanged(e){e.stopPropagation();const t=e.detail?.value,n="number"==typeof t?t:""===t||null==t?NaN:Number(t);this._mode="number",this._emit(Number.isFinite(n)?n:void 0)}_entityChanged(e){e.stopPropagation();const t=e.detail?.value;this._mode="entity",this._emit("string"==typeof t&&""!==t?t:void 0)}render(){const e=e=>at(this.hass,e),t=this._currentMode;return R`_t(this.hass,e,t),t=[{value:"default",label:e("editor_limit_default",{value:this.defaultLabel??""})},{value:"number",label:e("editor_limit_number")},{value:"entity",label:e("editor_limit_entity")}],o=t.map(e=>e.label).join("|");return this._modeSelector?.key!==o&&(this._modeSelector={key:o,selector:{select:{mode:"dropdown",options:t}}}),this._modeSelector.selector}get _currentMode(){return this._mode??db(this.value)}_emit(e){this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e}}))}_modeChanged(e){e.stopPropagation();const t=e.detail?.value;t&&t!==this._currentMode&&(this._mode=t,void 0!==this.value&&this._emit(void 0))}_numberChanged(e){e.stopPropagation();const t=e.detail?.value,o="number"==typeof t?t:""===t||null==t?NaN:Number(t);this._mode="number",this._emit(Number.isFinite(o)?o:void 0)}_entityChanged(e){e.stopPropagation();const t=e.detail?.value;this._mode="entity",this._emit("string"==typeof t&&""!==t?t:void 0)}render(){const e=e=>_t(this.hass,e),t=this._currentMode;return R``:"entity"===t?R``:W}`}}Kl.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},Kl.styles=r` + >`:W}`}}ub.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},ub.styles=r` :host { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; align-items: start; } - `,customElements.get("cd-limit-field")||customElements.define("cd-limit-field",Kl);const Ql=["min","max","target"];function ec(e,t,n){const o=t&&!n?e?.states[t]?.attributes?.unit_of_measurement:void 0;return"string"==typeof o&&o?o:void 0}class tc extends le{constructor(){super(),this._computeLabel=e=>this._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return at(this.hass,e,t)}get _asConfig(){return{type:"",entities:this.entities??[]}}_emit(e){e.entities!==this.entities&&this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e.entities??[]}}))}_set(e,t,n){this._emit(function(e,t,n,o){if("entity"===n&&(rt(o)||"string"!=typeof o))return e;const a=[...e.entities??[]],r=a[t];if(void 0===r)return e;const i="string"==typeof r?{entity:r}:{...r};rt(o)?delete i[n]:i[n]=o;const s=Object.keys(i);return a[t]=1===s.length&&"entity"===s[0]&&"string"==typeof i.entity?i.entity:i,{...e,entities:a}}(this._asConfig,e,t,n))}_move(e,t){const n=new Set;for(const o of this._expanded)n.add(o===e?t:o===t?e:o);this._expanded=n,this._emit(function(e,t,n){const o=[...e.entities??[]];if(t<0||t>=o.length||t===n)return e;const[a]=o.splice(t,1);return o.splice(Math.max(0,Math.min(n,o.length)),0,a),{...e,entities:o}}(this._asConfig,e,t))}_remove(e){const t=new Set;for(const n of this._expanded)n!==e&&t.add(n>e?n-1:n);this._expanded=t,this._emit(function(e,t){const n=[...e.entities??[]];return t<0||t>=n.length?e:(n.splice(t,1),{...e,entities:n})}(this._asConfig,e))}_toggle(e){const t=new Set(this._expanded);t.delete(e)||t.add(e),this._expanded=t}_add(e){e.stopPropagation();const t=e.detail?.value;"string"==typeof t&&""!==t&&(e.target.value="",this._emit(function(e,t){return{...e,entities:[...e.entities??[],t]}}(this._asConfig,t)))}_formChanged(e,t,n){n.stopPropagation();const o=this.entities?.[e];if(void 0===o)return;const a="string"==typeof o?{entity:o}:o,r=Rt(a,t,n.detail?.value??{});if(r===a)return;const i=[...this.entities??[]],s=Object.keys(r);i[e]=1===s.length&&"entity"===s[0]?r.entity:r,this._emit({type:"",entities:i})}_rowLabel(e){const t=this.hass?.states[e];return t?{text:t.attributes.friendly_name||e,missing:!1}:this.hass?{text:this._t("editor_not_found",{entity:e}),missing:!0}:{text:e,missing:!1}}_form(e,t,n,o,a){return R`this._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return _t(this.hass,e,t)}get _asConfig(){return{type:"",entities:this.entities??[]}}_emit(e){e.entities!==this.entities&&this.dispatchEvent(new CustomEvent("value-changed",{detail:{value:e.entities??[]}}))}_set(e,t,o){this._emit(zt(this._asConfig,e,t,o))}_move(e,t){const o=new Set;for(const a of this._expanded)o.add(a===e?t:a===t?e:a);this._expanded=o,this._emit(function(e,t,o){const a=[...e.entities??[]];if(t<0||t>=a.length||t===o)return e;const[n]=a.splice(t,1);return a.splice(Math.max(0,Math.min(o,a.length)),0,n),{...e,entities:a}}(this._asConfig,e,t))}_remove(e){const t=new Set;for(const o of this._expanded)o!==e&&t.add(o>e?o-1:o);this._expanded=t,this._emit(function(e,t){const o=[...e.entities??[]];return t<0||t>=o.length?e:(o.splice(t,1),{...e,entities:o})}(this._asConfig,e))}_toggle(e){const t=new Set(this._expanded);t.delete(e)||t.add(e),this._expanded=t}_add(e){e.stopPropagation();const t=e.detail?.value;"string"==typeof t&&""!==t&&(e.target.value="",this._emit(function(e,t){return{...e,entities:[...e.entities??[],t]}}(this._asConfig,t)))}_formChanged(e,t,o){o.stopPropagation();const a=this.entities?.[e];if(void 0===a)return;const n="string"==typeof a?{entity:a}:a,r=po(n,t,o.detail?.value??{});if(r===n)return;const i=[...this.entities??[]],s=Object.keys(r);i[e]=1===s.length&&"entity"===s[0]?r.entity:r,this._emit({type:"",entities:i})}_rowLabel(e){const t=this.hass?.states[e];return t?{text:t.attributes.friendly_name||e,missing:!1}:this.hass?{text:this._t("editor_not_found",{entity:e}),missing:!0}:{text:e,missing:!1}}_form(e,t,o,a,n){return R`this._formChanged(t,a,e)} - >`}_renderOverrides(e,t){const n=e=>t=>{t.stopPropagation(),e(t.detail?.value)};return R`
- ${this._form("entity-attribute",t,e,function(e){let t=Lt.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],Lt.set(e,t)),t}(e.entity),$t)} - ${this._form("entity-text",t,e,zt,xt)} + @value-changed=${e=>this._formChanged(t,n,e)} + >`}_renderOverrides(e,t){const o=e=>t=>{t.stopPropagation(),e(t.detail?.value)};return R`
+ ${this._form("entity-attribute",t,e,function(e){let t=eo.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],eo.set(e,t)),t}(e.entity),qt)} + ${this._form("entity-text",t,e,Qt,Zt)} ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):W} this._set(t,"icon",e))} + @value-changed=${o(e=>this._set(t,"icon",e))} > - ${Ql.map(o=>R`R`this._set(t,o,e))} + @value-changed=${o(e=>this._set(t,a,e))} >`)} this._set(t,"severity",e))} + .unit=${e.unit??this.unit??pb(this.hass,e.entity,e.attribute)} + @value-changed=${o(e=>this._set(t,"severity",e))} > - ${this._form("entity-actions",t,e,Ct,St)} -
`}_renderRow(e,t,n){const o=function(e){return"string"==typeof e?e:e.entity}(e),a="string"==typeof e?{entity:e}:e,r=this._rowLabel(o),i=this._expanded.has(t);return R`
+ ${this._form("entity-actions",t,e,Kt,Xt)} +
`}_renderRow(e,t,o){const a=Mt(e),n="string"==typeof e?{entity:e}:e,r=this._rowLabel(a),i=this._expanded.has(t);return R`
${r.text}
{e.stopPropagation(),this._set(t,"entity",e.detail?.value),this.requestUpdate()}} > @@ -2410,7 +6965,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="down" .path=${"M11,4H13V16L18.5,10.5L19.92,11.92L12,19.84L4.08,11.92L5.5,10.5L11,16V4Z"} .label=${this._t("editor_move_down")} - .disabled=${t===n-1} + .disabled=${t===o-1} @click=${()=>this._move(t,t+1)} > this._toggle(t)} >
- ${i?this._renderOverrides(a,t):W} -
`}render(){const e=this.entities??[];return R`${e.map((t,n)=>this._renderRow(t,n,e.length))} + ${i?this._renderOverrides(n,t):W} +
`}render(){const e=this.entities??[];return R`${e.map((t,o)=>this._renderRow(t,o,e.length))} `}}tc.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},tc.styles=r` + >`}}mb.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},mb.styles=r` :host { display: block; } @@ -2488,29 +7043,29 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de display: block; margin-top: 8px; } - `,customElements.get("cd-entities-editor")||customElements.define("cd-entities-editor",tc);class nc extends le{constructor(){super(...arguments),this._label=e=>at(this.hass,e),this._computeLabel=e=>at(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>at(this.hass,`editor_rule_${e.name.slice(e.name.indexOf("_")+1)}`)}setConfig(e){this._config=e}connectedCallback(){super.connectedCallback(),async function(){if(!customElements.get("ha-entity-picker")&&"function"==typeof window.loadCardHelpers)try{const e=await window.loadCardHelpers(),t=await e.createCardElement({type:"entities",entities:[]}),n=t?.constructor;await(n?.getConfigElement?.())}catch{}}()}_commit(e){this._config=e,function(e,t){e.dispatchEvent(new CustomEvent("config-changed",{bubbles:!0,composed:!0,detail:{config:t}}))}(this,e)}_formChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{};this._commit(Rt(this._config,e,n))}_form(e,t,n){return R`_t(this.hass,e),this._computeLabel=e=>_t(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>_t(this.hass,`editor_rule_${e.name.slice(e.name.indexOf("_")+1)}`)}setConfig(e){this._config=e}connectedCallback(){super.connectedCallback(),async function(){if(!customElements.get("ha-entity-picker")&&"function"==typeof window.loadCardHelpers)try{const e=await window.loadCardHelpers(),t=await e.createCardElement({type:"entities",entities:[]}),o=t?.constructor;await(o?.getConfigElement?.())}catch{}}()}_commit(e){this._config=e,function(e,t){e.dispatchEvent(new CustomEvent("config-changed",{bubbles:!0,composed:!0,detail:{config:t}}))}(this,e)}_formChanged(e,t){if(t.stopPropagation(),!this._config)return;const o=t.detail?.value??{};this._commit(po(this._config,e,o))}_form(e,t,o){return R`this._formChanged(n,e)} + @value-changed=${e=>this._formChanged(o,e)} >`}_panel(e,t){return R`
${t}
-
`}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(lt(this._config,e,t.detail?.value))}_chartFields(){const e=Nl[this._config?.style??"bar"];return e&&Ol(e)&&this._config?e.entityFields?.(this.hass,this._config):void 0}renderEntities(){return R``}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(kt(this._config,e,t.detail?.value))}_chartFields(){const e=Yy[this._config?.style??"bar"];return e&&Vy(e)&&this._config?e.entityFields?.(this.hass,this._config):void 0}renderEntities(){return R`this._subChanged(["entities"],e)} > - ${this._ruleForm("include")} ${this._ruleForm("exclude")}`}_ruleData(e){const t=this._config?.[e],n={};if(!t||"object"!=typeof t)return n;for(const[o,a]of Object.entries(t))n[`${e}_${o}`]=Array.isArray(a)?a:[a];return n}_ruleChanged(e,t){if(t.stopPropagation(),!this._config)return;const n=t.detail?.value??{},o=this._ruleData(e);let a=this._config;for(const r of Nt[e]){const t=`${e}_${r}`,i=Array.isArray(n[t])?n[t]:[];Bt(o[t]??[],i)||(a=lt(a,[e,r],i.length?i:void 0))}a!==this._config&&this._commit(a)}_ruleForm(e){return R`
${this._label(`editor_${e}_heading`)}
+ ${this._ruleForm("include")} ${this._ruleForm("exclude")}`}_ruleData(e){const t=this._config?.[e],o={};if(!t||"object"!=typeof t)return o;for(const[a,n]of Object.entries(t))o[`${e}_${a}`]=Array.isArray(n)?n:[n];return o}_ruleChanged(e,t){if(t.stopPropagation(),!this._config)return;const o=t.detail?.value??{},a=this._ruleData(e);let n=this._config;for(const r of no[e]){const t=`${e}_${r}`,i=Array.isArray(o[t])?o[t]:[];uo(a[t]??[],i)||(n=kt(n,[e,r],i.length?i:void 0))}n!==this._config&&this._commit(n)}_ruleForm(e){return R`
${this._label(`editor_${e}_heading`)}

${this._label(`editor_${e}_help`)}

this._ruleChanged(e,t)} >`}renderLimits(){return[["min","0"],["max","100"],["target",this._label("editor_none")]].map(([e,t])=>R`this._subChanged([e],t)} - >`)}renderColours(){return R`${this._toggles(vt)}`)}renderColours(){return R`${this._toggles(Ut)}this._subChanged(["severity"],e)} - >`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?ec(this.hass,e):e?.unit??ec(this.hass,e?.entity,e?.attribute)}_selectStyle(e){this._config&&this._commit(lt(this._config,["style"],"bar"===e?void 0:e))}renderStyle(){const e=this._config?.style??"bar";return R`${jl.map(t=>{const n=Ve.filter(e=>Nl[e].group===t);return n.length?R`
${this._label(`editor_group_${t}`)}
+ >
`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?pb(this.hass,e):e?.unit??pb(this.hass,e?.entity,e?.attribute)}_selectStyle(e){this._config&&this._commit(kt(this._config,["style"],"bar"===e?void 0:e))}renderStyle(){const e=this._config?.style??"bar";return R`${Wy.map(t=>{const o=it.filter(e=>Yy[e].group===t);return o.length?R`
${this._label(`editor_group_${t}`)}
- ${n.map(t=>{const n=Nl[t],o=Ol(n)?R``:Xt(t);return R``})} -
`:W})}`}renderChart(){const e=Nl[this._config?.style??"bar"];return e&&Ol(e)&&e.editor&&this._config?this._panel("chart",e.editor({hass:this.hass,config:this._config,commit:e=>this._commit(e)})):W}renderDisplay(){return R`${this._toggles(yt)} - ${this._form("display",function(e){const t=ot(e);return Dt?.language!==t&&(Dt={language:t,schema:Ot(e)}),Dt.schema}(this.hass),_t)}`}_toggles(e){const t=It(this._config,e);return R`
- ${e.map(({name:n})=>{const o=this._label(`editor_${n}`),a=!0===t[n];return R`
- this._toggle(e,n,!a)}>${o}`:W})} + ${fb.includes(e)?R`

${this._label("editor_gauge_help")}

+ ${this._form("gauge",function(e,t,o){const a="text"===o,n=`${bt(e)}|${t}|${a}`;let r=Rt.get(n);if(!r){const o={number:{min:-360,max:360,step:1,mode:"box",unit_of_measurement:"°"}};r=[{type:"grid",name:"",schema:[{name:"start_angle",selector:o},{name:"end_angle",selector:o}]},{name:"center",selector:{select:{mode:"dropdown",options:["value","band","name","text","none"].map(t=>({value:t,label:_t(e,`editor_center_mode_${t}`)}))}}},...a?[{name:"center_text",selector:{text:{}}}]:[],..."needle"===t?[{name:"tick_labels",selector:{boolean:{}}}]:[]],Rt.set(n,r)}return r}(this.hass,e,this._config?.center),Bt)}`:W}`}renderChart(){const e=Yy[this._config?.style??"bar"];return e&&Vy(e)&&e.editor&&this._config?this._panel("chart",e.editor({hass:this.hass,config:this._config,commit:e=>this._commit(e)})):W}renderDisplay(){return R`${this._toggles(Dt)} + ${this._form("display",function(e){const t=bt(e);return lo?.language!==t&&(lo={language:t,schema:ao(e)}),lo.schema}(this.hass),Ht)}`}_toggles(e){const t=ho(this._config,e);return R`
+ ${e.map(({name:o})=>{const a=this._label(`editor_${o}`),n=!0===t[o];return R`
+ this._toggle(e,o,!n)}>${a}this._toggle(e,n,t.target.checked)} + data-name=${o} + aria-label=${a} + .checked=${gt(n)} + @change=${t=>this._toggle(e,o,t.target.checked)} >
`})} -
`}_toggle(e,t,n){if(!this._config)return;const o=It(this._config,e);this._commit(Rt(this._config,e,{...o,[t]:n}))}renderActions(){return this._form("actions",Ct,bt)}render(){return this._config?R`
${this._form("title",Et,gt)}
+
`}_toggle(e,t,o){if(!this._config)return;const a=ho(this._config,e);this._commit(po(this._config,e,{...a,[t]:o}))}renderActions(){return this._form("actions",Kt,It)}render(){return this._config?R`
${this._form("title",Jt,Pt)}
${this._panel("entities",this.renderEntities())} ${this._panel("style",this.renderStyle())} ${this.renderChart()} ${this._panel("limits",this.renderLimits())} ${this._panel("colours",this.renderColours())} ${this._panel("display",this.renderDisplay())} - ${this._panel("actions",this.renderActions())}`:W}}nc.properties={hass:{attribute:!1},_config:{state:!0}},nc.styles=r` + ${this._panel("actions",this.renderActions())}`:W}}gb.properties={hass:{attribute:!1},_config:{state:!0}},gb.styles=r` :host { display: block; } @@ -2586,7 +7143,8 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de margin: 16px 0 4px; font-weight: 500; } - .rules-help { + .rules-help, + .gauge-help { margin: 0 0 8px; color: var(--secondary-text-color); font-size: 0.875em; @@ -2633,7 +7191,7 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de outline-offset: -1px; border-color: transparent; } - `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",nc);const oc=r` + `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",gb);const vb=r` :host { display: block; --cd-duration: 900ms; @@ -2736,8 +7294,8 @@ const et=pe(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de border-radius: 50%; flex: none; } -`;console.info("%c CHARTDECK %c 1.5.0 ","color:#fff;background:#3b8bff;font-weight:700","color:#3b8bff");const ac={rows:16,tiles:8};function rc(e){const t=ac[e.layout];return Math.max(1,Math.floor((368+t)/((e.minColumnWidth??0)+t)))}class ic extends le{constructor(){super(...arguments),this._watched=new Set,this._matched=[],this._items=[],this._detachers=new Map,this._readyScheduled=!1,this._readyAt=-1/0}static getStubConfig(e){const t=Object.values(e?.states??{}).filter(e=>e.entity_id.startsWith("sensor.")&&""!==e.state.trim()&&Number.isFinite(Number(e.state))),n=e=>"battery"===e.attributes?.device_class;return t.sort((e,t)=>n(e)!==n(t)?n(e)?-1:1:e.entity_idt.entity_id?1:0),{type:"custom:chartdeck-card",style:"bar",entities:t.slice(0,3).map(e=>e.entity_id)}}static getConfigElement(){return document.createElement("chartdeck-card-editor")}setConfig(e){const t=function(e){if(!e||"object"!=typeof e)throw new Error("Invalid configuration");const t=e.style??"bar";if(!Ve.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${Ve.join(", ")}`);const n=e.sort??"none";if(!We.includes(n))throw new Error(`Unknown sort "${String(n)}". Use one of: ${We.join(", ")}`);if(void 0!==e.entities&&!Array.isArray(e.entities))throw new Error('"entities" must be a list');const o=(e.entities??[]).map((t,n)=>Qe(t,e,n)),a={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},r=Ge(e.columns,"columns",1),i=Ge(e.limit,"limit",1),s=Fe(e.include,"include"),l=Fe(e.exclude,"exclude");s&&Qe("sensor.chartdeck_check",e,0);const c={style:t,entities:o,hideUnavailable:!0===e.hide_unavailable,cardOptions:e,show:a,sort:n,animate:!1!==e.animate,colorIcon:!0===e.color_icon,legendBands:Xe(e.severity)??qe};return void 0!==e.title&&(c.title=e.title),void 0!==r&&(c.columns=r),void 0!==i&&(c.limit=i),s&&(c.include=s),l&&(c.exclude=l),c}(e),n=Nl[t.style];if(Ol(n)&&n.validate?.(e),this._watched=function(e){const t=new Set;for(const n of e.entities){t.add(n.entity);for(const e of[n.min,n.max,n.target,...n.severity.map(e=>e.from)])"string"==typeof e&&""!==e&&t.add(e)}for(const n of e.legendBands)"string"==typeof n.from&&t.add(n.from);return t}(t),Ol(n))for(const o of n.watch?.(e)??[])this._watched.add(o);this._config&&this._config.style!==t.style&&this._disconnectStyle(),this._config=t}get _styleName(){return this._config?.style??"bar"}get _style(){return Nl[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const n=e?.decimals??1;return Oe(Ka(t.legendBands,this.hass),e=>Io(e,n,this.hass),e?.unit??"")}_count(){return this.hasUpdated&&this.hass?this._items.length:this._config?.entities.length??0}_contentHeight(){const e=this._config;if(!e)return 0;const t=this._style,n=16+(e.title?73:0)+(this._legend().length>0?32:0);if(Ol(t))return n+t.height;const o=this._count(),a=void 0!==e.columns?Math.min(e.columns,rc(t)):"rows"===t.layout?1:Math.min(o,t.tilesPerRow??3,rc(t));return n+(0===o?0:Math.ceil(o/Math.max(1,a)))*t.height}getCardSize(){return Math.max(1,Math.ceil(this._contentHeight()/50))}getGridOptions(){const e=this._count();if(Ol(this._style))return{columns:12,rows:"auto",min_columns:6,min_rows:2};const t="rows"===this._style.layout;return{columns:t||e<=1?6:12,rows:"auto",min_columns:t?6:3,min_rows:1}}shouldUpdate(e){if(0===e.size)return!0;if([...e.keys()].some(e=>"hass"!==e))return!0;if(!e.has("hass"))return!1;const t=e.get("hass"),n=this.hass;if(!t||!n)return!0;for(const o of this._watched)if(t.states[o]!==n.states[o])return!0;if(this._config?.include){const e=er(this._config,n);if(e.length!==this._matched.length||e.some((e,t)=>e!==this._matched[t]))return!0;for(const o of e)if(t.states[o]!==n.states[o])return!0}return t.themes?.darkMode!==n.themes?.darkMode||t.themes?.theme!==n.themes?.theme||t.locale!==n.locale}willUpdate(){const e=!1===this._config?.animate||"function"==typeof window.matchMedia&&!!window.matchMedia("(prefers-reduced-motion: reduce)")?.matches;this.toggleAttribute("static",e),(e||this.preview)&&this.setAttribute("ready","")}_scheduleReady(){if(this._readyScheduled||this.hasAttribute("ready")||0===this._items.length)return;this._readyScheduled=!0;const e=()=>{this.shadowRoot?.querySelector(".entity")?.getBoundingClientRect(),this.setAttribute("ready",""),this._readyAt=performance.now()};"function"==typeof requestAnimationFrame?requestAnimationFrame(e):setTimeout(e,16)}updated(){this._scheduleReady(),this._syncActions()}connectedCallback(){if(super.connectedCallback(),!this.hasUpdated)return;const e=this._config?Nl[this._config.style]:void 0;e&&Ol(e)&&this.requestUpdate(),this._syncActions(),this.hasAttribute("ready")&&!this.hasAttribute("static")&&performance.now()-this._readyAt<1e3&&(this.removeAttribute("ready"),this._readyScheduled=!1,this._scheduleReady())}_disconnectStyle(){const e=this._config?Nl[this._config.style]:void 0;e&&Ol(e)&&e.disconnect?.(this)}disconnectedCallback(){super.disconnectedCallback(),this._disconnectStyle();for(const e of this._detachers.values())e();this._detachers.clear()}_syncActions(){const e=this.shadowRoot;if(!e)return;const t=new Set(e.querySelectorAll(".entity"));for(const[n,o]of this._detachers)t.has(n)||(o(),this._detachers.delete(n));for(const n of t)this._detachers.has(n)||this._detachers.set(n,Ee(n,()=>this._items[Number(n.dataset.index)].config))}_renderEntity(e,t,n,o){const a=this._config,r=Se(e.config),i=function(e){const t=ze(e);return{start:t,end:`color-mix(in srgb, ${t} 80%, #fff)`}}(e.band.color),s=`--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};--target:${e.targetPosition??0};--c-start:${i.start};--c-end:${i.end}`;return R`
e.entity_id.startsWith("sensor.")&&""!==e.state.trim()&&Number.isFinite(Number(e.state))),o=e=>"battery"===e.attributes?.device_class;return t.sort((e,t)=>o(e)!==o(t)?o(e)?-1:1:e.entity_idt.entity_id?1:0),{type:"custom:chartdeck-card",style:"bar",entities:t.slice(0,3).map(e=>e.entity_id)}}static getConfigElement(){return document.createElement("chartdeck-card-editor")}setConfig(e){const t=function(e){if(!e||"object"!=typeof e)throw new Error("Invalid configuration");const t=e.style??"bar";if(!it.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${it.join(", ")}`);const o=e.sort??"none";if(!lt.includes(o))throw new Error(`Unknown sort "${String(o)}". Use one of: ${lt.join(", ")}`);if(void 0!==e.entities&&!Array.isArray(e.entities))throw new Error('"entities" must be a list');const a=(e.entities??[]).map((t,o)=>ft(t,e,o)),n={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},r=ut(e.columns,"columns",1),i=ut(e.limit,"limit",1),s=De(e.include,"include"),l=De(e.exclude,"exclude");s&&ft("sensor.chartdeck_check",e,0);const c={style:t,entities:a,hideUnavailable:!0===e.hide_unavailable,cardOptions:e,show:n,sort:o,animate:!1!==e.animate,colorIcon:!0===e.color_icon,legendBands:ht(e.severity)??st};return void 0!==e.title&&(c.title=e.title),void 0!==r&&(c.columns=r),void 0!==i&&(c.limit=i),s&&(c.include=s),l&&(c.exclude=l),c}(e),o=Yy[t.style];if(Vy(o)&&o.validate?.(e),this._watched=function(e){const t=new Set;for(const o of e.entities){t.add(o.entity);for(const e of[o.min,o.max,o.target,...o.severity.map(e=>e.from)])"string"==typeof e&&""!==e&&t.add(e)}for(const o of e.legendBands)"string"==typeof o.from&&t.add(o.from);return t}(t),Vy(o))for(const a of o.watch?.(e)??[])this._watched.add(a);this._config&&this._config.style!==t.style&&this._disconnectStyle(),this._config=t}get _styleName(){return this._config?.style??"bar"}get _style(){return Yy[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const o=e?.decimals??1;return Oe(os(t.legendBands,this.hass),e=>kn(e,o,this.hass),e?.unit??"")}_count(){return this.hasUpdated&&this.hass?this._items.length:this._config?.entities.length??0}_contentHeight(){const e=this._config;if(!e)return 0;const t=this._style,o=16+(e.title?73:0)+(this._legend().length>0?32:0);if(Vy(t))return o+t.height;const a=this._count(),n=void 0!==e.columns?Math.min(e.columns,bb(t)):"rows"===t.layout?1:Math.min(a,t.tilesPerRow??3,bb(t));return o+(0===a?0:Math.ceil(a/Math.max(1,n)))*t.height}getCardSize(){return Math.max(1,Math.ceil(this._contentHeight()/50))}getGridOptions(){const e=this._count();if(Vy(this._style))return{columns:12,min_columns:6,min_rows:2,...this._style.gridOptions,rows:"auto"};const t="rows"===this._style.layout;return{columns:t||e<=1?6:12,rows:"auto",min_columns:t?6:3,min_rows:1}}shouldUpdate(e){if(0===e.size)return!0;if([...e.keys()].some(e=>"hass"!==e))return!0;if(!e.has("hass"))return!1;const t=e.get("hass"),o=this.hass;if(!t||!o)return!0;for(const a of this._watched)if(t.states[a]!==o.states[a])return!0;if(this._config?.include){const e=ns(this._config,o);if(e.length!==this._matched.length||e.some((e,t)=>e!==this._matched[t]))return!0;for(const a of e)if(t.states[a]!==o.states[a])return!0}return t.themes?.darkMode!==o.themes?.darkMode||t.themes?.theme!==o.themes?.theme||t.locale!==o.locale}willUpdate(){const e=!1===this._config?.animate||"function"==typeof window.matchMedia&&!!window.matchMedia("(prefers-reduced-motion: reduce)")?.matches;this.toggleAttribute("static",e),(e||this.preview)&&this.setAttribute("ready","")}_scheduleReady(){if(this._readyScheduled||this.hasAttribute("ready")||0===this._items.length)return;this._readyScheduled=!0;const e=()=>{this.shadowRoot?.querySelector(".entity")?.getBoundingClientRect(),this.setAttribute("ready",""),this._readyAt=performance.now()};"function"==typeof requestAnimationFrame?requestAnimationFrame(e):setTimeout(e,16)}updated(){this._scheduleReady(),this._syncActions()}connectedCallback(){if(super.connectedCallback(),!this.hasUpdated)return;const e=this._config?Yy[this._config.style]:void 0;e&&Vy(e)&&this.requestUpdate(),this._syncActions(),this.hasAttribute("ready")&&!this.hasAttribute("static")&&performance.now()-this._readyAt<1e3&&(this.removeAttribute("ready"),this._readyScheduled=!1,this._scheduleReady())}_disconnectStyle(){const e=this._config?Yy[this._config.style]:void 0;e&&Vy(e)&&e.disconnect?.(this)}disconnectedCallback(){super.disconnectedCallback(),this._disconnectStyle();for(const e of this._detachers.values())e();this._detachers.clear()}_syncActions(){const e=this.shadowRoot;if(!e)return;const t=new Set(e.querySelectorAll(".entity"));for(const[o,a]of this._detachers)t.has(o)||(a(),this._detachers.delete(o));for(const o of t)this._detachers.has(o)||this._detachers.set(o,Se(o,()=>this._items[Number(o.dataset.index)].config))}_renderEntity(e,t,o,a){const n=this._config,r=Ae(e.config),i=je(e.band.color),s=`--start:${e.span[0]};--end:${e.span[1]};--pos:${e.position};--target:${e.targetPosition??0};--c-start:${i.start};--c-end:${i.end}`;return R`
- ${n.render(e,{show:a.show,hass:o})} -
`}render(){const e=this._config;if(!e)return W;const t=this.hass,n=this._style;let o,a,r;const i=t?function(e,t){const n=er(e,t).map((t,n)=>Qe(t,e.cardOptions,n)),o=[...e.entities,...n].map(e=>Qa(e,t));return e.hideUnavailable?o.filter(e=>e.available):o}(e,t):[];if(this._matched=t?er(e,t):[],0!==e.entities.length||e.include)if(t)if(0===i.length)this._items=[],o=R`
${at(t,e.include?"no_matches":"none_available")}
`;else{a=i[0];const s=new Map,l=new Set;i.forEach((e,t)=>{s.set(e,l.has(e.entityId)?`${e.entityId}#${t}`:e.entityId),l.add(e.entityId)}),this._items=function(e,t){if("none"===t)return[...e];const n="ascending"===t?1:-1;return e.map((e,t)=>({item:e,index:t})).sort((e,t)=>{if(e.item.available!==t.item.available)return e.item.available?-1:1;if(e.item.available){const o=(e.item.position-t.item.position)*n;if(0!==o)return o}return e.index-t.index}).map(e=>e.item)}(i,e.sort).slice(0,e.limit);const c=this._items.filter(e=>e.available).map(e=>e.display.length);if(r=c.length?Math.max(...c):void 0,Ol(n))o=R`
- ${n.render({items:this._items,config:e,raw:e.cardOptions,hass:t,requestUpdate:()=>this.requestUpdate(),host:this})} -
`;else{const a="tiles"===n.layout,r=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const n=(t-1)*ac[e.layout];return`grid-template-columns: repeat(auto-fill, minmax(max(min(${e.minColumnWidth??0}px, 100%), calc((100% - ${n}px) / ${t} - 0.5px)), 1fr))`}(n,e.columns);o=R`
`}render(){const e=this._config;if(!e)return W;const t=this.hass,o=this._style;let a,n,r;const i=t?function(e,t){const o=ns(e,t).map((t,o)=>ft(t,e.cardOptions,o)),a=[...e.entities,...o].map(e=>as(e,t));return e.hideUnavailable?a.filter(e=>e.available):a}(e,t):[];if(this._matched=t?ns(e,t):[],0!==e.entities.length||e.include)if(t)if(0===i.length)this._items=[],a=R`
${_t(t,e.include?"no_matches":"none_available")}
`;else{n=i[0];const s=new Map,l=new Set;i.forEach((e,t)=>{s.set(e,l.has(e.entityId)?`${e.entityId}#${t}`:e.entityId),l.add(e.entityId)}),this._items=function(e,t){if("none"===t)return[...e];const o="ascending"===t?1:-1;return e.map((e,t)=>({item:e,index:t})).sort((e,t)=>{if(e.item.available!==t.item.available)return e.item.available?-1:1;if(e.item.available){const a=(e.item.position-t.item.position)*o;if(0!==a)return a}return e.index-t.index}).map(e=>e.item)}(i,e.sort).slice(0,e.limit);const c=this._items.filter(e=>e.available).map(e=>e.display.length);if(r=c.length?Math.max(...c):void 0,Vy(o))a=R`
+ ${o.render({items:this._items,config:e,raw:e.cardOptions,hass:t,requestUpdate:()=>this.requestUpdate(),host:this})} +
`;else{const n="tiles"===o.layout,r=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const o=(t-1)*yb[e.layout];return`grid-template-columns: repeat(auto-fill, minmax(max(min(${e.minColumnWidth??0}px, 100%), calc((100% - ${o}px) / ${t} - 0.5px)), 1fr))`}(o,e.columns);a=R`
- ${Me(this._items,e=>s.get(e),(e,o)=>this._renderEntity(e,o,n,t))} -
`}}else this._items=[],o=W;else this._items=[],o=R`
${at(t,"no_entities")}
`;const s=this._legend(a);return R` + ${Me(this._items,e=>s.get(e),(e,a)=>this._renderEntity(e,a,o,t))} +
`}}else this._items=[],a=W;else this._items=[],a=R`
${_t(t,"no_entities")}
`;const s=this._legend(n);return R`
- ${o} + ${a}
${s.length?R`
${s.map(e=>R`${e.text}`)}
`:W} -
`}}ic.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},ic.styles=[oc,...Object.values(Nl).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",ic);const sc=window.customCards??=[];sc.some(e=>"chartdeck-card"===e.type)||sc.push({type:"chartdeck-card",name:"Chartdeck",description:"Bars, gauges and charts, set up entirely from the visual editor.",preview:!0,documentationURL:"https://tempus2016.github.io/chartdeck/"});export{ic as ChartdeckCard}; + `}}_b.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},_b.styles=[vb,...Object.values(Yy).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",_b);const xb=window.customCards??=[];xb.some(e=>"chartdeck-card"===e.type)||xb.push({type:"chartdeck-card",name:"Chartdeck",description:"Bars, gauges and charts, set up entirely from the visual editor.",preview:!0,documentationURL:"https://tempus2016.github.io/chartdeck/"});export{_b as ChartdeckCard}; From 88a6609038842b1204be68f8304b440fd3ffb53f Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 15:32:33 +0000 Subject: [PATCH 38/41] fix: small multiples on a log scale break the line at zero, and two more editor labels stay put Each small chart drew its readings at or below zero on a log axis; they are now gaps, as on the full-size chart, and a chart with nothing above zero says so. The trend card relabelled every chart's value-labels switch, and the rings chart the gauges' start angle; both now use their own label keys. --- dist/chartdeck-card.js | 974 ++++++++++++++++++------------------- src/editor/trend-editor.ts | 3 +- src/styles/line.ts | 9 +- src/styles/rings.ts | 4 +- test/log-scale.test.ts | 10 + 5 files changed, 508 insertions(+), 492 deletions(-) diff --git a/dist/chartdeck-card.js b/dist/chartdeck-card.js index 91f7eb4..1522878 100644 --- a/dist/chartdeck-card.js +++ b/dist/chartdeck-card.js @@ -254,15 +254,15 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de background: color-mix(in srgb, var(--card-background-color, #fff) 80%, transparent); white-space: nowrap; } -`;function kn(e,t,o){const{locale:a,grouping:n}=function(e){const t=e?.locale?.language??"en";switch(e?.locale?.number_format){case"system":return{locale:void 0,grouping:!0};case"comma_decimal":return{locale:"en-US",grouping:!0};case"decimal_comma":return{locale:"de",grouping:!0};case"space_comma":return{locale:"fr",grouping:!0};case"none":return{locale:"en-US",grouping:!1};default:return{locale:t,grouping:!0}}}(o),r=new Intl.NumberFormat(a,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:n});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Mn(e,t){return t?`${e} ${t}`:e}const zn=[[1,2,3,4,5,6,7,8,9],[1,2,5],[1]],An=[2,3,5,10,20,50,100],Sn=e=>Number(e.toPrecision(12)),En=e=>Sn(10**e),Cn=e=>Math.floor(Math.log10(e)+1e-9);function jn(e,t,o,a=1){const n=[];for(let r=Cn(e);r<=Cn(t)+a;r+=a)for(const a of o){const o=Sn(a*10**r);o>=e*(1-1e-9)&&o<=t*(1+1e-9)&&n.push(o)}return n}function Nn(e,t,o){const a=Cn(e),n=jn(En(a-2),En(a+2),t),r=o?n.filter(t=>t<=e*(1+1e-9)):n.filter(t=>t>=e*(1-1e-9));return o?r[r.length-1]:r[0]}function Ln(e,t){const o=Math.log10(e[0]),a=Math.log10(e[1]),[n,r]=t,i=a===o?0:(r-n)/(a-o);return e=>e>0?n+(Math.log10(e)-o)*i:NaN}const Tn=new WeakMap;function On(e){let t=Tn.get(e.host);if(!t){const o={width:e.host.clientWidth||400};t=o,Tn.set(e.host,o),"undefined"!=typeof ResizeObserver&&(o.observer=new ResizeObserver(t=>{const a=Math.round(t[t.length-1].contentRect.width);Math.round(a/70)!==Math.round(o.width/70)&&(o.width=a,e.requestUpdate())}),o.observer.observe(e.host))}return t.width}function Fn(e){Tn.get(e)?.observer?.disconnect(),Tn.delete(e)}function Pn(e){const t=To(e.raw);return Math.max(0,On(e)-32-(t.y&&"outside"===t.labels?40:0))}function Dn(e,t,o,a,n=4){const r=To(e.raw),i="log"===r.scale&&"line"===e.raw.style,s=i?function(e,t,o){let[a,n]=e??[1,100];void 0!==t.min&&t.min>0&&(a=t.min),void 0!==t.max&&t.max>0&&(n=t.max),n>0||(n=a>0?10*a:100),a>0||(a=n/1e3);const r=void 0!==t.min&&t.min===a,i=void 0!==t.max&&t.max===n;n<=a&&(r?n=10*a:i?a=n/10:(a/=Math.sqrt(10),n*=Math.sqrt(10)));const s=Math.max(2,o+1),l=[...zn.map(e=>({mantissas:e,every:1})),...An.map(e=>({mantissas:[1],every:e}))];let c=l[l.length-1],d=[a,n],u=[];for(const f of l){const e=r?a:Nn(a,f.mantissas,!0),t=i?n:Nn(n,f.mantissas,!1),o=jn(e,t,f.mantissas,f.every);if(c=f,d=[e,t],u=o,o.length<=s)break}const h=c.every>1?[1]:zn[0],p=9===c.mantissas.length?[]:jn(d[0],d[1],h).filter(e=>!u.includes(e)).slice(0,200),m=u.map(e=>Math.max(0,-Cn(e)));return{domain:d,ticks:u,decimals:Math.max(0,...m),minor:p,tickDecimals:m}}(o,r,n):Do(o,r,n),l=Math.max(2,Math.min(8,Math.floor((a??Pn(e))/70))),{unit:c,ticks:d}=Vo(t,l,Zo(Yo(e.hass)));return{options:r,value:s,times:d,unit:c,range:t,scales:{x:$o([t.start,t.end],[0,cn]),y:(i?Ln:$o)(s.domain,[dn,0]),domain:s.domain}}}const Un=e=>Math.round(1e4*e)/100+"%";function Hn(e,t,o){const a=e.ticks[t];return e.tickDecimals?function(e,t,o){if(Math.abs(e)<1e4)return kn(e,t,o);const a=o?.locale?.language??o?.language??"en";try{return new Intl.NumberFormat(a,{notation:"compact",maximumFractionDigits:1}).format(e)}catch{return new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}}(a,e.tickDecimals[t],o.hass):kn(a,e.decimals,o.hass)}function In({axes:e,ctx:t,content:o,label:a,defs:n=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c,gestures:d,classes:u="",below:h=W}){const{options:p}=e,m="outside"===p.labels,f=p.y&&e.value.ticks.length>0,g=p.x&&e.times.length>0,v=f?function(e,t,o){const{scales:a,value:n}=e;return n.ticks.map((e,r)=>{const i=a.y(e)/dn;return R`.92?"bottom":""}" style="top:${Un(i)}" - >${o?o(e):Hn(n,r,t)}`})}(e,t,c):[],y=f?R`
${v}
`:W,b=g?R`
${function(e,t,o){const{scales:a,range:n}=e,r=Zo(Yo(t.hass));return e.times.map(i=>{const s=a.x(i)/cn;return R`.95?"end":""}" style="left:${Un(s)}" +`;function kn(e,t,o){const{locale:a,grouping:n}=function(e){const t=e?.locale?.language??"en";switch(e?.locale?.number_format){case"system":return{locale:void 0,grouping:!0};case"comma_decimal":return{locale:"en-US",grouping:!0};case"decimal_comma":return{locale:"de",grouping:!0};case"space_comma":return{locale:"fr",grouping:!0};case"none":return{locale:"en-US",grouping:!1};default:return{locale:t,grouping:!0}}}(o),r=new Intl.NumberFormat(a,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:n});let i=r.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(i=r.format(0)),i}function Mn(e,t){return t?`${e} ${t}`:e}const zn=[[1,2,3,4,5,6,7,8,9],[1,2,5],[1]],An=[2,3,5,10,20,50,100],Sn=e=>Number(e.toPrecision(12)),En=e=>Sn(10**e),Cn=e=>Math.floor(Math.log10(e)+1e-9);function jn(e){return e.map(e=>{const t=e=>"number"==typeof e&&e<=0;return t(e.v)||t(e.min)||t(e.max)?{...e,v:t(e.v)?null:e.v,...void 0!==e.min?{min:t(e.min)?null:e.min}:{},...void 0!==e.max?{max:t(e.max)?null:e.max}:{}}:e})}function Nn(e,t,o,a=1){const n=[];for(let r=Cn(e);r<=Cn(t)+a;r+=a)for(const a of o){const o=Sn(a*10**r);o>=e*(1-1e-9)&&o<=t*(1+1e-9)&&n.push(o)}return n}function Ln(e,t,o){const a=Cn(e),n=Nn(En(a-2),En(a+2),t),r=o?n.filter(t=>t<=e*(1+1e-9)):n.filter(t=>t>=e*(1-1e-9));return o?r[r.length-1]:r[0]}function Tn(e,t){const o=Math.log10(e[0]),a=Math.log10(e[1]),[n,r]=t,i=a===o?0:(r-n)/(a-o);return e=>e>0?n+(Math.log10(e)-o)*i:NaN}const On=new WeakMap;function Fn(e){let t=On.get(e.host);if(!t){const o={width:e.host.clientWidth||400};t=o,On.set(e.host,o),"undefined"!=typeof ResizeObserver&&(o.observer=new ResizeObserver(t=>{const a=Math.round(t[t.length-1].contentRect.width);Math.round(a/70)!==Math.round(o.width/70)&&(o.width=a,e.requestUpdate())}),o.observer.observe(e.host))}return t.width}function Pn(e){On.get(e)?.observer?.disconnect(),On.delete(e)}function Dn(e){const t=To(e.raw);return Math.max(0,Fn(e)-32-(t.y&&"outside"===t.labels?40:0))}function Un(e,t,o,a,n=4){const r=To(e.raw),i="log"===r.scale&&"line"===e.raw.style,s=i?function(e,t,o){let[a,n]=e??[1,100];void 0!==t.min&&t.min>0&&(a=t.min),void 0!==t.max&&t.max>0&&(n=t.max),n>0||(n=a>0?10*a:100),a>0||(a=n/1e3);const r=void 0!==t.min&&t.min===a,i=void 0!==t.max&&t.max===n;n<=a&&(r?n=10*a:i?a=n/10:(a/=Math.sqrt(10),n*=Math.sqrt(10)));const s=Math.max(2,o+1),l=[...zn.map(e=>({mantissas:e,every:1})),...An.map(e=>({mantissas:[1],every:e}))];let c=l[l.length-1],d=[a,n],u=[];for(const f of l){const e=r?a:Ln(a,f.mantissas,!0),t=i?n:Ln(n,f.mantissas,!1),o=Nn(e,t,f.mantissas,f.every);if(c=f,d=[e,t],u=o,o.length<=s)break}const h=c.every>1?[1]:zn[0],p=9===c.mantissas.length?[]:Nn(d[0],d[1],h).filter(e=>!u.includes(e)).slice(0,200),m=u.map(e=>Math.max(0,-Cn(e)));return{domain:d,ticks:u,decimals:Math.max(0,...m),minor:p,tickDecimals:m}}(o,r,n):Do(o,r,n),l=Math.max(2,Math.min(8,Math.floor((a??Dn(e))/70))),{unit:c,ticks:d}=Vo(t,l,Zo(Yo(e.hass)));return{options:r,value:s,times:d,unit:c,range:t,scales:{x:$o([t.start,t.end],[0,cn]),y:(i?Tn:$o)(s.domain,[dn,0]),domain:s.domain}}}const Hn=e=>Math.round(1e4*e)/100+"%";function In(e,t,o){const a=e.ticks[t];return e.tickDecimals?function(e,t,o){if(Math.abs(e)<1e4)return kn(e,t,o);const a=o?.locale?.language??o?.language??"en";try{return new Intl.NumberFormat(a,{notation:"compact",maximumFractionDigits:1}).format(e)}catch{return new Intl.NumberFormat("en",{notation:"compact",maximumFractionDigits:1}).format(e)}}(a,e.tickDecimals[t],o.hass):kn(a,e.decimals,o.hass)}function Bn({axes:e,ctx:t,content:o,label:a,defs:n=W,overlay:r=W,hover:i,right:s,xLabel:l,yLabel:c,gestures:d,classes:u="",below:h=W}){const{options:p}=e,m="outside"===p.labels,f=p.y&&e.value.ticks.length>0,g=p.x&&e.times.length>0,v=f?function(e,t,o){const{scales:a,value:n}=e;return n.ticks.map((e,r)=>{const i=a.y(e)/dn;return R`.92?"bottom":""}" style="top:${Hn(i)}" + >${o?o(e):In(n,r,t)}`})}(e,t,c):[],y=f?R`
${v}
`:W,b=g?R`
${function(e,t,o){const{scales:a,range:n}=e,r=Zo(Yo(t.hass));return e.times.map(i=>{const s=a.x(i)/cn;return R`.95?"end":""}" style="left:${Hn(s)}" >${o?o(i):e.label?e.label(i):ea(i,e.unit,n.end-n.start,t.hass,r)}`})}(e,t,l)}
`:W,_=f?R`${e.value.ticks.map((o,a)=>c?c(o):In(e.value,a,t)).reduce((e,t)=>t.length>e.length?t:e,"")}
`:W,x=s&&p.y&&s.value.ticks.length>0?s:void 0,w=x?R``:W,$=x?R`
${m?w:W}${function(e,t){return e.value.ticks.map(o=>{const a=e.y(o)/dn;return R`.92?"bottom":""}" style="top:${Un(a)}" + >`:W,$=x?R`
${m?w:W}${function(e,t){return e.value.ticks.map(o=>{const a=e.y(o)/dn;return R`.92?"bottom":""}" style="top:${Hn(a)}" >${kn(o,e.value.decimals,t.hass)}`})}(x,t)}
`:W,k=`plot-wrap axes-${p.labels} ${f?"has-y":""} ${g?"has-x":""} ${x?"has-right":""} ${h===W?"":"has-overview"}`;return R`
${m&&f?R`
${_}${v}
`:W} @@ -284,7 +284,7 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de ${r}
${m?R`${$}${b}`:W} ${h} -
`}const Bn=r` +
`}const Rn=r` .plot-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -397,9 +397,9 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de border-radius: 2px; background: color-mix(in srgb, var(--card-background-color, #fff) 70%, transparent); } -`,Rn=["above","over","hidden"];function Zn(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Rn.includes(e.header))throw new Error(`"header" must be one of: ${Rn.join(", ")}`)}function qn(e){return{compact:!0===e.compact,header:e.header??"above"}}function Wn(e,t,o){return R`
+`,Zn=["above","over","hidden"];function qn(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!Zn.includes(e.header))throw new Error(`"header" must be one of: ${Zn.join(", ")}`)}function Wn(e){return{compact:!0===e.compact,header:e.header??"above"}}function Vn(e,t,o){return R`
${"above"===e.header?t:W}${o} -
`}function Vn(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const Yn=r` +
`}function Yn(e){return e===W?W:R`
e.stopPropagation()}>${e}
`}const Gn=r` .header-over { position: absolute; top: 2px; @@ -452,17 +452,17 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de .frame.compact .axes-outside .x-axis .axis-label { top: 3px; } -`,Gn=["none","normal","percent"];function Xn(e){const t=e.stack;if(void 0!==t&&!Gn.includes(t))throw new Error(`"stack" must be one of: ${Gn.join(", ")}`)}function Jn(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return 0===o?null:e[o-1].v}function Kn(e,t){const o=e[0]?.length??0,a=e.map(()=>[]);for(let n=0;ne+Math.abs(t[n]??0),0);o=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[n];if(null==s)return void a[t].push(null);const l=s*o;l>=0?(a[t].push([r,r+l]),r+=l):(a[t].push([i,i+l]),i+=l)})}return a}function Qn(e){const t=e[0]?.length??0,o=[];for(let a=0;a=190?"left":e>.5?"pin-left":"pin-right"}const or=3e3;function ar(e,t){if(0===e.length)return-1;let o=0,a=e.length-1;for(;o>1;e[n].t0&&t-e[o-1].t<=e[o].t-t?o-1:o}const nr=new WeakMap;function rr(e){return nr.get(e)?.t}function ir(e){const t=nr.get(e);void 0!==t?.timer&&clearTimeout(t.timer),nr.delete(e)}function sr(e){return!1!==e.tooltip}function lr(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function cr(e,t,o){const a=function(e){let t=nr.get(e);return t||(t={},nr.set(e,t)),t}(e.host),n=t=>{t!==a.t&&(a.t=t,e.requestUpdate())},r=()=>{void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0};return{move(e){r();const a=e.currentTarget.getBoundingClientRect(),i=a.width>0?Math.min(1,Math.max(0,(e.clientX-a.left)/a.width)):0;n(function(e,t){let o;for(const a of e){const e=ar(a,t);if(e<0)continue;const n=a[e].t;(void 0===o||Math.abs(n-t){a.timer=void 0,n(void 0)},or):n(void 0)}}}const dr=e=>Math.round(1e4*e)/100+"%";function ur(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function hr(e,t,o,a){return R`
+`,Xn=["none","normal","percent"];function Jn(e){const t=e.stack;if(void 0!==t&&!Xn.includes(t))throw new Error(`"stack" must be one of: ${Xn.join(", ")}`)}function Kn(e,t){let o=0,a=e.length;for(;o>1;e[n].t<=t?o=n+1:a=n}return 0===o?null:e[o-1].v}function Qn(e,t){const o=e[0]?.length??0,a=e.map(()=>[]);for(let n=0;ne+Math.abs(t[n]??0),0);o=t>0?100/t:0}let r=0,i=0;e.forEach((e,t)=>{const s=e[n];if(null==s)return void a[t].push(null);const l=s*o;l>=0?(a[t].push([r,r+l]),r+=l):(a[t].push([i,i+l]),i+=l)})}return a}function er(e){const t=e[0]?.length??0,o=[];for(let a=0;a=190?"left":e>.5?"pin-left":"pin-right"}const ar=3e3;function nr(e,t){if(0===e.length)return-1;let o=0,a=e.length-1;for(;o>1;e[n].t0&&t-e[o-1].t<=e[o].t-t?o-1:o}const rr=new WeakMap;function ir(e){return rr.get(e)?.t}function sr(e){const t=rr.get(e);void 0!==t?.timer&&clearTimeout(t.timer),rr.delete(e)}function lr(e){return!1!==e.tooltip}function cr(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function dr(e,t,o){const a=function(e){let t=rr.get(e);return t||(t={},rr.set(e,t)),t}(e.host),n=t=>{t!==a.t&&(a.t=t,e.requestUpdate())},r=()=>{void 0!==a.timer&&clearTimeout(a.timer),a.timer=void 0};return{move(e){r();const a=e.currentTarget.getBoundingClientRect(),i=a.width>0?Math.min(1,Math.max(0,(e.clientX-a.left)/a.width)):0;n(function(e,t){let o;for(const a of e){const e=nr(a,t);if(e<0)continue;const n=a[e].t;(void 0===o||Math.abs(n-t){a.timer=void 0,n(void 0)},ar):n(void 0)}}}const ur=e=>Math.round(1e4*e)/100+"%";function hr(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function pr(e,t,o,a){return R`
${_t(e.hass,"chart_total")} ${null===t?"—":Mn(kn(t,a,e.hass),o)} -
`}function pr(e,t,o,a,n,r={}){if(void 0===a)return W;const i=o.x(a)/cn;if(!(i>=0&&i<=1))return W;const s=tr(i,n),l=t.map(e=>{const t=ar(e.points,a);return{s:e,p:t<0?void 0:e.points[t]}});return R``}function mr(e,t,o,a,n,r={}){if(void 0===a)return W;const i=o.x(a)/cn;if(!(i>=0&&i<=1))return W;const s=or(i,n),l=t.map(e=>{const t=nr(e.points,a);return{s:e,p:t<0?void 0:e.points[t]}});return R``}yt("en",{chart_total:"Total"});const fr=r` .plot-area.hoverable { /* A finger dragging sideways scrubs the chart; up and down still scrolls the page. */ touch-action: pan-y; @@ -559,13 +559,13 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de .tooltip-total .swatch { background: none; } -`,fr=["all","last","minmax","ends"],gr=["auto","above","below","center"];function vr(e){const t=e;if(!0===t.labels)return{show:t.labels_show??"all",position:t.labels_position??"auto",hideOverlap:!0!==t.labels_overlap}}function yr(e,t){const o=e.map((e,t)=>null===e.v?-1:t).filter(e=>e>=0);if(0===o.length)return[];const a=o[o.length-1],n=o[0];let r=n,i=n;for(const l of o)e[l].v>e[r].v&&(r=l),e[l].vnull!==e);if(0===a.length)return"above";const n=a.reduce((e,t)=>e+t,0)/a.length;return oe[0]Math.round(1e4*e)/100+"%";function kr(e,t,o,a){const n=[],r=[];for(const c of e){const e=o.skip?c.points.map(e=>o.skip(e)?{...e,v:null}:e):c.points;!t.hideOverlap&&"all"===t.show&&e.filter(e=>null!==e.v).length>40||yr(e,t.show).forEach((i,s)=>{const l=e[i],[d,u]=o.at(l);if(!(d>=0&&d<=1&&u>=0&&u<=1))return;const h=kn(l.v,c.decimals,a),p="auto"===t.position?br(e,i):t.position,m="above"===p?"below":"above",f={x:d*o.width,y:u*o.height,width:wr(h),sides:"center"===p?["center"]:[p,m],text:h,color:c.color,fx:d,fy:u};(s<3&&"all"===t.show?n:"all"===t.show?r:n).push(f)})}const i=[...n,...r].slice(0,600),s=function(e,t,o){const a=[];return e.map(e=>{for(const n of e.sides){const r="auto"===n?"above":n,i=_r(e,r);if(!o)return{side:r,box:i};if(!(i[1]<-2||i[3]>t+2||a.some(e=>xr(e,i))))return a.push(i),{side:r,box:i}}})}(i,o.height,t.hideOverlap),l=i.map((e,t)=>({b:e,p:s[t]})).filter(e=>void 0!==e.p);return 0===l.length?W:(l.sort((e,t)=>e.b.fx-t.b.fx),R`
+`,gr=["all","last","minmax","ends"],vr=["auto","above","below","center"];function yr(e){const t=e;if(!0===t.labels)return{show:t.labels_show??"all",position:t.labels_position??"auto",hideOverlap:!0!==t.labels_overlap}}function br(e,t){const o=e.map((e,t)=>null===e.v?-1:t).filter(e=>e>=0);if(0===o.length)return[];const a=o[o.length-1],n=o[0];let r=n,i=n;for(const l of o)e[l].v>e[r].v&&(r=l),e[l].vnull!==e);if(0===a.length)return"above";const n=a.reduce((e,t)=>e+t,0)/a.length;return oe[0]Math.round(1e4*e)/100+"%";function Mr(e,t,o,a){const n=[],r=[];for(const c of e){const e=o.skip?c.points.map(e=>o.skip(e)?{...e,v:null}:e):c.points;!t.hideOverlap&&"all"===t.show&&e.filter(e=>null!==e.v).length>40||br(e,t.show).forEach((i,s)=>{const l=e[i],[d,u]=o.at(l);if(!(d>=0&&d<=1&&u>=0&&u<=1))return;const h=kn(l.v,c.decimals,a),p="auto"===t.position?_r(e,i):t.position,m="above"===p?"below":"above",f={x:d*o.width,y:u*o.height,width:$r(h),sides:"center"===p?["center"]:[p,m],text:h,color:c.color,fx:d,fy:u};(s<3&&"all"===t.show?n:"all"===t.show?r:n).push(f)})}const i=[...n,...r].slice(0,600),s=function(e,t,o){const a=[];return e.map(e=>{for(const n of e.sides){const r="auto"===n?"above":n,i=xr(e,r);if(!o)return{side:r,box:i};if(!(i[1]<-2||i[3]>t+2||a.some(e=>wr(e,i))))return a.push(i),{side:r,box:i}}})}(i,o.height,t.hideOverlap),l=i.map((e,t)=>({b:e,p:s[t]})).filter(e=>void 0!==e.p);return 0===l.length?W:(l.sort((e,t)=>e.b.fx-t.b.fx),R`
${l.map(({b:e,p:t})=>R`o?"end":""}(e.fx*o.width,e.width,o.width)}" - style="left:${$r(e.fx)};top:${$r(e.fy)};--cd-label:${e.color}" + style="left:${kr(e.fx)};top:${kr(e.fy)};--cd-label:${e.color}" >${e.text}`)} -
`)}const Mr=r` +
`)}const zr=r` .point-labels { position: absolute; inset: 0; @@ -608,23 +608,23 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de .point-label.end.center { transform: translate(var(--cd-label-x), -50%); } -`,zr=["none","line","figure"],Ar=!1,Sr=!1,Er="none";function Cr(e){const t=e.extremes??{};return{min:t.min??Ar,max:t.max??Sr,average:t.average??Er}}function jr(e){return e.min||e.max||"none"!==e.average}yt("en",{extremes_high:"High",extremes_low:"Low",extremes_average:"Avg",editor_extremes_max:"Mark the highest reading",editor_extremes_min:"Mark the lowest reading",editor_extremes_average:"Average",editor_extremes_average_none:"Don't show",editor_extremes_average_line:"Line across the chart",editor_extremes_average_figure:"Figure only",editor_extremes:"Mark highest, lowest and average"});function Nr(e,t,o,a=[]){const n=e.filter(e=>e.dot).map(e=>({l:e.x*t-6,r:e.x*t+6,t:e.y*o-6,b:e.y*o+6})),r=[...a,...n];return e.map(e=>{const a=(e=>Math.ceil(6.2*e.length)+12)(e.text),n=e.x*t,i=e.y*o,s=n-a/2<0?"start":n+a/2>t?"end":"center",l="start"===s?0:"end"===s?t-a:n-a/2,c=(e,t=0)=>{const o=8+20*t,n="above"===e?i-o-18:i+o;return{l:l,r:l+a,t:n,b:n+18}},d=e=>e.t>=0&&e.b<=o,u="above"===e.prefer?"below":"above",h=[{side:e.prefer,tier:0},{side:u,tier:0},{side:e.prefer,tier:1},{side:u,tier:1}],p=h.find(e=>(e=>d(e)&&!r.some(t=>((e,t)=>e.l0===e.tier&&d(c(e.side)))??h[0];r.push(c(p.side,p.tier));return{side:p.side,align:s,tier:p.tier}})}function Lr(e,t,o,a){if("day"===a?.unit||"week"===a?.unit||"month"===a?.unit){const t="month"===a.unit?{month:"short",year:"numeric"}:{weekday:"short",day:"numeric",month:"short"};try{return new Intl.DateTimeFormat(o?.locale?.language??"en",{...t,timeZone:Yo(o)}).format(e)}catch{return new Intl.DateTimeFormat("en",t).format(e)}}return t.end-t.start>864e5?Qo(e,o):Ko(e,o)}const Tr=e=>Math.round(1e4*e)/100+"%";function Or(e){const{ctx:t,scales:o,width:a,height:n}=e,r=Cr(t.raw);if(!jr(r))return{lines:W,overlay:W};const i=e.series.filter(e=>!1!==e.entity.config.extremes).map(t=>{const{min:o,max:a}=function(e){let t,o;for(const a of e)null===a.v||a.carried||((!t||a.vo.v)&&(o=a));return{min:t,max:o}}(t.points),n="none"===r.average?void 0:function(e,t,o=1/0){const a=e.filter(e=>null!==e.v&&!(e.carried&&!t));if(0!==a.length){if(t){let t=0,a=0;for(let n=0;n0)return t/a}return a.reduce((e,t)=>e+t.v,0)/a.length}}(t.points,e.timeWeighted,e.until);return{s:t,min:r.min?o:void 0,max:r.max?a:void 0,average:n}}),s=(e,o)=>Mn(kn(e,o.entity.decimals,t.hass),o.entity.unit),l=e=>_t(t.hass,e),c=e=>e>=0&&e<=1,d=[];for(const f of i){for(const a of["max","min"]){const n=f[a];if(!n)continue;const r=o.y(n.v)/dn;if(!c(r))continue;const i=l("max"===a?"extremes_high":"extremes_low");d.push({s:f.s,kind:a,spec:{x:o.x(n.t)/cn,y:r,text:`${i} ${s(n.v,f.s)} · ${Lr(n.t,e.range,t.hass,e.group)}`,prefer:"max"===a?"above":"below",dot:!0}})}if("line"===r.average&&void 0!==f.average){const e=o.y(f.average)/dn;c(e)&&d.push({s:f.s,kind:"average",spec:{x:1,y:e,text:`${l("extremes_average")} ${s(f.average,f.s)}`,prefer:"above"}})}}const u=Nr(d.map(e=>e.spec),a,n,e.obstacles),h="line"===r.average?Z`${i.map(e=>{if(void 0===e.average)return W;const t=o.y(e.average);return Z``})}`:W,p="figure"===r.average&&i.some(e=>void 0!==e.average)?R`
+`,Ar=["none","line","figure"],Sr=!1,Er=!1,Cr="none";function jr(e){const t=e.extremes??{};return{min:t.min??Sr,max:t.max??Er,average:t.average??Cr}}function Nr(e){return e.min||e.max||"none"!==e.average}yt("en",{extremes_high:"High",extremes_low:"Low",extremes_average:"Avg",editor_extremes_max:"Mark the highest reading",editor_extremes_min:"Mark the lowest reading",editor_extremes_average:"Average",editor_extremes_average_none:"Don't show",editor_extremes_average_line:"Line across the chart",editor_extremes_average_figure:"Figure only",editor_extremes:"Mark highest, lowest and average"});function Lr(e,t,o,a=[]){const n=e.filter(e=>e.dot).map(e=>({l:e.x*t-6,r:e.x*t+6,t:e.y*o-6,b:e.y*o+6})),r=[...a,...n];return e.map(e=>{const a=(e=>Math.ceil(6.2*e.length)+12)(e.text),n=e.x*t,i=e.y*o,s=n-a/2<0?"start":n+a/2>t?"end":"center",l="start"===s?0:"end"===s?t-a:n-a/2,c=(e,t=0)=>{const o=8+20*t,n="above"===e?i-o-18:i+o;return{l:l,r:l+a,t:n,b:n+18}},d=e=>e.t>=0&&e.b<=o,u="above"===e.prefer?"below":"above",h=[{side:e.prefer,tier:0},{side:u,tier:0},{side:e.prefer,tier:1},{side:u,tier:1}],p=h.find(e=>(e=>d(e)&&!r.some(t=>((e,t)=>e.l0===e.tier&&d(c(e.side)))??h[0];r.push(c(p.side,p.tier));return{side:p.side,align:s,tier:p.tier}})}function Tr(e,t,o,a){if("day"===a?.unit||"week"===a?.unit||"month"===a?.unit){const t="month"===a.unit?{month:"short",year:"numeric"}:{weekday:"short",day:"numeric",month:"short"};try{return new Intl.DateTimeFormat(o?.locale?.language??"en",{...t,timeZone:Yo(o)}).format(e)}catch{return new Intl.DateTimeFormat("en",t).format(e)}}return t.end-t.start>864e5?Qo(e,o):Ko(e,o)}const Or=e=>Math.round(1e4*e)/100+"%";function Fr(e){const{ctx:t,scales:o,width:a,height:n}=e,r=jr(t.raw);if(!Nr(r))return{lines:W,overlay:W};const i=e.series.filter(e=>!1!==e.entity.config.extremes).map(t=>{const{min:o,max:a}=function(e){let t,o;for(const a of e)null===a.v||a.carried||((!t||a.vo.v)&&(o=a));return{min:t,max:o}}(t.points),n="none"===r.average?void 0:function(e,t,o=1/0){const a=e.filter(e=>null!==e.v&&!(e.carried&&!t));if(0!==a.length){if(t){let t=0,a=0;for(let n=0;n0)return t/a}return a.reduce((e,t)=>e+t.v,0)/a.length}}(t.points,e.timeWeighted,e.until);return{s:t,min:r.min?o:void 0,max:r.max?a:void 0,average:n}}),s=(e,o)=>Mn(kn(e,o.entity.decimals,t.hass),o.entity.unit),l=e=>_t(t.hass,e),c=e=>e>=0&&e<=1,d=[];for(const f of i){for(const a of["max","min"]){const n=f[a];if(!n)continue;const r=o.y(n.v)/dn;if(!c(r))continue;const i=l("max"===a?"extremes_high":"extremes_low");d.push({s:f.s,kind:a,spec:{x:o.x(n.t)/cn,y:r,text:`${i} ${s(n.v,f.s)} · ${Tr(n.t,e.range,t.hass,e.group)}`,prefer:"max"===a?"above":"below",dot:!0}})}if("line"===r.average&&void 0!==f.average){const e=o.y(f.average)/dn;c(e)&&d.push({s:f.s,kind:"average",spec:{x:1,y:e,text:`${l("extremes_average")} ${s(f.average,f.s)}`,prefer:"above"}})}}const u=Lr(d.map(e=>e.spec),a,n,e.obstacles),h="line"===r.average?Z`${i.map(e=>{if(void 0===e.average)return W;const t=o.y(e.average);return Z``})}`:W,p="figure"===r.average&&i.some(e=>void 0!==e.average)?R`
${i.map(t=>void 0===t.average?W:R`
${e.series.length>1?R``:W} ${l("extremes_average")} ${s(t.average,t.s)}
`)}
`:W,m=R`
- ${d.map((e,t)=>{const{side:o,align:a,tier:n}=u[t],{x:r,y:i,text:s}=e.spec,l="start"===a?"left:0":"end"===a?"right:0":`left:${Tr(r)}`;return R`{const{side:o,align:a,tier:n}=u[t],{x:r,y:i,text:s}=e.spec,l="start"===a?"left:0":"end"===a?"right:0":`left:${Or(r)}`;return R`${s}`})} ${d.map(e=>e.spec.dot?R``:W)} ${p} -
`;return{lines:h,overlay:m}}const Fr=r` +
`;return{lines:h,overlay:m}}const Pr=r` .plot .average-line { stroke-width: 1.5; stroke-dasharray: 6 5; @@ -705,13 +705,13 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de width: 8px; height: 8px; } -`,Pr=[{name:"extremes_max",path:["extremes","max"],default:Sr},{name:"extremes_min",path:["extremes","min"],default:Ar},{name:"extremes_average",path:["extremes","average"],default:Er}];const Dr={name:"extremes",path:["extremes"],default:!0},Ur=new Map;function Hr(e,t){const o=bt(t);let a=Ur.get(o);return a&&a[0]===e[0]||(a=[...e,{name:"extremes",selector:{boolean:{}}}],Ur.set(o,a)),a}const Ir=new WeakMap;function Br(e){let t=Ir.get(e);return t||(t=[...e,Dr],Ir.set(e,t)),t}const Rr=["auto","shared","independent"];function Zr(e,t,o,a,n){const r=Math.max(0,On(e)-32),i=function(e,t,o){const a=e=>Math.max(1,Math.floor((t+12)/(e+12))),n=void 0===o?a(150):Math.min(o,a(110));return Math.max(1,Math.min(e,n))}(t.series.length,r,o.columns),s=function(e,t){return Math.max(0,(e-12*(t-1))/t)}(r,i),l=new Set(t.series.map(e=>e.entity.unit).filter(e=>""!==e)),c="shared"===o.scale||"auto"===o.scale&&l.size<=1?xo(t.series.map(e=>e.points)):void 0,{show:d}=e.config;return R`
- ${t.series.map(o=>{const r=Dn(e,t.range,c??xo([o.points]),s,2),i=d.name||d.value?R`
+`,Dr=[{name:"extremes_max",path:["extremes","max"],default:Er},{name:"extremes_min",path:["extremes","min"],default:Sr},{name:"extremes_average",path:["extremes","average"],default:Cr}];const Ur={name:"extremes",path:["extremes"],default:!0},Hr=new Map;function Ir(e,t){const o=bt(t);let a=Hr.get(o);return a&&a[0]===e[0]||(a=[...e,{name:"extremes",selector:{boolean:{}}}],Hr.set(o,a)),a}const Br=new WeakMap;function Rr(e){let t=Br.get(e);return t||(t=[...e,Ur],Br.set(e,t)),t}const Zr=["auto","shared","independent"];function qr(e,t,o,a,n){const r=Math.max(0,Fn(e)-32),i=function(e,t,o){const a=e=>Math.max(1,Math.floor((t+12)/(e+12))),n=void 0===o?a(150):Math.min(o,a(110));return Math.max(1,Math.min(e,n))}(t.series.length,r,o.columns),s=function(e,t){return Math.max(0,(e-12*(t-1))/t)}(r,i),l=new Set(t.series.map(e=>e.entity.unit).filter(e=>""!==e)),c="shared"===o.scale||"auto"===o.scale&&l.size<=1?xo(t.series.map(e=>e.points)):void 0,{show:d}=e.config;return R`
+ ${t.series.map(o=>{const r=Un(e,t.range,c??xo([o.points]),s,2),i=d.name||d.value?R`
${d.name?R`${o.entity.name}`:W} - ${d.value?R`${function(e,t,o){if(void 0===t)return e.entity.display;const a=ar(e.points,t),n=a<0?void 0:e.points[a];return n&&null!==n.v?Mn(kn(n.v,e.entity.decimals,o.hass),e.entity.unit):"—"}(o,a,e)}`:W} + ${d.value?R`${function(e,t,o){if(void 0===t)return e.entity.display;const a=nr(e.points,t),n=a<0?void 0:e.points[a];return n&&null!==n.v?Mn(kn(n.v,e.entity.decimals,o.hass),e.entity.unit):"—"}(o,a,e)}`:W}
`:W,l=o.points.some(e=>null!==e.v)?n(o,r,s):R`
${_t(e.hass,"chart_no_data")}
`;return R`
${i}${l}
`})} -
`}const qr=r` +
`}const Wr=r` .multiples { display: grid; gap: 8px ${12}px; @@ -761,23 +761,23 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de .panel:not(:hover) .tooltip { display: none; } -`;yt("en",{annotation_at:"{label} at {time}"});const Wr=["line","band","time","state","point"],Vr=["max","min","last","first"],Yr={line:"red",band:"green",time:"grey",state:"amber",point:"primary"},Gr=e=>"number"==typeof e&&Number.isFinite(e),Xr=e=>"string"==typeof e&&/^[a-z_]+\.[a-z0-9_]+$/.test(e.trim());function Jr(e,t){if(!(void 0===e||Gr(e)||Xr(e)||"string"==typeof e&&""!==e.trim()&&Number.isFinite(Number(e))))throw new Error(`"${t}" must be a number or an entity id`)}const Kr=/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/,Qr=/^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;function ei(e){return(Array.isArray(e)?e:"string"==typeof e?e.split(","):[]).map(e=>String(e).trim()).filter(e=>""!==e)}function ti(e){const t=e.annotations;if(void 0!==e.zones&&"boolean"!=typeof e.zones)throw new Error('"zones" must be true or false');if(void 0!==t){if(!Array.isArray(t))throw new Error('"annotations" must be a list');t.forEach((e,t)=>{const o=e=>`annotations[${t}].${e}`;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error(`"annotations[${t}]" must have a "type"`);const a=e;if(!Wr.includes(a.type))throw new Error(`"${o("type")}" must be one of: ${Wr.join(", ")}`);for(const n of["label","color","state"])if(void 0!==a[n]&&"string"!=typeof a[n])throw new Error(`"${o(n)}" must be text`);for(const n of["dash","shade"])if(void 0!==a[n]&&"boolean"!=typeof a[n])throw new Error(`"${o(n)}" must be true or false`);if(Jr(a.value,o("value")),Jr(a.from,o("from")),Jr(a.to,o("to")),void 0!==a.entity&&!Xr(a.entity))throw new Error(`"${o("entity")}" must be an entity id`);if("line"===a.type&&void 0===a.value)throw new Error(`"${o("value")}" is needed for a line`);if("band"===a.type&&void 0===a.from&&void 0===a.to)throw new Error(`"${o("from")}" or "${o("to")}" is needed for a band`);if("time"===a.type){if(void 0!==a.times&&"string"!=typeof a.times&&!Array.isArray(a.times))throw new Error(`"${o("times")}" must be a time or a list of times`);const e=ei(a.times).find(e=>!function(e){const t=e.trim(),o=Kr.exec(t);return o?Number(o[1])<24&&Number(o[2])<60:Qr.test(t)||/\d{4}-\d{2}-\d{2}T.+(Z|[+-]\d{2}:?\d{2})$/.test(t)&&Number.isFinite(Date.parse(t))}(e));if(void 0!==e)throw new Error(`"${o("times")}" has "${e}": use 07:00 or 2026-10-28 07:00`)}if("state"===a.type&&void 0===a.entity)throw new Error(`"${o("entity")}" is needed for a state mark`);if(void 0!==a.at&&!Vr.includes(a.at))throw new Error(`"${o("at")}" must be one of: ${Vr.join(", ")}`)})}}function oi(e){return Array.isArray(e.annotations)?e.annotations:[]}function ai(e,t){if(void 0===e)return;if(Gr(e))return e;const o=Ta(e);if(null!==o)return o;return Ta(t.states[e.trim()]?.state)??void 0}function ni(e,t,o){const a=[],n=pa("day",t.start,t.end,o,800);for(const r of e){const e=Kr.exec(r);if(e){for(const t of n){const n=sa(t,o.timeZone);a.push(ca(o.timeZone,n.year,n.month,n.day,+e[1],+e[2],+(e[3]??0)))}continue}const t=Qr.exec(r),i=t?ca(o.timeZone,+t[1],+t[2],+t[3],+t[4],+t[5],+(t[6]??0)):Date.parse(r);Number.isFinite(i)&&a.push(i)}return[...new Set(a.filter(e=>e>=t.start&&e<=t.end))].sort((e,t)=>e-t)}function ri(e,t,o,a){const n=[],r=t?.trim().toLowerCase();for(let i=0;i0&&e[i-1].s===t.s)continue;if(void 0!==r&&t.s.toLowerCase()!==r)continue;let s=i+1;for(;so.end||n.push({start:c,end:Math.min(l,o.end),s:t.s})}return n}function ii(e){return Math.ceil(5.8*e.length)+10}const si=new WeakMap;const li=e=>Math.round(1e4*e)/100+"%";function ci(e,t){return e.entity&&t.find(t=>t.entity.entityId===e.entity.trim())||t[0]}function di(e,t,o,a){const n=e.points.filter(e=>null!==e.v&&(a||!e.carried&&(e.t!==o.end||"last"===t)));if(0!==n.length)return"first"===t?n[0]:"last"===t?n[n.length-1]:n.reduce((e,o)=>("max"===t?o.v>e.v:o.v{const i=Math.max(o,Number.isFinite(n.from)?n.from:o),s=Math.min(a,r+1i))return Z``;const l=t.y(s),c=t.y(i);return Z``})}(t.series[0].entity.bands,o):[];if(0===n.length&&0===r.length)return{behind:W,front:W,overlay:W};const i=[...r],s=[],l=[],{range:c}=t,d=e=>o.x(e),[u,h]=o.domain,p=function(e,t,o){const a=[...new Set(oi(e.raw).filter(e=>"state"===e.type&&e.entity).map(e=>e.entity.trim()))];if(0===a.length)return new Map;const n=a.join(",");let r=si.get(e.host);if(!r||r.shape!==n||t.startr.to+6e4||o&&r.to!==1/0){const i=(r?.generation??0)+1,s={shape:n,from:t.start,to:o?1/0:t.end,status:"loading",data:new Map,generation:i};r=s,si.set(e.host,s);const l=e.hass.callWS?.({type:"history/history_during_period",start_time:new Date(t.start).toISOString(),end_time:new Date(t.end).toISOString(),entity_ids:a,minimal_response:!0,no_attributes:!0,significant_changes_only:!1});l&&l.then(t=>{if(si.get(e.host)===s&&s.generation===i){for(const e of a){const o=(t?.[e]??[]).map(e=>({t:Math.round(1e3*(e.lu??e.lc??0)),s:String(e.s??"")})).filter(e=>e.t>0);s.data.set(e,o.slice(-2e3))}s.status="ready",e.requestUpdate()}},()=>{si.get(e.host)===s&&(s.status="error")})}const i=r;if("ready"===i.status&&o)for(const s of a){const t=e.hass.states[s];if(!t)continue;const o=Date.parse(t.last_changed??t.last_updated??""),a=i.data.get(s)??[],n=a[a.length-1];Number.isFinite(o)&&(!n||o>n.t&&n.s!==t.state)&&(a.push({t:o,s:t.state}),i.data.set(s,a))}return i.data}(e,c,t.live),m=Math.min(c.end,Date.now()),f=[],g=(e,t,o,a)=>{const n=o.slice(0,2e3);if(s.push(Z`${n.map(e=>Z``)}`),e.label&&n.length<=60)for(const r of n)f.push({t:r,label:e.label,color:t})};for(const y of n){const a=Ce(y.color??Yr[y.type]);if("line"===y.type){const t=ai(y.value,e.hass);if(void 0===t||th)continue;const n=o.y(t);if(s.push(Z``),y.label){const e=n/dn;l.push(R`"number"==typeof e&&Number.isFinite(e),Jr=e=>"string"==typeof e&&/^[a-z_]+\.[a-z0-9_]+$/.test(e.trim());function Kr(e,t){if(!(void 0===e||Xr(e)||Jr(e)||"string"==typeof e&&""!==e.trim()&&Number.isFinite(Number(e))))throw new Error(`"${t}" must be a number or an entity id`)}const Qr=/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/,ei=/^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;function ti(e){return(Array.isArray(e)?e:"string"==typeof e?e.split(","):[]).map(e=>String(e).trim()).filter(e=>""!==e)}function oi(e){const t=e.annotations;if(void 0!==e.zones&&"boolean"!=typeof e.zones)throw new Error('"zones" must be true or false');if(void 0!==t){if(!Array.isArray(t))throw new Error('"annotations" must be a list');t.forEach((e,t)=>{const o=e=>`annotations[${t}].${e}`;if("object"!=typeof e||null===e||Array.isArray(e))throw new Error(`"annotations[${t}]" must have a "type"`);const a=e;if(!Vr.includes(a.type))throw new Error(`"${o("type")}" must be one of: ${Vr.join(", ")}`);for(const n of["label","color","state"])if(void 0!==a[n]&&"string"!=typeof a[n])throw new Error(`"${o(n)}" must be text`);for(const n of["dash","shade"])if(void 0!==a[n]&&"boolean"!=typeof a[n])throw new Error(`"${o(n)}" must be true or false`);if(Kr(a.value,o("value")),Kr(a.from,o("from")),Kr(a.to,o("to")),void 0!==a.entity&&!Jr(a.entity))throw new Error(`"${o("entity")}" must be an entity id`);if("line"===a.type&&void 0===a.value)throw new Error(`"${o("value")}" is needed for a line`);if("band"===a.type&&void 0===a.from&&void 0===a.to)throw new Error(`"${o("from")}" or "${o("to")}" is needed for a band`);if("time"===a.type){if(void 0!==a.times&&"string"!=typeof a.times&&!Array.isArray(a.times))throw new Error(`"${o("times")}" must be a time or a list of times`);const e=ti(a.times).find(e=>!function(e){const t=e.trim(),o=Qr.exec(t);return o?Number(o[1])<24&&Number(o[2])<60:ei.test(t)||/\d{4}-\d{2}-\d{2}T.+(Z|[+-]\d{2}:?\d{2})$/.test(t)&&Number.isFinite(Date.parse(t))}(e));if(void 0!==e)throw new Error(`"${o("times")}" has "${e}": use 07:00 or 2026-10-28 07:00`)}if("state"===a.type&&void 0===a.entity)throw new Error(`"${o("entity")}" is needed for a state mark`);if(void 0!==a.at&&!Yr.includes(a.at))throw new Error(`"${o("at")}" must be one of: ${Yr.join(", ")}`)})}}function ai(e){return Array.isArray(e.annotations)?e.annotations:[]}function ni(e,t){if(void 0===e)return;if(Xr(e))return e;const o=Ta(e);if(null!==o)return o;return Ta(t.states[e.trim()]?.state)??void 0}function ri(e,t,o){const a=[],n=pa("day",t.start,t.end,o,800);for(const r of e){const e=Qr.exec(r);if(e){for(const t of n){const n=sa(t,o.timeZone);a.push(ca(o.timeZone,n.year,n.month,n.day,+e[1],+e[2],+(e[3]??0)))}continue}const t=ei.exec(r),i=t?ca(o.timeZone,+t[1],+t[2],+t[3],+t[4],+t[5],+(t[6]??0)):Date.parse(r);Number.isFinite(i)&&a.push(i)}return[...new Set(a.filter(e=>e>=t.start&&e<=t.end))].sort((e,t)=>e-t)}function ii(e,t,o,a){const n=[],r=t?.trim().toLowerCase();for(let i=0;i0&&e[i-1].s===t.s)continue;if(void 0!==r&&t.s.toLowerCase()!==r)continue;let s=i+1;for(;so.end||n.push({start:c,end:Math.min(l,o.end),s:t.s})}return n}function si(e){return Math.ceil(5.8*e.length)+10}const li=new WeakMap;const ci=e=>Math.round(1e4*e)/100+"%";function di(e,t){return e.entity&&t.find(t=>t.entity.entityId===e.entity.trim())||t[0]}function ui(e,t,o,a){const n=e.points.filter(e=>null!==e.v&&(a||!e.carried&&(e.t!==o.end||"last"===t)));if(0!==n.length)return"first"===t?n[0]:"last"===t?n[n.length-1]:n.reduce((e,o)=>("max"===t?o.v>e.v:o.v{const i=Math.max(o,Number.isFinite(n.from)?n.from:o),s=Math.min(a,r+1i))return Z``;const l=t.y(s),c=t.y(i);return Z``})}(t.series[0].entity.bands,o):[];if(0===n.length&&0===r.length)return{behind:W,front:W,overlay:W};const i=[...r],s=[],l=[],{range:c}=t,d=e=>o.x(e),[u,h]=o.domain,p=function(e,t,o){const a=[...new Set(ai(e.raw).filter(e=>"state"===e.type&&e.entity).map(e=>e.entity.trim()))];if(0===a.length)return new Map;const n=a.join(",");let r=li.get(e.host);if(!r||r.shape!==n||t.startr.to+6e4||o&&r.to!==1/0){const i=(r?.generation??0)+1,s={shape:n,from:t.start,to:o?1/0:t.end,status:"loading",data:new Map,generation:i};r=s,li.set(e.host,s);const l=e.hass.callWS?.({type:"history/history_during_period",start_time:new Date(t.start).toISOString(),end_time:new Date(t.end).toISOString(),entity_ids:a,minimal_response:!0,no_attributes:!0,significant_changes_only:!1});l&&l.then(t=>{if(li.get(e.host)===s&&s.generation===i){for(const e of a){const o=(t?.[e]??[]).map(e=>({t:Math.round(1e3*(e.lu??e.lc??0)),s:String(e.s??"")})).filter(e=>e.t>0);s.data.set(e,o.slice(-2e3))}s.status="ready",e.requestUpdate()}},()=>{li.get(e.host)===s&&(s.status="error")})}const i=r;if("ready"===i.status&&o)for(const s of a){const t=e.hass.states[s];if(!t)continue;const o=Date.parse(t.last_changed??t.last_updated??""),a=i.data.get(s)??[],n=a[a.length-1];Number.isFinite(o)&&(!n||o>n.t&&n.s!==t.state)&&(a.push({t:o,s:t.state}),i.data.set(s,a))}return i.data}(e,c,t.live),m=Math.min(c.end,Date.now()),f=[],g=(e,t,o,a)=>{const n=o.slice(0,2e3);if(s.push(Z`${n.map(e=>Z``)}`),e.label&&n.length<=60)for(const r of n)f.push({t:r,label:e.label,color:t})};for(const y of n){const a=Ce(y.color??Gr[y.type]);if("line"===y.type){const t=ni(y.value,e.hass);if(void 0===t||th)continue;const n=o.y(t);if(s.push(Z``),y.label){const e=n/dn;l.push(R`${y.label}`)}}else if("band"===y.type){const t=void 0===y.from?u:ai(y.from,e.hass),n=void 0===y.to?h:ai(y.to,e.hass);if(void 0===t||void 0===n)continue;const r=Math.max(u,Math.min(t,n)),s=Math.min(h,Math.max(t,n));if(!(s>r))continue;const c=o.y(s);i.push(Z``),y.label&&l.push(R``)}}else if("band"===y.type){const t=void 0===y.from?u:ni(y.from,e.hass),n=void 0===y.to?h:ni(y.to,e.hass);if(void 0===t||void 0===n)continue;const r=Math.max(u,Math.min(t,n)),s=Math.min(h,Math.max(t,n));if(!(s>r))continue;const c=o.y(s);i.push(Z``),y.label&&l.push(R`${y.label}`)}else if("time"===y.type)g(y,a,ni(ei(y.times),c,t.zone),"time");else if("state"===y.type){const e=ri(p.get(y.entity.trim())??[],y.state,c,m);y.shade&&i.push(Z`${e.map(e=>Z``)}`),g(y,a,e.map(e=>e.start).filter(e=>e>c.start),"state")}else if("point"===y.type){const n=ci(y,t.series),r=n&&di(n,y.at??"max",c,void 0!==t.group);if(!n||!r)continue;const i=d(r.t)/cn,s=o.y(r.v)/dn;if(!(i>=0&&i<=1&&s>=0&&s<=1))continue;const u=Mn(kn(r.v,n.entity.decimals,e.hass),n.entity.unit),h=i<.1?"start":i>.9?"end":"";l.push(R` + >`)}else if("time"===y.type)g(y,a,ri(ti(y.times),c,t.zone),"time");else if("state"===y.type){const e=ii(p.get(y.entity.trim())??[],y.state,c,m);y.shade&&i.push(Z`${e.map(e=>Z``)}`),g(y,a,e.map(e=>e.start).filter(e=>e>c.start),"state")}else if("point"===y.type){const n=di(y,t.series),r=n&&ui(n,y.at??"max",c,void 0!==t.group);if(!n||!r)continue;const i=d(r.t)/cn,s=o.y(r.v)/dn;if(!(i>=0&&i<=1&&s>=0&&s<=1))continue;const u=Mn(kn(r.v,n.entity.decimals,e.hass),n.entity.unit),h=i<.1?"start":i>.9?"end":"";l.push(R` ${y.label?R`${y.label} `:W}${u}`)}}const v=function(e,t,o=2){const a=e.map((e,t)=>t).sort((t,o)=>e[t].x-e[o].x),n=Array.from({length:o},()=>-1/0),r=e.map(()=>{});for(const i of a){const{x:o,width:a}=e[i],s=o+2+a>t?o-2-a:o+2,l=n.findIndex(e=>s>=e+4);l<0||(n[l]=s+a,r[i]=l)}return r}(f.map(e=>({x:d(e.t)/cn*a,width:ii(e.label)})),a);return f.forEach((t,o)=>{const n=v[o];if(void 0===n)return;const r=d(t.t)/cn,i=r*a+2+ii(t.label)>a;l.push(R``)}}const v=function(e,t,o=2){const a=e.map((e,t)=>t).sort((t,o)=>e[t].x-e[o].x),n=Array.from({length:o},()=>-1/0),r=e.map(()=>{});for(const i of a){const{x:o,width:a}=e[i],s=o+2+a>t?o-2-a:o+2,l=n.findIndex(e=>s>=e+4);l<0||(n[l]=s+a,r[i]=l)}return r}(f.map(e=>({x:d(e.t)/cn*a,width:si(e.label)})),a);return f.forEach((t,o)=>{const n=v[o];if(void 0===n)return;const r=d(t.t)/cn,i=r*a+2+si(t.label)>a;l.push(R`${t.label}`)}),{behind:i.length?Z`${i}`:W,front:s.length?Z`${s}`:W,overlay:l.length?R`
${l}
`:W}}const hi=r` + >`)}),{behind:i.length?Z`${i}`:W,front:s.length?Z`${s}`:W,overlay:l.length?R`
${l}
`:W}}const pi=r` .plot .zone { opacity: 0.1; } @@ -868,7 +868,7 @@ const gt=me(class extends fe{constructor(e){if(super(e),e.type!==he&&e.type!==de .annotation-label.point.end.below { transform: translate(calc(-100% + 6px), 7px); } -`;yt("en",{editor_annotations:"Annotations",editor_annotation_type:"Kind",editor_annotation_type_line:"Line at a value",editor_annotation_type_band:"Shaded band of values",editor_annotation_type_time:"Marks at set times",editor_annotation_type_state:"Marks when an entity changes",editor_annotation_type_point:"Label on a reading",editor_annotation_add_line:"Line",editor_annotation_add_band:"Band",editor_annotation_add_time:"Times",editor_annotation_add_state:"State changes",editor_annotation_add_point:"Point label",editor_annotation_add:"Add an annotation:",editor_annotation_value:"Value (a number or an entity)",editor_annotation_from:"From (a number or an entity; blank for the bottom)",editor_annotation_to:"To (a number or an entity; blank for the top)",editor_annotation_times:"Times: 07:00 for every day, or 2026-10-28 07:00 (commas between)",editor_annotation_entity:"Entity",editor_annotation_series:"Series (blank for the first)",editor_annotation_state:"State (blank for every change)",editor_annotation_shade:"Shade while in this state",editor_annotation_at:"Reading",editor_annotation_at_max:"Highest",editor_annotation_at_min:"Lowest",editor_annotation_at_last:"Latest",editor_annotation_at_first:"First",editor_annotation_label:"Label",editor_annotation_color:"Colour",editor_annotation_dash:"Dashed",editor_annotation_remove:"Remove annotation",editor_zones:"Shade the severity bands behind the chart"});const pi={line:["value","dash"],band:["from","to"],time:["times"],state:["entity","state","shade"],point:["at","series"]},mi={dash:!0,shade:!1,at:"max"};function fi(e,t,o){return{select:{mode:"dropdown",options:o.map(o=>({value:o,label:_t(e,`editor_annotation_${t}_${o}`)}))}}}const gi=new Map;function vi(e,t){const o={...e};return t.length?o.annotations=t:delete o.annotations,o}function yi(e,t){if("line"===e)return{type:e,value:0};if("band"===e)return{type:e,from:0,to:1};if("time"===e)return{type:e,times:["07:00"]};if("state"===e){const o=(t.entities??[])[0];return{type:e,entity:"string"==typeof o?o:o?.entity??"sensor.example",state:"on"}}return{type:e,at:"max"}}function bi(e){const t=Array.isArray(e.config.annotations)?e.config.annotations:[],o=(e.config.entities??[]).map(e=>"string"==typeof e?e:e.entity),a=t=>_t(e.hass,t),n=(o,a)=>{const n=[...t];a?n[o]=a:n.splice(o,1),e.commit(vi(e.config,n))};return R`