From 126371c08c12d89f666c92d8bbab9860a563f922 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:24:10 +0000 Subject: [PATCH 1/7] feat: histogram of how long a sensor spent in each range of values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new chart style, "Histogram" (style: histogram, in the editor's Distributions group), shows how often each entity's readings fell in each range: how long the living room sat below 18 °C this week, or the spread of a response time. Bins count time by default (count: time). History records changes, not samples: a room at 20 °C all day that flickers to 21 ten times in a minute has eleven rows, ten of them 21. Counting rows says it was mostly 21; adding up how long each value held says 20, which is what the room was. Each value counts until the next reading (the last until now or the end of the range), and time spent unavailable counts nothing. count: readings counts rows instead, for sensors where each reading is an event; the value carried in from before the range and the copy carried to its right edge are not counted. Long-term statistics rows count one period each, so a missing row is missing data rather than a long-held value. Grouped readings (group_by) count each group's length. Bin width is automatic (a round 1, 2 or 5 × 10ⁿ for a number of bins that suits the card's width), or set with bin_width (edges on its multiples) or bins (an exact split from the lowest to the highest reading), at most 100. x_min / x_max fix the range; readings outside it are left out. Bars show each entity's share of the whole (show_as: percent) or the hours / number of readings (show_as: total). Two or more entities are drawn side by side in each bin or over each other (bars: overlap, translucent with an outline). color_by: bands colours each bin with the colour band its middle falls in. It reuses the history layer and the shared frame, so periods and previous / next, statistics, grouping, axes, gridlines, compact and the header placement all work. Hovering or touching a bin shades it and lists each entity's share and time there. Every option is in the editor's Chart section; each entity has a bar colour. Value axes along the bottom, x_min / x_max and the pick-one-mark tooltip live in the new src/chart/xy.ts, shared with the other distribution charts; chartArea takes optional label formatters for axes that aren't time. Closes #19 --- src/chart/grid.ts | 19 +- src/chart/histogram.ts | 151 +++++++++++++++ src/chart/xy.ts | 265 +++++++++++++++++++++++++++ src/styles/histogram.ts | 386 +++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 18 +- test/chart-style.test.ts | 2 +- test/histogram.test.ts | 334 +++++++++++++++++++++++++++++++++ test/xy.test.ts | 131 +++++++++++++ 9 files changed, 1285 insertions(+), 23 deletions(-) create mode 100644 src/chart/histogram.ts create mode 100644 src/chart/xy.ts create mode 100644 src/styles/histogram.ts create mode 100644 test/histogram.test.ts create mode 100644 test/xy.test.ts diff --git a/src/chart/grid.ts b/src/chart/grid.ts index d8af4ee..dc55737 100644 --- a/src/chart/grid.ts +++ b/src/chart/grid.ts @@ -108,19 +108,19 @@ function gridLines(axes: PlotAxes): SVGTemplateResult { const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; -function valueLabels(axes: PlotAxes, ctx: ChartContext) { +function valueLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, value } = axes; return value.ticks.map((v) => { 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`${formatNumber(v, value.decimals, ctx.hass)}${format ? format(v) : formatNumber(v, value.decimals, ctx.hass)}`; }); } -function timeLabels(axes: PlotAxes, ctx: ChartContext) { +function timeLabels(axes: PlotAxes, ctx: ChartContext, format?: (v: number) => string) { const { scales, range } = axes; const offset = zoneOffset(timeZoneOf(ctx.hass)); return axes.times.map((t) => { @@ -128,7 +128,7 @@ function timeLabels(axes: PlotAxes, ctx: ChartContext) { // Labels near an edge line up with it instead of centring off the chart. const edge = left < 0.05 ? 'start' : left > 0.95 ? 'end' : ''; return html`${axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}${format ? format(t) : axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}`; }); } @@ -163,6 +163,9 @@ export interface ChartAreaOptions { hover?: Hover; /** A second value axis on the right. */ right?: RightAxis; + /** 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; } /** @@ -179,18 +182,20 @@ export function chartArea({ overlay = nothing, hover, right, + xLabel, + yLabel, }: ChartAreaOptions) { const { options } = axes; const outside = options.labels === 'outside'; const y = options.y && axes.value.ticks.length > 0; const x = options.x && axes.times.length > 0; - const yTicks = y ? valueLabels(axes, ctx) : []; + const yTicks = y ? valueLabels(axes, ctx, yLabel) : []; const yLabels = y ? html`
${yTicks}
` : nothing; - const xLabels = x ? html`
${timeLabels(axes, ctx)}
` : nothing; + const xLabels = x ? html`
${timeLabels(axes, ctx, xLabel)}
` : nothing; const sizer = y ? html`` : nothing; diff --git a/src/chart/histogram.ts b/src/chart/histogram.ts new file mode 100644 index 0000000..452c223 --- /dev/null +++ b/src/chart/histogram.ts @@ -0,0 +1,151 @@ +import type { Point } from '../history/types'; +import { niceStep } from './axes'; + +/** + * Histogram maths: where the bins go, and how much of each series' readings fall in each. + * + * History records changes, not samples: a sensor that sat at 20 °C all day and flickered to 21 + * ten times in a minute has eleven rows, ten of them 21. Counting rows says it was mostly 21; + * weighting each reading by how long it held says it was 20, which is what the room was. So bins + * count time by default (as the grouped mean in group.ts does), and plain row counts are an option + * for sensors where each reading is an event (a response time, a measured dose). + */ + +export const COUNT_MODES = ['time', 'readings'] as const; +export type CountMode = (typeof COUNT_MODES)[number]; + +/** The most bins a histogram draws; a narrower bin width is widened to fit. */ +export const MAX_BINS = 100; + +const tidy = (v: number) => Number(v.toPrecision(12)); + +export interface BinOptions { + /** How many bins (exact edges from the lowest to the highest reading). */ + bins?: number; + /** How wide each bin is (edges on whole multiples of it). Wins over `bins`. */ + width?: number; + /** Fixed ends of the binned range; readings outside are left out. */ + min?: number; + max?: number; +} + +/** + * Bin edges (each bin's start, then the last end) for readings from `lo` to `hi`. A set width + * lines edges up on its multiples; a set count splits the span exactly; otherwise a round width + * (1, 2 or 5 × 10ⁿ) gives about `target` bins. Flat data gets one bin around its value. + */ +export function binEdges(lo: number, hi: number, opts: BinOptions = {}, target = 12): number[] { + const start0 = opts.min ?? lo; + const end0 = opts.max ?? hi; + if (opts.bins !== undefined && opts.width === undefined) { + let [a, b] = [start0, end0]; + if (!(b > a)) [a, b] = [a - 0.5, a + 0.5]; + const step = (b - a) / opts.bins; + return Array.from({ length: opts.bins + 1 }, (_, i) => (i === opts.bins ? b : tidy(a + i * step))); + } + const span = end0 - start0; + let width = opts.width ?? (span > 0 ? niceStep(span, target) : niceStep(Math.max(Math.abs(start0), 1), 10)); + while (span / width > MAX_BINS) width = niceStep(span, MAX_BINS / 2); + const first = opts.min ?? tidy(Math.floor(start0 / width + 1e-9) * width); + let last = opts.max ?? tidy(Math.ceil(end0 / width - 1e-9) * width); + if (!(last > first)) last = tidy(first + width); + const edges = [first]; + for (let i = 1; edges[edges.length - 1] < last - width * 1e-9; i++) + edges.push(Math.min(last, tidy(first + i * width))); + return edges; +} + +/** The bin a value falls in (the top edge belongs to the last bin), or -1 outside the edges. */ +export function binIndex(v: number, edges: readonly number[]): number { + const n = edges.length - 1; + if (n < 1 || v < edges[0] || v > edges[n]) return -1; + if (v === edges[n]) return n - 1; + let lo = 0; + let hi = n - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (edges[mid] <= v) lo = mid; + else hi = mid - 1; + } + return lo; +} + +export interface WeightOptions { + mode: CountMode; + /** Nothing is known past this moment (now, or the range's end). */ + until: number; + /** Long-term statistics rows (each one equal period), not history. */ + statistics: boolean; +} + +/** + * How much each point counts, in ms of time or in readings. + * + * - Grouped points (`end` set) are one value per group: time counts the group's length up to + * `until`, readings count each group once. + * - Statistics rows are all one period long, so each counts the typical gap between rows (a + * missing row is missing data, not a long-held value). + * - History holds each value until the next reading; the last holds until `until`. In readings + * mode the value carried in from before the range, and the copy carried on to its right edge, + * are not readings and count nothing. + * + * Gaps (`v: null`) count nothing. + */ +export function pointWeights(points: readonly Point[], opts: WeightOptions): number[] { + const { mode, until, statistics } = opts; + if (points.length === 0) return []; + if (points[0].end !== undefined) { + return points.map((p) => (p.v === null ? 0 : mode === 'readings' ? 1 : Math.max(0, Math.min(p.end!, until) - p.t))); + } + if (statistics) { + if (mode === 'readings') return points.map((p) => (p.v === null || p.carried ? 0 : 1)); + const gaps = points + .slice(1) + .map((p, i) => p.t - points[i].t) + .filter((g) => g > 0) + .sort((a, b) => a - b); + const period = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 3_600_000; + return points.map((p) => (p.v === null || p.carried ? 0 : period)); + } + const last = points.length - 1; + return points.map((p, i) => { + if (p.v === null) return 0; + if (mode === 'readings') { + const edgeCopy = i === last && i > 0 && p.t >= until && points[i - 1].v === p.v; + return p.carried || edgeCopy ? 0 : 1; + } + const next = i < last ? points[i + 1].t : Math.max(p.t, until); + return Math.max(0, Math.min(next, until) - p.t); + }); +} + +/** How much of the weight falls in each bin; readings outside the edges are left out. */ +export function binTotals(points: readonly Point[], weights: readonly number[], edges: readonly number[]): number[] { + const totals = new Array(Math.max(0, edges.length - 1)).fill(0); + points.forEach((p, i) => { + if (p.v === null || !(weights[i] > 0)) return; + const b = binIndex(p.v, edges); + if (b >= 0) totals[b] += weights[i]; + }); + return totals; +} + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** A length of time in a few words: "2 d 4 h", "3 h 20 min", "45 min", "< 1 min". */ +export function formatDuration(ms: number): string { + if (ms < MINUTE) return ms > 0 ? '< 1 min' : '0 min'; + const minutes = Math.round(ms / MINUTE); + if (minutes < 60) return `${minutes} min`; + if (ms < DAY) { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return m ? `${h} h ${m} min` : `${h} h`; + } + const hours = Math.round(ms / HOUR); + const d = Math.floor(hours / 24); + const h = hours % 24; + return h ? `${d} d ${h} h` : `${d} d`; +} diff --git a/src/chart/xy.ts b/src/chart/xy.ts new file mode 100644 index 0000000..4541cee --- /dev/null +++ b/src/chart/xy.ts @@ -0,0 +1,265 @@ +import { css, html, nothing } from 'lit'; +import type { TemplateResult } from 'lit'; +import type { FieldBinding } from '../editor/schemas'; +import { registerStrings } from '../localize'; +import type { ChartContext } from '../styles/index'; +import type { CardConfig } from '../types'; +import { axisOptions, valueAxis } from './axes'; +import { VIEW_H, VIEW_W } from './frame'; +import { chartWidth } from './grid'; +import type { PlotAxes } from './grid'; +import { linear } from './scale'; +import { TOUCH_LINGER_MS, tooltipSide } from './tooltip'; +import type { Hover } from './tooltip'; + +/** + * Shared pieces for charts whose bottom axis is a value, not time (histogram, scatter, bubble, + * box plot): the value axes, an x range from YAML, and a tooltip that picks one mark under the + * pointer rather than a moment in time. + */ + +/** Room each bottom label needs, in px, when choosing how many fit. */ +const X_LABEL_PX = 70; +/** Gaps between value labels on a full-height plot: about one per 40 px. */ +const Y_GAPS = 4; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +/** Throw a readable error for a bad `x_min` / `x_max`. */ +export function validateXRange(raw: CardConfig): void { + for (const k of ['x_min', 'x_max'] as const) { + if (raw[k] !== undefined && !isNumber(raw[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(raw.x_min) && isNumber(raw.x_max) && raw.x_min >= raw.x_max) { + throw new Error('"x_min" must be below "x_max"'); + } +} + +registerStrings('en', { + editor_x_min: 'Lowest value across', + editor_x_max: 'Highest value across', +}); + +/** Editor fields for `x_min` / `x_max`: empty fits the readings. */ +export const X_RANGE_FIELDS: FieldBinding[] = [ + { name: 'x_min', path: ['x_min'] }, + { name: 'x_max', path: ['x_max'] }, +]; + +/** The x range as one row of the editor form. */ +export const X_RANGE_SCHEMA = [ + { + type: 'grid', + name: '', + schema: [ + { name: 'x_min', selector: { number: { mode: 'box', step: 'any' } } }, + { name: 'x_max', selector: { number: { mode: 'box', step: 'any' } } }, + ], + }, +]; + +/** The fixed ends of the bottom axis, when set. */ +export function xRange(raw: CardConfig): { min?: number; max?: number } { + return { min: isNumber(raw.x_min) ? raw.x_min : undefined, max: isNumber(raw.x_max) ? raw.x_max : undefined }; +} + +export interface ValueAxesOptions { + /** Fixed ends of the bottom axis (kept exactly). */ + x?: { min?: number; max?: number }; + /** Where the side axis starts when the card doesn't fix it (0 for counts). */ + yFloor?: number; +} + +/** + * Axes for a chart with values along the bottom: round steps on both, sized to the card, the side + * axis honouring the card's `y_min` / `y_max`. Returned in the shape `chartArea` draws, with the + * bottom ticks in `times` and the bottom span in `range`. + */ +export function valueAxes( + ctx: ChartContext, + xData: [number, number] | undefined, + yData: [number, number] | undefined, + opts: ValueAxesOptions = {}, +): PlotAxes { + const options = axisOptions(ctx.raw); + const count = Math.max(2, Math.min(8, Math.round(chartWidth(ctx) / X_LABEL_PX))); + const x = valueAxis(xData, opts.x ?? {}, count); + const yFixed = { min: options.min ?? opts.yFloor, max: options.max }; + const value = valueAxis(yData, yFixed, Y_GAPS); + return { + options, + value, + times: x.ticks, + unit: 'minute', + range: { start: x.domain[0], end: x.domain[1] }, + scales: { + x: linear(x.domain, [0, VIEW_W]), + y: linear(value.domain, [VIEW_H, 0]), + domain: value.domain, + }, + }; +} + +/** Decimals that print the bottom axis' round steps exactly. */ +export function xDecimals(axes: PlotAxes): number { + const [a, b] = axes.times; + if (a === undefined || b === undefined) return 0; + return Math.max(0, -Math.floor(Math.log10(Math.abs(b - a)) + 1e-9)); +} + +/* ------------------------------------------------------------- picking ------------------------------------------------------------- */ + +interface PickState { + i?: number; + timer?: ReturnType; +} + +const picks = new WeakMap(); + +function pickState(host: HTMLElement): PickState { + let s = picks.get(host); + if (!s) { + s = {}; + picks.set(host, s); + } + return s; +} + +/** The mark the pointer is on, if any (an index the chart chose). */ +export function pickedIndex(host: HTMLElement): number | undefined { + return picks.get(host)?.i; +} + +/** Forget the pointer (the card leaves the page). */ +export function stopPick(host: HTMLElement): void { + const s = picks.get(host); + if (s?.timer !== undefined) clearTimeout(s.timer); + picks.delete(host); +} + +/** + * Pointer handlers that ask `pick` which mark is under the pointer, given where it is as a + * fraction of the element across and down, and the element's size in px. The card redraws only + * when the answer changes. A mouse leaving clears it at once; a finger lifting leaves it up for a + * moment so it can be read. + */ +export function pickHandlers( + ctx: Pick, + pick: (fx: number, fy: number, width: number, height: number) => number | undefined, +): Hover { + const state = pickState(ctx.host); + const set = (i: number | undefined) => { + if (i === state.i) return; + state.i = i; + ctx.requestUpdate(); + }; + const cancel = () => { + if (state.timer !== undefined) clearTimeout(state.timer); + state.timer = undefined; + }; + return { + move(ev) { + cancel(); + const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); + const fx = rect.width > 0 ? Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width)) : 0; + const fy = rect.height > 0 ? Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height)) : 0; + set(pick(fx, fy, rect.width, rect.height)); + }, + leave(ev) { + cancel(); + if (ev.pointerType === 'touch' || ev.pointerType === 'pen') { + state.timer = setTimeout(() => { + state.timer = undefined; + set(undefined); + }, TOUCH_LINGER_MS); + } else { + set(undefined); + } + }, + }; +} + +/** + * The index of the point nearest the pointer in screen distance, or undefined when none is within + * `reach` px. Points are in the plot's view box (VIEW_W × VIEW_H), which stretches to `width` × + * `height` px; `fx`, `fy` are the pointer as fractions of that box. + */ +export function nearestPoint( + points: readonly (readonly [number, number])[], + fx: number, + fy: number, + width: number, + height: number, + reach = 24, +): number | undefined { + let best: number | undefined; + let bestD = reach * reach; + const px = fx * width; + const py = fy * height; + points.forEach(([x, y], i) => { + const dx = (x / VIEW_W) * width - px; + const dy = (y / VIEW_H) * height - py; + const d = dx * dx + dy * dy; + if (d <= bestD) { + bestD = d; + best = i; + } + }); + return best; +} + +export interface PickRow { + color?: string; + name: string; + value: string; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +/** + * A tooltip box for one picked mark, beside a point at (`x`, `y`) given as fractions of the plot, + * with a heading and rows of name and value. `dot` puts a ring on the point; `band` shades a + * column from `band[0]` to `band[1]` across instead (a bin or a box). + */ +export function pickTooltip(opts: { + x: number; + y: number; + plotWidth: number; + title: string; + rows: PickRow[]; + dot?: string; + band?: [number, number]; +}): TemplateResult | typeof nothing { + const { x, y, plotWidth, title, rows, dot, band } = opts; + if (!(x >= 0 && x <= 1)) return nothing; + const side = tooltipSide(x, plotWidth); + return html``; +} + +/** The shaded column behind a picked bin or box; the rest comes from tooltipStyles. */ +export const pickStyles = css` + .pick-band { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 8%, transparent); + } +`; diff --git a/src/styles/histogram.ts b/src/styles/histogram.ts new file mode 100644 index 0000000..90402b9 --- /dev/null +++ b/src/styles/histogram.ts @@ -0,0 +1,386 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { bandFor, baseColor } from '../bands'; +import { stepDecimals, validateAxisOptions } from '../chart/axes'; +import { + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + seriesHeader, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries } from '../chart/frame'; +import { chartArea, gridStyles, plotWidth, stopChartWidth } from '../chart/grid'; +import { COUNT_MODES, MAX_BINS, binEdges, binTotals, formatDuration, pointWeights } from '../chart/histogram'; +import type { CountMode } from '../chart/histogram'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { tooltipEnabled, tooltipSide, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { + X_RANGE_FIELDS, + X_RANGE_SCHEMA, + pickHandlers, + pickStyles, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from '../chart/xy'; +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, HomeAssistant } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +const SHOW_AS = ['percent', 'total'] as const; +type ShowAs = (typeof SHOW_AS)[number]; +const BARS = ['side', 'overlap'] as const; +type Bars = (typeof BARS)[number]; +const COLOR_BY = ['series', 'bands'] as const; + +const HOUR = 3_600_000; + +registerStrings('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 isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function oneOf(raw: CardConfig, 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(', ')}`); + } +} + +interface HistogramOptions { + count: CountMode; + showAs: ShowAs; + bars: Bars; + bands: boolean; + bins?: number; + width?: number; +} + +function options(raw: CardConfig): HistogramOptions { + return { + count: (raw.count as CountMode | undefined) ?? 'time', + showAs: (raw.show_as as ShowAs | undefined) ?? 'percent', + bars: (raw.bars as Bars | undefined) ?? 'side', + bands: raw.color_by === 'bands', + bins: isNumber(raw.bins) ? raw.bins : undefined, + width: isNumber(raw.bin_width) ? raw.bin_width : undefined, + }; +} + +/** One series' share of each bin, ready to draw. */ +interface Binned { + series: ChartSeries; + /** Weight per bin: ms of time, or readings. */ + totals: number[]; + /** What is drawn per bin: a percentage, hours, or readings. */ + heights: number[]; + whole: number; +} + +/** A small step, so a value printed for a bin edge isn't longer than it needs to be. */ +function edgeDecimals(edges: number[]): number { + if (edges.length < 2) return 0; + const width = edges[1] - edges[0]; + const exact = edges.every((e) => Math.abs(e - Number(e.toFixed(stepDecimals(width)))) < width * 1e-6); + return exact ? stepDecimals(width) : Math.min(4, stepDecimals(width) + 1); +} + +function binned(ctx: ChartContext, now: number) { + const frame = historyFrame(ctx, now); + const opts = options(ctx.raw); + const until = Math.min(frame.range.end, now); + const weights = frame.series.map((s) => + pointWeights(s.points, { mode: opts.count, until, statistics: frame.source === 'statistics' }), + ); + let lo = Infinity; + let hi = -Infinity; + frame.series.forEach((s, i) => + s.points.forEach((p, k) => { + if (p.v === null || !(weights[i][k] > 0)) return; + lo = Math.min(lo, p.v); + hi = Math.max(hi, p.v); + }), + ); + const fixed = xRange(ctx.raw); + const side = opts.bars === 'side' && frame.series.length > 1; + const target = Math.max(5, Math.min(24, Math.round(plotWidth(ctx) / (side ? 40 : 28)))); + const edges = + lo === Infinity + ? [] + : binEdges(lo, hi, { bins: opts.bins, width: opts.width, min: fixed.min, max: fixed.max }, target); + const data: Binned[] = frame.series.map((s, i) => { + const totals = binTotals(s.points, weights[i], edges); + const whole = totals.reduce((a, b) => a + b, 0); + const heights = totals.map((t) => + opts.showAs === 'percent' ? (whole > 0 ? (t / whole) * 100 : 0) : opts.count === 'time' ? t / HOUR : t, + ); + return { series: s, totals, heights, whole }; + }); + return { frame, opts, edges, data, side }; +} + +function amountText(total: number, count: CountMode, ctx: ChartContext): string { + if (count === 'time') return formatDuration(total); + return total === 1 + ? localize(ctx.hass, 'histogram_reading') + : localize(ctx.hass, 'histogram_readings', { n: formatNumber(total, 0, ctx.hass) }); +} + +export const histogram: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .style-histogram .bin { + shape-rendering: crispEdges; + } + .style-histogram .overlap .bin { + fill-opacity: 0.35; + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate(raw: CardConfig) { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + validateXRange(raw); + oneOf(raw, 'count', COUNT_MODES); + oneOf(raw, 'show_as', SHOW_AS); + oneOf(raw, 'bars', BARS); + oneOf(raw, 'color_by', COLOR_BY); + if ( + raw.bins !== undefined && + !(Number.isInteger(raw.bins) && (raw.bins as number) >= 1 && (raw.bins as number) <= MAX_BINS) + ) { + throw new Error(`"bins" must be a whole number from 1 to ${MAX_BINS}`); + } + if (raw.bin_width !== undefined && !(isNumber(raw.bin_width) && raw.bin_width > 0)) { + throw new Error('"bin_width" must be a number above 0'); + } + }, + editor: histogramEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const now = Date.now(); + const { frame, opts, edges, data, side } = binned(ctx, now); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const layout = chartLayout(ctx.raw); + const message = + frameMessage(frame, ctx) ?? (edges.length < 2 ? frameMessage({ ...frame, series: [] }, ctx) : undefined); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + + const top = Math.max(0, ...data.flatMap((d) => d.heights)); + const axes = valueAxes(ctx, [edges[0], edges[edges.length - 1]], [0, top], { + x: { min: edges[0], max: edges[edges.length - 1] }, + yFloor: 0, + }); + const { scales } = axes; + const base = scales.y(Math.max(0, axes.value.domain[0])); + const n = data.length; + const overlap = opts.bars === 'overlap' && n > 1; + const bars = svg`${data.map((d, si) => { + const bins = d.heights.map((h, b) => { + if (!(h > 0)) return nothing; + const x0 = scales.x(edges[b]); + const x1 = scales.x(edges[b + 1]); + const pad = Math.min(4, (x1 - x0) * 0.08); + const slot = (x1 - x0 - 2 * pad) / (side ? n : 1); + const x = x0 + pad + (side ? slot * si : 0); + const y = scales.y(h); + const fill = + opts.bands && d.series.entity.bands.length + ? baseColor(bandFor((edges[b] + edges[b + 1]) / 2, d.series.entity.bands).color) + : d.series.color; + return svg``; + }); + return svg`${bins}`; + })}`; + + const decimals = edgeDecimals(edges); + const xLabel = (v: number) => formatNumber(v, xDecimals(axes), ctx.hass); + const yLabel = (v: number) => + opts.showAs === 'percent' + ? `${formatNumber(v, axes.value.decimals, ctx.hass)}%` + : opts.count === 'time' + ? `${formatNumber(v, axes.value.decimals, ctx.hass)} h` + : formatNumber(v, axes.value.decimals, ctx.hass); + + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + let overlay: TemplateResult | typeof nothing = nothing; + if (picked !== undefined && picked < edges.length - 1) { + const unit = data[0]?.series.entity.unit ?? ''; + const from = formatNumber(edges[picked], decimals, ctx.hass); + const to = formatNumber(edges[picked + 1], decimals, ctx.hass); + const x0 = scales.x(edges[picked]) / VIEW_W; + const x1 = scales.x(edges[picked + 1]) / VIEW_W; + // Beside the bin, not over it: from its right edge, or its left when there's no room. + const pw = plotWidth(ctx); + overlay = pickTooltip({ + x: tooltipSide(x1, pw) === 'right' ? x1 : x0, + y: 0, + plotWidth: pw, + title: withUnit(`${from} – ${to}`, unit), + band: [x0, x1], + rows: data.map((d) => { + const share = d.whole > 0 ? (d.totals[picked] / d.whole) * 100 : 0; + return { + color: d.series.color, + name: d.series.entity.name, + value: `${formatNumber(share, share > 0 && share < 1 ? 1 : 0, ctx.hass)}% · ${amountText(d.totals[picked], opts.count, ctx)}`, + }; + }), + }); + } + + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, 'editor_style_histogram'), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content: svg`${bars}`, + label, + xLabel, + yLabel, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing}${overlay}`, + hover: tooltip + ? pickHandlers(ctx, (fx) => { + const v = edges[0] + fx * (edges[edges.length - 1] - edges[0]); + const b = edges.findIndex((e, i) => i < edges.length - 1 && v >= e && v <= edges[i + 1]); + return b < 0 ? undefined : b; + }) + : undefined, + }), + ); + }, +}; + +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'bar_color', path: ['color'] }]; +const ENTITY_SCHEMA = [{ name: 'bar_color', selector: { text: {} } }]; + +const HIST_AXES_FIELDS: FieldBinding[] = AXES_FIELDS.map((f) => + f.name === 'x_axis' ? { ...f, name: 'hist_x_axis' } : f, +); + +const HISTOGRAM_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'count', path: ['count'], default: 'time' }, + { name: 'show_as', path: ['show_as'], default: 'percent' }, + { name: 'bins', path: ['bins'] }, + { name: 'bin_width', path: ['bin_width'] }, + ...X_RANGE_FIELDS, + { name: 'bars', path: ['bars'], default: 'side' }, + { name: 'color_by', path: ['color_by'], default: 'series' }, + ...HIST_AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +function histogramEditor(ctx: ChartEditorContext): TemplateResult { + const hass: HomeAssistant | undefined = ctx.hass; + const schema = cachedSchema(`histogram|${historyVariant(ctx.config)}`, hass, () => [ + ...historySchema(hass, ctx.config), + { + type: 'grid', + name: '', + schema: [ + { name: 'count', selector: choice(hass, 'count', COUNT_MODES) }, + { name: 'show_as', selector: choice(hass, 'show_as', SHOW_AS) }, + ], + }, + { + type: 'grid', + name: '', + schema: [ + { name: 'bins', selector: { number: { min: 1, max: MAX_BINS, mode: 'box' } } }, + { name: 'bin_width', selector: { number: { min: 0, mode: 'box', step: 'any' } } }, + ], + }, + ...X_RANGE_SCHEMA, + { + type: 'grid', + name: '', + schema: [ + { name: 'bars', selector: choice(hass, 'bars', BARS) }, + { name: 'color_by', selector: choice(hass, 'color_by', COLOR_BY) }, + ], + }, + ...axesSchema(hass).map((part) => + (part as { type?: string }).type === 'grid' + ? { + ...(part as object), + schema: (part as { schema: { name: string }[] }).schema.map((f) => + f.name === 'x_axis' ? { ...f, name: 'hist_x_axis' } : f, + ), + } + : part, + ), + ...layoutSchema(hass), + ]); + return chartForm(ctx, 'chart', schema, HISTOGRAM_FIELDS); +} diff --git a/src/styles/index.ts b/src/styles/index.ts index bd9cf19..bb15abc 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6,6 +6,7 @@ import { area } from './area'; import { bar } from './bar'; import { columnChart } from './column'; import { combined } from './combined'; +import { histogram } from './histogram'; import { line } from './line'; import { needle } from './needle'; import { pictograph } from './pictograph'; @@ -120,4 +121,5 @@ export const STYLES: Record = { rings, pictograph, waffle, + histogram, }; diff --git a/src/types.ts b/src/types.ts index eb318e5..505f54d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,20 +1,7 @@ import type { Rule } from './fill'; export type StyleName = - | 'bar' - | 'thin' - | 'segmented' - | 'tank' - | 'ring' - | 'needle' - | 'arc' - | 'line' - | 'column' - | 'area' - | 'combined' - | 'pie' - | 'rings' - | 'pictograph' - | 'waffle'; + 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' + | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -31,6 +18,7 @@ export const STYLE_NAMES: StyleName[] = [ 'rings', 'pictograph', 'waffle', + 'histogram', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/chart-style.test.ts b/test/chart-style.test.ts index b024369..2ec4d94 100644 --- a/test/chart-style.test.ts +++ b/test/chart-style.test.ts @@ -133,7 +133,7 @@ describe('editor with chart styles', () => { it('groups the style picker and draws chart thumbnails from the style', async () => { const ed = await editor({}); const groups = [...ed.shadowRoot!.querySelectorAll('.styles')].map((g) => g.getAttribute('data-group')); - expect(groups).toEqual(['bars', 'gauges', 'history', 'totals']); + expect(groups.slice(0, 3)).toEqual(['bars', 'gauges', 'history']); expect(ed.shadowRoot!.querySelector('.style[data-style="fake"] svg rect')).not.toBeNull(); expect(ed.shadowRoot!.querySelector('.style-group')!.textContent).toBe('Bars'); }); diff --git a/test/histogram.test.ts b/test/histogram.test.ts new file mode 100644 index 0000000..1e256d4 --- /dev/null +++ b/test/histogram.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { binEdges, binIndex, binTotals, formatDuration, pointWeights } from '../src/chart/histogram'; +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', + 'ha-switch', +]) { + if (!customElements.get(tag)) customElements.define(tag, class extends Stub {}); +} +await import('../src/editor/chartdeck-card-editor'); + +const H = 3_600_000; + +describe('bin edges', () => { + it('picks a round width for about the target number of bins, on its multiples', () => { + expect(binEdges(17.3, 26.8, {}, 10)).toEqual([17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]); + expect(binEdges(0.12, 0.47, {}, 4)).toEqual([0.1, 0.2, 0.3, 0.4, 0.5]); + }); + + it('honours a set width, a set count, and fixed ends', () => { + expect(binEdges(17.3, 19.1, { width: 0.5 })).toEqual([17, 17.5, 18, 18.5, 19, 19.5]); + expect(binEdges(10, 20, { bins: 4 })).toEqual([10, 12.5, 15, 17.5, 20]); + expect(binEdges(10, 20, { bins: 2, min: 0, max: 30 })).toEqual([0, 15, 30]); + expect(binEdges(3, 40, { width: 10, min: 0, max: 25 })).toEqual([0, 10, 20, 25]); + // A width too narrow for the span is widened to keep the bin count sane. + expect(binEdges(0, 1000, { width: 0.001 }).length).toBeLessThanOrEqual(101); + }); + + it('gives flat readings one bin around their value', () => { + expect(binEdges(20, 20, {})).toEqual([20, 22]); + expect(binEdges(5, 5, { bins: 3 }).map((e) => e.toFixed(3))).toEqual(['4.500', '4.833', '5.167', '5.500']); + expect(binEdges(0, 0, {})).toEqual([0, 0.1]); + }); + + it('finds the bin for a value, with the top edge in the last bin', () => { + const edges = [0, 10, 20, 30]; + expect(binIndex(0, edges)).toBe(0); + expect(binIndex(9.99, edges)).toBe(0); + expect(binIndex(10, edges)).toBe(1); + expect(binIndex(30, edges)).toBe(2); + expect(binIndex(-1, edges)).toBe(-1); + expect(binIndex(31, edges)).toBe(-1); + expect(binIndex(1, [0])).toBe(-1); + }); +}); + +describe('weights', () => { + // 20 for 10 h, a burst of ten 30s in one minute, then back to 20 for the rest of the day. + const burst: Point[] = [ + { t: 0, v: 20 }, + ...Array.from({ length: 10 }, (_, i) => ({ t: 10 * H + i * 6000, v: 30 })), + { t: 10 * H + 60_000, v: 20 }, + { t: 24 * H, v: 20 }, + ]; + + it('counts time held, so a burst of changes does not outweigh a steady day', () => { + const w = pointWeights(burst, { mode: 'time', until: 24 * H, statistics: false }); + const totals = binTotals(burst, w, [15, 25, 35]); + expect(totals[0]).toBe(24 * H - 60_000); + expect(totals[1]).toBe(60_000); + // Counting rows instead says the 30s won: ten of them against two (the right-edge copy is not a reading). + const rows = binTotals( + burst, + pointWeights(burst, { mode: 'readings', until: 24 * H, statistics: false }), + [15, 25, 35], + ); + expect(rows).toEqual([2, 10]); + }); + + it('stops the last value at now, and skips gaps and the value carried in', () => { + const pts: Point[] = [ + { t: 0, v: 5, carried: true }, + { t: H, v: null }, + { t: 2 * H, v: 7 }, + ]; + expect(pointWeights(pts, { mode: 'time', until: 3 * H, statistics: false })).toEqual([H, 0, H]); + expect(pointWeights(pts, { mode: 'readings', until: 3 * H, statistics: false })).toEqual([0, 0, 1]); + expect(pointWeights([], { mode: 'time', until: 0, statistics: false })).toEqual([]); + }); + + it('weighs statistics rows as one period each, and grouped points by their group', () => { + const rows: Point[] = [ + { t: 0, v: 1 }, + { t: H, v: 2 }, + { t: 5 * H, v: 3 }, + { t: 6 * H, v: null }, + ]; + expect(pointWeights(rows, { mode: 'time', until: 9 * H, statistics: true })).toEqual([H, H, H, 0]); + expect(pointWeights(rows, { mode: 'readings', until: 9 * H, statistics: true })).toEqual([1, 1, 1, 0]); + expect(pointWeights([{ t: 0, v: 1 }], { mode: 'time', until: H, statistics: true })).toEqual([H]); + const groups: Point[] = [ + { t: 0, end: 24 * H, v: 1 }, + { t: 24 * H, end: 48 * H, v: 2 }, + { t: 48 * H, end: 72 * H, v: null }, + ]; + expect(pointWeights(groups, { mode: 'time', until: 30 * H, statistics: false })).toEqual([24 * H, 6 * H, 0]); + expect(pointWeights(groups, { mode: 'readings', until: 30 * H, statistics: false })).toEqual([1, 1, 0]); + }); + + it('writes durations in a few words', () => { + expect(formatDuration(0)).toBe('0 min'); + expect(formatDuration(20_000)).toBe('< 1 min'); + expect(formatDuration(45 * 60_000)).toBe('45 min'); + expect(formatDuration(2 * H)).toBe('2 h'); + expect(formatDuration(3 * H + 20 * 60_000)).toBe('3 h 20 min'); + expect(formatDuration(52 * H)).toBe('2 d 4 h'); + expect(formatDuration(48 * H)).toBe('2 d'); + }); +}); + +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: '21', lu: sec(14 * H) }, + { s: 'unavailable', lu: sec(8 * H) }, + { s: '22.5', lu: sec(2 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], +}; + +function hassWith(data: Record) { + const hass = makeHass({ + 'sensor.in': ['22.5', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => data) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, data = history) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'histogram', + entities: ['sensor.in'], + ...config, + } as CardConfig); + card.hass = hassWith(data); + 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 q = (card: Card, sel: string) => card.shadowRoot!.querySelector(sel); + +afterEach(() => { + document.body.innerHTML = ''; +}); + +function hover(card: Card, fx: number, type = 'mouse') { + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + const ev = new MouseEvent('pointermove', { clientX: fx * 400, clientY: 50, bubbles: true }); + Object.defineProperty(ev, 'pointerType', { value: type }); + area.dispatchEvent(ev); +} + +describe('histogram chart', () => { + it('draws one bar per range, sized by share of time, with value labels along the bottom', async () => { + const card = await mount({ bin_width: 1 }); + const bars = qa(card, 'rect.bin'); + // 19 for 6 h, 21 for 6 h, 22.5 for 2 h: three bins with time in them. + expect(bars).toHaveLength(3); + const heights = bars.map((b) => Number(b.getAttribute('height'))); + expect(heights[0]).toBeCloseTo(heights[1]); + expect(heights[0]).toBeGreaterThan(heights[2] * 2.5); + expect(qa(card, '.y-axis .axis-label').some((l) => l.textContent!.includes('%'))).toBe(true); + expect(qa(card, '.x-axis .axis-label').map((l) => l.textContent)).toContain('20'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Histogram chart of Inside'); + expect(bars[0].getAttribute('fill')).toContain('--primary-color'); + }); + + it('counts readings instead, as hours, and colours bins by band', async () => { + const readings = await mount({ count: 'readings', show_as: 'total', bin_width: 1 } as Partial); + expect(qa(readings, 'rect.bin')).toHaveLength(3); + const hours = await mount({ + show_as: 'total', + bins: 2, + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 21, color: 'red' }, + ], + } as Partial); + expect(qa(hours, '.y-axis .axis-label').some((l) => / h$/.test(l.textContent!))).toBe(true); + const fills = qa(hours, 'rect.bin').map((b) => b.getAttribute('fill')); + expect(fills[0]).toContain('--info-color'); + expect(fills[1]).toContain('--error-color'); + }); + + it('puts two entities side by side, or over each other', async () => { + const side = await mount({ entities: ['sensor.in', 'sensor.out'], bin_width: 5 } as Partial); + const groups = qa(side, 'g.series'); + expect(groups).toHaveLength(2); + const [a] = qa(side, 'g.series[data-series="0"] rect.bin'); + const [b] = qa(side, 'g.series[data-series="1"] rect.bin'); + expect(Number(a.getAttribute('width'))).toBeLessThan(200); + expect(b.getAttribute('fill')).toContain('--warning-color'); + const over = await mount({ + entities: ['sensor.in', 'sensor.out'], + bars: 'overlap', + bin_width: 5, + } as Partial); + expect(q(over, 'g.overlap')).not.toBeNull(); + expect(qa(over, 'rect.bin')[0].getAttribute('stroke')).not.toBe('none'); + }); + + it('leaves out readings outside a fixed range', async () => { + const card = await mount({ x_min: 20, x_max: 25, bin_width: 1 } as Partial); + expect(qa(card, 'rect.bin')).toHaveLength(2); + expect(qa(card, '.x-axis .axis-label')[0].textContent).toBe('20'); + }); + + it('shows the time and share in a bin on hover', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out'], bin_width: 5 } as Partial); + // Bins 5–10, 10–15, 15–20, 20–25: the first quarter is 5–10. + hover(card, 0.1); + await card.updateComplete; + expect(q(card, '.tooltip-time')!.textContent).toBe('5 – 10 °C'); + const rows = qa(card, '.tooltip-row').map((r) => r.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows[0]).toBe('Inside 0% · 0 min'); + expect(rows[1]).toMatch(/^Outside 100% · 20 h/); + expect(q(card, '.pick-band')).not.toBeNull(); + const area = q(card, '.plot-area') as HTMLElement; + area.dispatchEvent(Object.assign(new MouseEvent('pointerleave', { bubbles: true }), {})); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const counted = await mount({ count: 'readings', bin_width: 5 } as Partial); + hover(counted, 0.9); + await counted.updateComplete; + expect(q(counted, '.tooltip-row')!.textContent).toMatch(/67% · 2 readings\s*$/); + const many = await mount({ count: 'readings', bins: 1 } as Partial); + hover(many, 0.5); + await many.updateComplete; + expect(q(many, '.tooltip-row')!.textContent).toMatch(/3 readings\s*$/); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area.hoverable')).toBeNull(); + }); + + it('says when there is nothing to draw, and copes with an unavailable or missing entity', async () => { + const empty = await mount({}, {}); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + const card = await mount({ entities: ['sensor.in', 'sensor.missing'], header: 'over' } as Partial); + expect(qa(card, 'g.series')).toHaveLength(2); + expect(qa(card, 'g.series[data-series="1"] rect')).toHaveLength(0); + expect(q(card, '.header-over')).not.toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'histogram', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ count: 'rows' })).toThrow('"count" must be one of: time, readings'); + expect(set({ show_as: 'pie' })).toThrow(/show_as/); + expect(set({ bars: 'stacked' })).toThrow(/bars/); + expect(set({ color_by: 'mood' })).toThrow(/color_by/); + expect(set({ bins: 0 })).toThrow(/bins/); + expect(set({ bins: 2.5 })).toThrow(/bins/); + expect(set({ bin_width: -1 })).toThrow(/bin_width/); + expect(set({ x_min: 5, x_max: 1 })).toThrow(/x_min/); + expect(set({ bins: 10, bin_width: 0.5, count: 'readings' })).not.toThrow(); + }); +}); + +describe('histogram editor', () => { + it('shows the Chart section and writes bins, counting and layout', 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: 'histogram', 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(form.data).toMatchObject({ + count: 'time', + show_as: 'percent', + bars: 'side', + color_by: 'series', + hist_x_axis: true, + y_axis: true, + }); + const names = JSON.stringify(form.schema); + for (const n of ['bins', 'bin_width', 'x_min', 'x_max', 'hist_x_axis', 'period_calendar', 'compact']) { + expect(names).toContain(`"${n}"`); + } + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { + value: { + ...(form.data as object), + bins: 12, + count: 'readings', + bars: 'overlap', + x_min: 10, + hist_x_axis: false, + }, + }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, bins: 12, count: 'readings', bars: 'overlap', x_min: 10, x_axis: false }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields).toEqual([{ name: 'bar_color', path: ['color'] }]); + }); +}); diff --git a/test/xy.test.ts b/test/xy.test.ts new file mode 100644 index 0000000..2f35aa7 --- /dev/null +++ b/test/xy.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'lit'; +import { VIEW_H, VIEW_W } from '../src/chart/frame'; +import { + nearestPoint, + pickHandlers, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from '../src/chart/xy'; +import type { ChartContext } from '../src/styles/index'; +import type { CardConfig } from '../src/types'; + +const ctxWith = (raw: Partial = {}) => { + const host = document.createElement('div'); + return { host, raw: { type: 'x', ...raw }, requestUpdate: vi.fn() } as unknown as ChartContext; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('value axes', () => { + it('puts round steps on both axes, with the bottom ones as ticks and range', () => { + const axes = valueAxes(ctxWith(), [3, 27], [0, 9], { yFloor: 0 }); + expect(axes.range.start).toBeLessThanOrEqual(3); + expect(axes.range.end).toBeGreaterThanOrEqual(27); + expect(axes.times[0]).toBe(axes.range.start); + expect(axes.value.domain[0]).toBe(0); + expect(axes.scales.x(axes.range.start)).toBe(0); + expect(axes.scales.x(axes.range.end)).toBeCloseTo(VIEW_W); + expect(axes.scales.y(0)).toBeCloseTo(VIEW_H); + expect(xDecimals(axes)).toBe(0); + }); + + it('keeps fixed ends, the card y range over the floor, and copes with no data', () => { + const axes = valueAxes(ctxWith({ y_min: 2, y_max: 8 }), [0.1, 0.4], undefined, { + x: { min: 0, max: 0.5 }, + yFloor: 0, + }); + expect(axes.range).toEqual({ start: 0, end: 0.5 }); + expect(axes.value.domain).toEqual([2, 8]); + expect(xDecimals(axes)).toBe(1); + expect(xDecimals({ ...axes, times: [1] })).toBe(0); + }); + + it('reads and checks x_min and x_max', () => { + expect(xRange({ type: 'x', x_min: 1, x_max: 'a' })).toEqual({ min: 1, max: undefined }); + expect(() => validateXRange({ type: 'x', x_min: 'low' })).toThrow('"x_min" must be a number'); + expect(() => validateXRange({ type: 'x', x_min: 5, x_max: 5 })).toThrow('"x_min" must be below "x_max"'); + expect(() => validateXRange({ type: 'x', x_min: 0, x_max: 5 })).not.toThrow(); + }); +}); + +describe('picking a mark', () => { + it('finds the nearest point within reach in screen pixels', () => { + const pts: [number, number][] = [ + [0, 0], + [500, 100], + [1000, 200], + ]; + // A 200 × 100 px plot: the middle point is at (100, 50) px. + expect(nearestPoint(pts, 0.52, 0.5, 200, 100)).toBe(1); + expect(nearestPoint(pts, 0.98, 0.98, 200, 100)).toBe(2); + expect(nearestPoint(pts, 0.25, 0.25, 200, 100, 10)).toBeUndefined(); + }); + + it('asks which mark is under the pointer and redraws only when it changes', () => { + vi.useFakeTimers(); + const ctx = ctxWith(); + const pick = vi.fn((fx: number) => (fx < 0.5 ? 0 : 1)); + const h = pickHandlers(ctx, pick); + const el = document.createElement('div'); + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 100, height: 50 }) as DOMRect; + const ev = (x: number, type = 'mouse') => + ({ currentTarget: el, clientX: x, clientY: 10, pointerType: type }) as never; + h.move(ev(20)); + expect(pick).toHaveBeenLastCalledWith(0.2, 0.2, 100, 50); + expect(pickedIndex(ctx.host)).toBe(0); + h.move(ev(30)); + expect(ctx.requestUpdate).toHaveBeenCalledTimes(1); + h.move(ev(80)); + expect(pickedIndex(ctx.host)).toBe(1); + h.leave(ev(80)); + expect(pickedIndex(ctx.host)).toBeUndefined(); + h.move(ev(80, 'touch')); + h.leave(ev(80, 'touch')); + expect(pickedIndex(ctx.host)).toBe(1); + vi.advanceTimersByTime(3000); + expect(pickedIndex(ctx.host)).toBeUndefined(); + h.move(ev(10, 'touch')); + h.leave(ev(10, 'touch')); + stopPick(ctx.host); + expect(pickedIndex(ctx.host)).toBeUndefined(); + const zero = document.createElement('div'); + zero.getBoundingClientRect = () => ({ left: 0, top: 0, width: 0, height: 0 }) as DOMRect; + h.move({ currentTarget: zero, clientX: 5, clientY: 5, pointerType: 'mouse' } as never); + expect(pick).toHaveBeenLastCalledWith(0, 0, 0, 0); + }); + + it('draws a tooltip with a dot or a shaded band, on the side that fits', () => { + const el = document.createElement('div'); + render( + pickTooltip({ + x: 0.9, + y: 0.5, + plotWidth: 400, + title: 'Head', + rows: [{ name: 'A', value: '1', color: 'red' }], + dot: 'red', + }), + el, + ); + expect(el.querySelector('.tooltip')!.classList.contains('left')).toBe(true); + expect(el.querySelector('.hover-dot')).not.toBeNull(); + expect(el.querySelector('.tooltip-time')!.textContent).toBe('Head'); + render( + pickTooltip({ x: 0.1, y: 2, plotWidth: 400, title: 'B', rows: [{ name: 'n', value: 'v' }], band: [0, 0.2] }), + el, + ); + expect(el.querySelector('.pick-band')).not.toBeNull(); + expect(el.querySelector('.hover-dot')).toBeNull(); + expect(el.querySelector('.swatch')).toBeNull(); + render(pickTooltip({ x: 2, y: 0, plotWidth: 400, title: 'C', rows: [] }), el); + expect(el.querySelector('.tooltip')).toBeNull(); + }); +}); From fcdbbbe1f1e0c9067e1beac1ec4b77fa8c6711ff Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:14:31 +0000 Subject: [PATCH 2/7] feat: scatter chart of one sensor against another, or against time A new "Scatter" style in the Distributions group. By default the first entity goes along the bottom and every other entity is plotted up the side against it, one coloured series each: outside temperature against heating power, say. Readings are paired in time: history records changes, not samples, so at every moment any of the sensors changed, each one's value is the last it reported. Moments when one of them was unavailable, or had not reported yet, are left out, and so is the reading the chart carries on to its right edge. With "Group readings by" set, each group (an hour's average outside against that hour's heating, say) is one dot, which is the better way to compare a fast sensor with a slow one. "Along the bottom: Time" plots each entity's readings over time as dots, on the usual time axis. Jitter (px) nudges each dot a steady random amount, worked out from the reading's own time so a dot stays put as new readings arrive, so readings that land on top of each other stay visible. Dot size, an optional fixed range along the bottom (x_min, x_max), the period, source, grouping, axes, gridlines, header and compact layout are all in the editor's Chart section; each entity's dot colour is in its own options. Hover or touch picks the nearest dot and shows its time and both readings. Fewer entities than the chart needs shows a message saying what to add; a missing or unavailable entity just has no dots. The value axes, picking and tooltip box live in src/chart/xy.ts, shared with the other charts that have values along the bottom, and the pairing and drawing in src/chart/scatter-plot.ts, shared with the bubble chart. Closes #20 --- src/chart/scatter-plot.ts | 600 ++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/styles/scatter.ts | 25 ++ src/types.ts | 3 +- test/scatter-plot.test.ts | 106 +++++++ test/scatter.test.ts | 222 ++++++++++++++ 6 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 src/chart/scatter-plot.ts create mode 100644 src/styles/scatter.ts create mode 100644 test/scatter-plot.test.ts create mode 100644 test/scatter.test.ts diff --git a/src/chart/scatter-plot.ts b/src/chart/scatter-plot.ts new file mode 100644 index 0000000..d9a0ff7 --- /dev/null +++ b/src/chart/scatter-plot.ts @@ -0,0 +1,600 @@ +import { css, html, nothing, svg } from 'lit'; +import type { TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { + AXES_FIELDS, + HISTORY_FIELDS, + LAYOUT_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, + layoutSchema, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import type { Point } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, EntityFields } from '../styles/index'; +import type { CardConfig, HomeAssistant } from '../types'; +import { AXIS_LABELS, formatDateTime, validateAxisOptions } from './axes'; +import { + SERIES_COLORS, + VIEW_H, + VIEW_W, + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from './frame'; +import type { ChartSeries, HistoryFrame } from './frame'; +import { chartArea, gridStyles, plotAxes, plotWidth, stopChartWidth } from './grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from './layout'; +import { extent } from './scale'; +import { dotsPath } from './series-style'; +import { stopHover, tooltipEnabled, tooltipStyles, validateTooltipOptions } from './tooltip'; +import { + X_RANGE_FIELDS, + X_RANGE_SCHEMA, + nearestPoint, + pickHandlers, + pickStyles, + pickTooltip, + pickedIndex, + stopPick, + validateXRange, + valueAxes, + xDecimals, + xRange, +} from './xy'; + +/** + * Scatter and bubble charts: one entity's readings against another's, paired in time (or against + * time itself), drawn as dots. A bubble chart adds a third entity that sets each dot's size. + */ + +export type XYKind = 'scatter' | 'bubble'; + +/** What goes along the bottom of a scatter chart: the first entity's readings, or time. */ +export const AGAINST = ['entity', 'time'] as const; +export type Against = (typeof AGAINST)[number]; + +/** Dots drawn per series at most; more are thinned evenly. */ +export const MAX_MARKS = 800; +/** Bubbles drawn at most: each is its own element. */ +export const MAX_BUBBLES = 400; + +export const DEFAULT_DOT_SIZE = 6; +export const DEFAULT_MIN_SIZE = 6; +export const DEFAULT_MAX_SIZE = 32; +const SIZE_LIMITS = [2, 80] as const; +const DOT_LIMITS = [2, 30] as const; +const JITTER_LIMITS = [0, 20] as const; + +registerStrings('en', { + editor_style_scatter: 'Scatter', + editor_style_bubble: 'Bubble', + editor_against: 'Along the bottom', + editor_against_entity: 'The first entity', + editor_against_time: 'Time', + editor_dot_size: 'Dot size (px)', + editor_jitter: 'Jitter (px)', + editor_min_size: 'Smallest bubble (px)', + editor_max_size: 'Largest bubble (px)', + editor_dot_color: 'Dot colour', + editor_xy_x_axis: 'Labels along the bottom', + scatter_needs_two: 'Add a second entity. The first goes along the bottom, the others up the side.', + bubble_needs_three: 'Add three entities: one along the bottom, one up the side, and one for the size of each bubble.', + scatter_role_x: 'Across', + bubble_role_size: 'Size', +}); + +/* -------------------------------------------------------------- maths -------------------------------------------------------------- */ + +/** One moment where every series had a value: the time, and each series' value then. */ +export interface Sample { + t: number; + v: number[]; +} + +/** + * Pair several series in time. A sensor holds its reading until the next one, so at every time + * any series has a reading, each series' value is the last one at or before it. A time where a + * series has no value yet, or is unavailable, is left out. `edge` is a time to skip: the reading + * the chart carried on to its right edge, which is not a new one. + */ +export function pairReadings(series: readonly Point[][], edge?: number): Sample[] { + const times = new Set(); + for (const points of series) for (const p of points) if (p.t !== edge) times.add(p.t); + const sorted = [...times].sort((a, b) => a - b); + const at = series.map(() => -1); + const out: Sample[] = []; + for (const t of sorted) { + const v: number[] = []; + let ok = true; + series.forEach((points, i) => { + while (at[i] + 1 < points.length && points[at[i] + 1].t <= t) at[i]++; + const p = points[at[i]]; + if (!p || p.v === null) ok = false; + else v.push(p.v); + }); + if (ok) out.push({ t, v }); + } + return out; +} + +/** Keep at most `max` items, evenly spaced, always including the last. */ +export function thin(items: T[], max: number): T[] { + if (items.length <= max) return items; + const step = items.length / max; + const out: T[] = []; + for (let i = 0; i < max - 1; i++) out.push(items[Math.floor(i * step)]); + out.push(items[items.length - 1]); + return out; +} + +/** + * A steady random number from -1 to 1 for a reading at time `t` in series `seed`. Jitter comes + * from the reading itself, not its position in the list, so a dot stays put as new readings + * arrive and the oldest drop off. + */ +export function jitter(t: number, seed: number): number { + let h = (Math.floor(t / 1000) ^ Math.imul(seed + 1, 0x9e3779b9)) | 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h ^= h >>> 16; + return ((h >>> 0) / 0xffffffff) * 2 - 1; +} + +/** + * A bubble's diameter in px. Its area, not its width, grows with the value, from `minPx` at the + * lowest reading to `maxPx` at the highest, so a value twice as far up the range covers twice as + * much of the chart. All readings equal: halfway. + */ +export function bubbleDiameter(v: number, [lo, hi]: [number, number], minPx: number, maxPx: number): number { + const f = hi > lo ? Math.min(1, Math.max(0, (v - lo) / (hi - lo))) : 0.5; + const a = minPx * minPx + (maxPx * maxPx - minPx * minPx) * f; + return Math.round(Math.sqrt(a) * 10) / 10; +} + +/* ------------------------------------------------------------- options ------------------------------------------------------------- */ + +const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function checkRange(raw: CardConfig, key: string, [lo, hi]: readonly [number, number]) { + const v = raw[key]; + if (v !== undefined && (!num(v) || v < lo || v > hi)) + throw new Error(`"${key}" must be a number from ${lo} to ${hi}`); +} + +export interface XYOptions { + against: Against; + dotSize: number; + jitter: number; + minSize: number; + maxSize: number; +} + +export function xyOptions(raw: CardConfig, kind: XYKind): XYOptions { + return { + against: kind === 'bubble' ? 'entity' : ((raw.against as Against | undefined) ?? 'entity'), + dotSize: num(raw.dot_size) ? raw.dot_size : DEFAULT_DOT_SIZE, + jitter: num(raw.jitter) ? raw.jitter : 0, + minSize: num(raw.min_size) ? raw.min_size : DEFAULT_MIN_SIZE, + maxSize: num(raw.max_size) ? raw.max_size : DEFAULT_MAX_SIZE, + }; +} + +/** Readable errors for every option a scatter or bubble chart reads. */ +export function validateXY(raw: CardConfig, kind: XYKind): void { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + validateXRange(raw); + checkRange(raw, 'jitter', JITTER_LIMITS); + if (kind === 'scatter') { + if (raw.against !== undefined && !AGAINST.includes(raw.against as Against)) { + throw new Error(`"against" must be one of: ${AGAINST.join(', ')}`); + } + checkRange(raw, 'dot_size', DOT_LIMITS); + } else { + checkRange(raw, 'min_size', SIZE_LIMITS); + checkRange(raw, 'max_size', SIZE_LIMITS); + const o = xyOptions(raw, kind); + if (o.minSize > o.maxSize) throw new Error('"min_size" must not be above "max_size"'); + } +} + +/* ------------------------------------------------------------- drawing ------------------------------------------------------------- */ + +/** One dot: which series it belongs to, when, where (view box), and its readings. */ +export interface Mark { + series: ChartSeries; + t: number; + x: number; + y: number; + /** The readings behind it: [x, y] or [x, y, size]; [y] against time. */ + v: number[]; + color: string; + /** Bubbles: diameter in px. */ + d?: number; +} + +/** A y series' colour: its own, else the palette from the start, as the x entity takes none. */ +function seriesColor(s: ChartSeries, offset: number): string { + const own = (s.entity.config as { color?: string }).color; + return baseColor(own ?? SERIES_COLORS[(s.index - offset) % SERIES_COLORS.length]); +} + +/** Entities a chart needs before it can draw anything. */ +function needed(kind: XYKind, against: Against): number { + return kind === 'bubble' ? 3 : against === 'entity' ? 2 : 1; +} + +/** + * The names row: the entity along the bottom and (bubbles) the one setting size get a small tag + * saying so; the others a swatch of their dot colour. Tapping one opens it. + */ +function roleHeader(series: ChartSeries[], colors: (string | undefined)[], ctx: ChartContext, kind: XYKind) { + const { show } = ctx.config; + if (!show.name && !show.value) return nothing; + return html`
+ ${series.map((s, i) => { + const color = colors[i]; + const role = + color !== undefined + ? html`` + : html`${localize(ctx.hass, kind === 'bubble' && i === 2 ? 'bubble_role_size' : 'scatter_role_x')}`; + return html`
+ ${role} ${show.name ? html`${s.entity.name}` : nothing} + ${show.value ? html`${s.entity.display}` : nothing} +
`; + })} +
`; +} + +/** A reading formatted as its entity is, with its unit. */ +const valueText = (v: number, s: ChartSeries, hass: HomeAssistant) => + withUnit(formatNumber(v, s.entity.decimals, hass), s.entity.unit); + +/** The plot's rough height in px, for turning jitter in px into view-box units. */ +const plotHeight = (compact: boolean) => (compact ? 96 : 162); + +/** Build the marks (before scaling): which readings to plot, paired as the chart needs. */ +function collect(frame: HistoryFrame, kind: XYKind, against: Against, edge: number | undefined) { + const { series } = frame; + if (against === 'time') { + return series.map((s) => ({ + s, + samples: thin( + s.points + .filter((p) => p.v !== null && p.t !== edge && !p.carried) + .map((p) => ({ t: p.t, v: [p.t, p.v as number] })), + MAX_MARKS, + ), + })); + } + if (kind === 'bubble') { + const [x, y, size] = series; + return [{ s: y, samples: thin(pairReadings([x.points, y.points, size.points], edge), MAX_BUBBLES) }]; + } + const [x, ...ys] = series; + return ys.map((s) => ({ s, samples: thin(pairReadings([x.points, s.points], edge), MAX_MARKS) })); +} + +/** Draw a scatter or bubble chart for the card. */ +export function renderXY(ctx: ChartContext, kind: XYKind, now = Date.now()): TemplateResult { + const opts = xyOptions(ctx.raw, kind); + const { against } = opts; + const layout = chartLayout(ctx.raw); + const frame = historyFrame(ctx, now); + const nav = periodNav(frame, ctx); + const byTime = against === 'time'; + const offset = byTime ? 0 : 1; + const colors = frame.series.map((s, i) => + byTime + ? s.color + : kind === 'bubble' + ? i === 1 + ? seriesColor(s, 1) + : undefined + : i === 0 + ? undefined + : seriesColor(s, offset), + ); + const names = roleHeader(frame.series, colors, ctx, kind); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const above = layout.header === 'over' ? { ...layout, header: 'above' as const } : layout; + if (frame.series.length < needed(kind, against)) { + return chartFrame( + above, + header, + html`
+ ${localize(ctx.hass, kind === 'bubble' ? 'bubble_needs_three' : 'scatter_needs_two')} +
`, + ); + } + const message = frameMessage(frame, ctx); + if (message) return chartFrame(above, header, message); + // History without groups carries each series' last reading on to the right edge: not a reading. + const edge = frame.source === 'history' && !frame.group ? Math.min(frame.range.end, now) : undefined; + const groups = collect(frame, kind, against, edge); + const all = groups.flatMap((g) => g.samples); + if (all.length === 0) + return chartFrame(above, header, html`
${localize(ctx.hass, 'chart_no_data')}
`); + const ys = all.map((m) => m.v[1]); + const yData: [number, number] = [Math.min(...ys), Math.max(...ys)]; + let axes; + let xLabel: ((v: number) => string) | undefined; + if (byTime) { + axes = plotAxes(ctx, frame.range, extent(frame.series.map((s) => s.points))); + } else { + const xs = all.map((m) => m.v[0]); + axes = valueAxes(ctx, [Math.min(...xs), Math.max(...xs)], yData, { x: xRange(ctx.raw) }); + const decimals = xDecimals(axes); + xLabel = (v) => formatNumber(v, decimals, ctx.hass); + } + const { scales } = axes; + const width = plotWidth(ctx) || 1; + const jx = (opts.jitter * VIEW_W) / width; + const jy = (opts.jitter * VIEW_H) / plotHeight(layout.compact); + const sizes = kind === 'bubble' ? all.map((m) => m.v[2]) : []; + const sizeRange: [number, number] = sizes.length ? [Math.min(...sizes), Math.max(...sizes)] : [0, 0]; + const marks: Mark[] = groups.flatMap(({ s, samples }) => { + const color = byTime ? s.color : seriesColor(s, offset); + return samples.map((m) => ({ + series: s, + t: m.t, + x: scales.x(m.v[0]) + (jx ? jitter(m.t, s.index * 2) * jx : 0), + y: scales.y(m.v[1]) + (jy ? jitter(m.t, s.index * 2 + 1) * jy : 0), + v: byTime ? [m.v[1]] : m.v, + color, + d: kind === 'bubble' ? bubbleDiameter(m.v[2], sizeRange, opts.minSize, opts.maxSize) : undefined, + })); + }); + const content = + kind === 'bubble' + ? svg`${[...marks] + .sort((a, b) => (b.d ?? 0) - (a.d ?? 0)) + .map( + (m) => + svg``, + )}` + : svg`${groups.map(({ s }) => { + const own = marks.filter((m) => m.series === s); + return own.length + ? svg` [m.x, m.y]))} + stroke=${own[0].color} style="stroke-width:${opts.dotSize}px">` + : nothing; + })}`; + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + const mark = picked !== undefined ? marks[picked] : undefined; + const label = localize(ctx.hass, 'chart_aria', { + style: localize(ctx.hass, `editor_style_${kind}`), + names: frame.series.map((s) => s.entity.name).join(', '), + }); + const reach = Math.max(16, kind === 'bubble' ? opts.maxSize / 2 : opts.dotSize); + return chartFrame( + layout, + header, + html`${chartArea({ + axes, + ctx, + content, + label, + xLabel, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing} + ${mark ? markTooltip(mark, frame, kind, against, ctx, width) : nothing}`, + hover: tooltip + ? pickHandlers(ctx, (fx, fy, w, h) => + nearestPoint( + marks.map((m) => [m.x, m.y] as const), + fx, + fy, + w, + h, + reach, + ), + ) + : undefined, + })} + ${kind === 'bubble' ? sizeLegend(frame.series[2], sizeRange, opts, ctx.hass) : nothing}`, + ); +} + +function markTooltip(m: Mark, frame: HistoryFrame, kind: XYKind, against: Against, ctx: ChartContext, width: number) { + const shown = + against === 'time' ? [m.series] : kind === 'bubble' ? frame.series.slice(0, 3) : [frame.series[0], m.series]; + return pickTooltip({ + x: m.x / VIEW_W, + y: m.y / VIEW_H, + plotWidth: width, + title: formatDateTime(m.t, ctx.hass), + dot: m.color, + rows: shown.map((s, i) => ({ + color: s === m.series ? m.color : undefined, + name: s.entity.name, + value: valueText(m.v[i], s, ctx.hass), + })), + }); +} + +/** Under a bubble chart: the smallest and largest bubble, with the readings they stand for. */ +function sizeLegend(s: ChartSeries, [lo, hi]: [number, number], opts: XYOptions, hass: HomeAssistant) { + const item = (v: number, d: number) => + html`${valueText(v, s, hass)}`; + return html``; +} + +export const xyStyles = css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .plot .dots, + .plot .bubble { + fill: none; + stroke-linecap: round; + } + .plot .dots { + stroke-opacity: 0.8; + } + .plot .bubble { + stroke-opacity: 0.55; + } + .role { + flex: none; + align-self: center; + font-size: 0.6875em; + line-height: 1; + padding: 2px 5px; + border-radius: 8px; + color: var(--secondary-text-color); + border: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .size-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + padding-top: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .size-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .size-dot { + flex: none; + border-radius: 50%; + border: 1px solid var(--secondary-text-color); + background: color-mix(in srgb, var(--secondary-text-color) 25%, transparent); + box-sizing: border-box; + } + .frame.compact .size-legend { + padding-top: 2px; + } +`; + +export function disconnectXY(host: HTMLElement): void { + disconnectHistory(host); + stopChartWidth(host); + stopHover(host); + stopPick(host); +} + +/* ------------------------------------------------------------- editor ------------------------------------------------------------- */ + +/** The shared axes fields, with the bottom labels named for values, not times. */ +const XY_AXES_FIELDS: FieldBinding[] = AXES_FIELDS.map((f) => (f.name === 'x_axis' ? { ...f, name: 'xy_x_axis' } : f)); + +function xyAxesSchema(hass?: HomeAssistant) { + return [ + { + 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: choice(hass, 'axis_labels', AXIS_LABELS) }, + { + 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: {} } }, + ]; +} + +const px = (min: number, max: number, step = 1) => ({ + number: { min, max, step, mode: 'box', unit_of_measurement: 'px' }, +}); + +const FIELDS: Record = { + scatter: [ + ...HISTORY_FIELDS, + { name: 'against', path: ['against'], default: 'entity' }, + { name: 'dot_size', path: ['dot_size'], default: DEFAULT_DOT_SIZE }, + { name: 'jitter', path: ['jitter'], default: 0 }, + ...X_RANGE_FIELDS, + ...XY_AXES_FIELDS, + ...LAYOUT_FIELDS, + ], + bubble: [ + ...HISTORY_FIELDS, + { name: 'min_size', path: ['min_size'], default: DEFAULT_MIN_SIZE }, + { name: 'max_size', path: ['max_size'], default: DEFAULT_MAX_SIZE }, + { name: 'jitter', path: ['jitter'], default: 0 }, + ...X_RANGE_FIELDS, + ...XY_AXES_FIELDS, + ...LAYOUT_FIELDS, + ], +}; + +/** The Chart section of a scatter or bubble card's editor. */ +export function xyEditor(kind: XYKind) { + return (ctx: ChartEditorContext): TemplateResult => { + const byTime = kind === 'scatter' && ctx.config.against === 'time'; + const schema = cachedSchema(`${kind}|${historyVariant(ctx.config)}|${byTime}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ctx.config), + ...(kind === 'scatter' + ? [ + { name: 'against', selector: choice(ctx.hass, 'against', AGAINST) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'dot_size', selector: px(DOT_LIMITS[0], DOT_LIMITS[1]) }, + { name: 'jitter', selector: px(JITTER_LIMITS[0], JITTER_LIMITS[1]) }, + ], + }, + ] + : [ + { + type: 'grid', + name: '', + schema: [ + { name: 'min_size', selector: px(SIZE_LIMITS[0], SIZE_LIMITS[1]) }, + { name: 'max_size', selector: px(SIZE_LIMITS[0], SIZE_LIMITS[1]) }, + ], + }, + { name: 'jitter', selector: px(JITTER_LIMITS[0], JITTER_LIMITS[1]) }, + ]), + ...(byTime ? [] : X_RANGE_SCHEMA), + ...xyAxesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, FIELDS[kind]); + }; +} + +const COLOR_FIELDS: FieldBinding[] = [{ name: 'dot_color', path: ['color'] }]; +const COLOR_SCHEMA = [{ name: 'dot_color', selector: { text: {} } }]; + +/** Each entity's own dot colour (the bottom and size entities ignore it). */ +export function xyEntityFields(): EntityFields { + return { schema: COLOR_SCHEMA, fields: COLOR_FIELDS }; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index bb15abc..d5130c9 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -13,6 +13,7 @@ import { pictograph } from './pictograph'; import { pie } from './pie'; import { arc, ring } from './radial'; import { rings } from './rings'; +import { scatter } from './scatter'; import { segmented } from './segmented'; import { tank } from './tank'; import { thin } from './thin'; @@ -122,4 +123,5 @@ export const STYLES: Record = { pictograph, waffle, histogram, + scatter, }; diff --git a/src/styles/scatter.ts b/src/styles/scatter.ts new file mode 100644 index 0000000..8948b35 --- /dev/null +++ b/src/styles/scatter.ts @@ -0,0 +1,25 @@ +import { svg } from 'lit'; +import { disconnectXY, renderXY, validateXY, xyEditor, xyEntityFields, xyStyles } from '../chart/scatter-plot'; +import type { ChartStyleModule } from './index'; + +/** One entity's readings against another's (or against time), as dots. */ +export const scatter: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: xyStyles, + thumbnail: () => svg` + + + + + + + + `, + validate: (raw) => validateXY(raw, 'scatter'), + editor: xyEditor('scatter'), + entityFields: () => xyEntityFields(), + disconnect: disconnectXY, + render: (ctx) => renderXY(ctx, 'scatter'), +}; diff --git a/src/types.ts b/src/types.ts index 505f54d..5062b7b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ import type { Rule } from './fill'; export type StyleName = 'bar' | 'thin' | 'segmented' | 'tank' | 'ring' | 'needle' | 'arc' | 'line' | 'column' | 'area' | 'combined' - | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram'; + | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -19,6 +19,7 @@ export const STYLE_NAMES: StyleName[] = [ 'pictograph', 'waffle', 'histogram', + 'scatter', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/scatter-plot.test.ts b/test/scatter-plot.test.ts new file mode 100644 index 0000000..dabab43 --- /dev/null +++ b/test/scatter-plot.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { bubbleDiameter, jitter, pairReadings, thin, validateXY, xyOptions } from '../src/chart/scatter-plot'; +import type { CardConfig } from '../src/types'; + +describe('pairing readings in time', () => { + it('samples every series at each time any of them changed, holding the last value', () => { + const x = [ + { t: 0, v: 1 }, + { t: 10, v: 2 }, + ]; + const y = [ + { t: 5, v: 50 }, + { t: 10, v: 60 }, + { t: 20, v: 70 }, + ]; + expect(pairReadings([x, y])).toEqual([ + { t: 5, v: [1, 50] }, + { t: 10, v: [2, 60] }, + { t: 20, v: [2, 70] }, + ]); + }); + + it('leaves out times when a series is unavailable, has no value yet, or is the carried edge', () => { + const x = [ + { t: 0, v: 1 }, + { t: 10, v: null }, + { t: 20, v: 3 }, + { t: 30, v: 3 }, + ]; + const y = [ + { t: 5, v: 7 }, + { t: 30, v: 8 }, + ]; + expect(pairReadings([x, y], 30)).toEqual([ + { t: 5, v: [1, 7] }, + { t: 20, v: [3, 7] }, + ]); + expect(pairReadings([x, []])).toEqual([]); + }); + + it('pairs three series for bubbles', () => { + const s = (v: number) => [{ t: 0, v }]; + expect(pairReadings([s(1), s(2), s(3)])).toEqual([{ t: 0, v: [1, 2, 3] }]); + }); +}); + +describe('scatter maths', () => { + it('thins evenly, keeping the last', () => { + const items = Array.from({ length: 10 }, (_, i) => i); + expect(thin(items, 20)).toBe(items); + expect(thin(items, 4)).toEqual([0, 2, 5, 9]); + }); + + it('jitters steadily per reading, between -1 and 1', () => { + const a = jitter(1_700_000_000_000, 0); + expect(jitter(1_700_000_000_000, 0)).toBe(a); + expect(jitter(1_700_000_000_000, 1)).not.toBe(a); + const all = Array.from({ length: 200 }, (_, i) => jitter(i * 60_000, 3)); + expect(Math.min(...all)).toBeGreaterThanOrEqual(-1); + expect(Math.max(...all)).toBeLessThanOrEqual(1); + expect(Math.min(...all)).toBeLessThan(-0.5); + expect(Math.max(...all)).toBeGreaterThan(0.5); + }); + + it('sizes bubbles by area between the smallest and largest', () => { + expect(bubbleDiameter(0, [0, 10], 4, 20)).toBe(4); + expect(bubbleDiameter(10, [0, 10], 4, 20)).toBe(20); + // Halfway up the range: half the area between the two, not half the width. + expect(bubbleDiameter(5, [0, 10], 0, 20)).toBeCloseTo(14.1, 1); + expect(bubbleDiameter(99, [0, 10], 4, 20)).toBe(20); + expect(bubbleDiameter(3, [3, 3], 0, 20)).toBeCloseTo(14.1, 1); + }); +}); + +describe('scatter and bubble options', () => { + const check = (kind: 'scatter' | 'bubble', extra: Record) => () => + validateXY({ type: 'x', ...extra } as CardConfig, kind); + + it('has defaults, and a bubble chart is always against an entity', () => { + expect(xyOptions({ type: 'x' }, 'scatter')).toEqual({ + against: 'entity', + dotSize: 6, + jitter: 0, + minSize: 6, + maxSize: 32, + }); + expect(xyOptions({ type: 'x', against: 'time', dot_size: 9, jitter: 3 }, 'scatter')).toMatchObject({ + against: 'time', + dotSize: 9, + jitter: 3, + }); + expect(xyOptions({ type: 'x', against: 'time' }, 'bubble').against).toBe('entity'); + }); + + it('gives readable errors', () => { + expect(check('scatter', { against: 'moon' })).toThrow('"against" must be one of: entity, time'); + expect(check('scatter', { dot_size: 0 })).toThrow('"dot_size" must be a number from 2 to 30'); + expect(check('scatter', { jitter: 'lots' })).toThrow('"jitter" must be a number from 0 to 20'); + expect(check('scatter', { x_min: 3, x_max: 1 })).toThrow(/x_min/); + expect(check('scatter', { source: 'cloud' })).toThrow(/source/); + expect(check('bubble', { max_size: 200 })).toThrow('"max_size" must be a number from 2 to 80'); + expect(check('bubble', { min_size: 30, max_size: 10 })).toThrow('"min_size" must not be above "max_size"'); + expect(check('bubble', { min_size: 4, max_size: 40, jitter: 2 })).not.toThrow(); + expect(check('scatter', { against: 'time', dot_size: 8 })).not.toThrow(); + }); +}); diff --git a/test/scatter.test.ts b/test/scatter.test.ts new file mode 100644 index 0000000..b42bc95 --- /dev/null +++ b/test/scatter.test.ts @@ -0,0 +1,222 @@ +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 }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +const history = { + 'sensor.out': [ + { s: '2', lu: sec(20 * H) }, + { s: '5', lu: sec(14 * H) }, + { s: '9', lu: sec(8 * H) }, + { s: 'unavailable', lu: sec(5 * H) }, + { s: '12', lu: sec(3 * H) }, + ], + 'sensor.heat': [ + { s: '3000', lu: sec(19 * H) }, + { s: '2200', lu: sec(13 * H) }, + { s: '900', lu: sec(7 * H) }, + { s: '400', lu: sec(2 * H) }, + ], + 'sensor.flow': [ + { s: '40', lu: sec(18 * H) }, + { s: '45', lu: sec(6 * H) }, + ], +}; + +function hassWith(data: Record = history) { + const hass = makeHass({ + 'sensor.out': ['12', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.heat': ['400', { friendly_name: 'Heating', unit_of_measurement: 'W' }], + 'sensor.flow': ['45', { friendly_name: 'Flow', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => 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: 'scatter', + entities: ['sensor.out', 'sensor.heat'], + ...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 dotCount = (el: Element) => (el.getAttribute('d')!.match(/M/g) ?? []).length; + +function point(el: Element, type: string, fx: number, fy: number, pointerType = 'mouse') { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 200, right: 1000, bottom: 200 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: fx * 1000, clientY: fy * 200 }); + Object.defineProperty(ev, 'pointerType', { value: pointerType }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('scatter chart', () => { + it('plots the second entity against the first, one dot per time either changed', async () => { + const card = await mount({}); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(1); + // Paired at each change of either, leaving out the unavailable stretch and the carried edge. + expect(dotCount(dots[0])).toBe(7); + expect(dots[0].getAttribute('stroke')).toContain('--primary-color'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Scatter chart of Outside, Heating'); + const header = qa(card, '.series-item').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(header).toEqual(['Across Outside 12.0 °C', 'Heating 400.0 W']); + expect(q(card, '.series-item .role')).not.toBeNull(); + // Bottom labels are values: numbers, not times. + const bottom = qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim()); + expect(bottom.length).toBeGreaterThanOrEqual(2); + expect(bottom.every((v) => /^-?\d+$/.test(v))).toBe(true); + }); + + it('draws each further entity as its own coloured series, with jitter that stays put', async () => { + const card = await mount({ + entities: ['sensor.out', 'sensor.heat', { entity: 'sensor.flow', color: 'red' }], + jitter: 6, + dot_size: 9, + } as Partial); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(2); + expect(dots[1].getAttribute('stroke')).toContain('--error-color'); + expect((dots[0] as SVGElement).style.strokeWidth).toBe('9px'); + const before = dots.map((d) => d.getAttribute('d')); + card.hass = { ...card.hass! }; + await card.updateComplete; + expect(qa(card, 'path.dots').map((d) => d.getAttribute('d'))).toEqual(before); + const plain = await mount({ entities: ['sensor.out', 'sensor.heat', 'sensor.flow'] } as Partial); + expect(qa(plain, 'path.dots')[0].getAttribute('d')).not.toBe(before[0]); + }); + + it('plots values against time as dots, with time labels', async () => { + const card = await mount({ against: 'time', entities: ['sensor.out'] } as Partial); + const dots = qa(card, 'path.dots'); + expect(dots).toHaveLength(1); + // The readings in range, not the gap and not the value carried to the right edge. + expect(dotCount(dots[0])).toBe(4); + expect(q(card, '.role')).toBeNull(); + expect(q(card, '.swatch')).not.toBeNull(); + }); + + it('asks for a second entity, and copes with one that is missing or has no history', async () => { + const one = await mount({ entities: ['sensor.out'] }); + expect(q(one, '.chart-message')!.textContent).toContain('Add a second entity'); + const missing = await mount({ entities: ['sensor.out', 'sensor.gone'] }); + expect(q(missing, '.chart-message')!.textContent).toBe('No readings in this period.'); + const empty = await mount({}, hassWith({})); + expect(q(empty, '.chart-message')!.textContent).toBe('No readings in this period.'); + }); + + it('shows both readings and the time of the dot under the pointer', async () => { + const card = await mount({ x_axis: false, y_axis: false } as Partial); + const area = q(card, '.plot-area')!; + expect(area.classList.contains('hoverable')).toBe(true); + // Highest outside temperature (right edge) has the lowest heating (bottom). + const d = q(card, 'path.dots')!.getAttribute('d')!; + const pts = [...d.matchAll(/M([\d.-]+),([\d.-]+)/g)].map((m) => [Number(m[1]), Number(m[2])]); + const [x, y] = pts.reduce((a, b) => (b[0] > a[0] || (b[0] === a[0] && b[1] > a[1]) ? b : a)); + point(area, 'pointermove', x / 1000, y / 200); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toEqual(['Outside 12.0 °C', 'Heating 400.0 W']); + expect(q(card, '.hover-dot')).not.toBeNull(); + point(area, 'pointerleave', 0, 0); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area')!.classList.contains('hoverable')).toBe(false); + }); + + it('keeps a fixed bottom range and the header over the chart', async () => { + const card = await mount({ x_min: 0, x_max: 20, header: 'over', compact: true } as Partial); + const bottom = qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim()); + expect(bottom[0]).toBe('0'); + expect(bottom.at(-1)).toBe('20'); + expect(q(card, '.header-over')).not.toBeNull(); + expect(q(card, '.frame.compact')).not.toBeNull(); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'scatter', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ against: 'moon' })).toThrow(/against/); + expect(set({ jitter: 99 })).toThrow(/jitter/); + }); +}); + +describe('scatter chart editor', () => { + it('writes what goes along the bottom, dot size and jitter, and a colour per entity', 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: 'scatter', + entities: ['sensor.out', 'sensor.heat'], + } 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({ against: 'entity', dot_size: 6, jitter: 0, xy_x_axis: true, tooltip: true }); + const names = JSON.stringify(form.schema); + expect(names).toContain('"x_min"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), jitter: 4, dot_size: 8, x_min: -5, xy_x_axis: false } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, jitter: 4, dot_size: 8, x_min: -5, x_axis: false }); + form.dispatchEvent( + new CustomEvent('value-changed', { detail: { value: { ...(form.data as object), against: 'time' } } }), + ); + expect(events.at(-1)).toEqual({ ...raw, against: 'time' }); + ed.setConfig({ ...raw, against: 'time' } as CardConfig); + await ed.updateComplete; + const timeForm = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(JSON.stringify(timeForm.schema)).not.toContain('"x_min"'); + 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).toEqual(['dot_color']); + }); +}); From c417fae47dab63890c6d83a4d2475ecde1609d42 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:16:31 +0000 Subject: [PATCH 3/7] feat: bubble chart, a scatter sized by a third entity A new "Bubble" style in the Distributions group: the first entity goes along the bottom, the second up the side, and the third sets the size of each bubble (temperature against humidity, sized by CO2, say). The three are paired in time as the scatter chart does: at every moment any of them changed, each one's value is the last it reported, leaving out moments when one was unavailable or had not reported yet. Grouping by hour or day gives one bubble per group, which keeps a busy sensor from burying the chart. A bubble's area, not its width, grows with the size reading, from the smallest bubble at the lowest reading in view to the largest at the highest, so twice as far up the range covers twice as much of the chart. Larger bubbles are drawn first so small ones stay on top, half see through. A legend under the chart shows the smallest and largest bubble with the readings they stand for; hover or touch shows all three readings and the time. Smallest and largest bubble (px), jitter, an optional fixed range along the bottom, and the usual period, source, grouping, axes and layout options are in the editor's Chart section; the bubble colour is the second entity's own colour option. Fewer than three entities shows a message saying what to add; a missing or unavailable size entity leaves no bubbles. Shares src/chart/xy.ts and src/chart/scatter-plot.ts, byte for byte, with the scatter chart. Closes #21 --- src/styles/bubble.ts | 21 +++++ src/styles/index.ts | 2 + src/types.ts | 3 +- test/bubble.test.ts | 179 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/styles/bubble.ts create mode 100644 test/bubble.test.ts diff --git a/src/styles/bubble.ts b/src/styles/bubble.ts new file mode 100644 index 0000000..efc3f74 --- /dev/null +++ b/src/styles/bubble.ts @@ -0,0 +1,21 @@ +import { svg } from 'lit'; +import { disconnectXY, renderXY, validateXY, xyEditor, xyEntityFields, xyStyles } from '../chart/scatter-plot'; +import type { ChartStyleModule } from './index'; + +/** A scatter of one entity against another, each dot sized by a third. */ +export const bubble: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 270, + styles: xyStyles, + thumbnail: () => svg` + + + + `, + validate: (raw) => validateXY(raw, 'bubble'), + editor: xyEditor('bubble'), + entityFields: () => xyEntityFields(), + disconnect: disconnectXY, + render: (ctx) => renderXY(ctx, 'bubble'), +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index d5130c9..2e6c2f6 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4,6 +4,7 @@ import type { ResolvedEntity } from '../resolve'; import type { CardConfig, HomeAssistant, NormalizedConfig, ShowOptions, StyleName } from '../types'; import { area } from './area'; import { bar } from './bar'; +import { bubble } from './bubble'; import { columnChart } from './column'; import { combined } from './combined'; import { histogram } from './histogram'; @@ -124,4 +125,5 @@ export const STYLES: Record = { waffle, histogram, scatter, + bubble, }; diff --git a/src/types.ts b/src/types.ts index 5062b7b..110d687 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ 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'; + | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -20,6 +20,7 @@ export const STYLE_NAMES: StyleName[] = [ 'waffle', 'histogram', 'scatter', + 'bubble', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/bubble.test.ts b/test/bubble.test.ts new file mode 100644 index 0000000..69c1611 --- /dev/null +++ b/test/bubble.test.ts @@ -0,0 +1,179 @@ +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 }; + +const now = Date.now(); +const sec = (msAgo: number) => (now - msAgo) / 1000; +const H = 3_600_000; + +const history = { + 'sensor.out': [ + { s: '2', lu: sec(20 * H) }, + { s: '5', lu: sec(14 * H) }, + { s: '9', lu: sec(8 * H) }, + { s: 'unavailable', lu: sec(5 * H) }, + { s: '12', lu: sec(3 * H) }, + ], + 'sensor.heat': [ + { s: '3000', lu: sec(19 * H) }, + { s: '2200', lu: sec(13 * H) }, + { s: '900', lu: sec(7 * H) }, + { s: '400', lu: sec(2 * H) }, + ], + 'sensor.flow': [ + { s: '40', lu: sec(18 * H) }, + { s: '45', lu: sec(6 * H) }, + ], +}; + +function hassWith(data: Record = history) { + const hass = makeHass({ + 'sensor.out': ['12', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.heat': ['400', { friendly_name: 'Heating', unit_of_measurement: 'W' }], + 'sensor.flow': ['45', { friendly_name: 'Flow', unit_of_measurement: '°C' }], + }); + hass.callWS = vi.fn(async () => 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: 'bubble', + entities: ['sensor.out', 'sensor.heat', 'sensor.flow'], + ...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, fx: number, fy: number, pointerType = 'mouse') { + el.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 200, right: 1000, bottom: 200 }) as DOMRect; + const ev = new MouseEvent(type, { bubbles: true, clientX: fx * 1000, clientY: fy * 200 }); + Object.defineProperty(ev, 'pointerType', { value: pointerType }); + el.dispatchEvent(ev); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('bubble chart', () => { + it('sizes each paired dot by the third entity, largest drawn first, with a size legend', async () => { + const card = await mount({}); + const bubbles = qa(card, 'path.bubble'); + // Every time any of the three changed once all three had a value, less the unavailable stretch. + expect(bubbles).toHaveLength(8); + const widths = bubbles.map((b) => parseFloat((b as SVGElement).style.strokeWidth)); + expect(widths).toEqual([...widths].sort((a, b) => b - a)); + expect(Math.max(...widths)).toBe(32); + expect(Math.min(...widths)).toBe(6); + expect(bubbles[0].getAttribute('stroke')).toContain('--primary-color'); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Bubble chart of Outside, Heating, Flow'); + const roles = qa(card, '.series-item .role').map((e) => e.textContent!.trim()); + expect(roles).toEqual(['Across', 'Size']); + const legend = q(card, '.size-legend')!.textContent!.replace(/\s+/g, ' ').trim(); + expect(legend).toBe('Flow 40.0 °C 45.0 °C'); + expect(qa(card, '.size-dot')).toHaveLength(2); + }); + + it('uses the y entity colour and the card sizes, and shows all three readings on hover', async () => { + const card = await mount({ + min_size: 4, + max_size: 20, + x_axis: false, + y_axis: false, + entities: ['sensor.out', { entity: 'sensor.heat', color: 'green' }, 'sensor.flow'], + } as Partial); + const bubbles = qa(card, 'path.bubble'); + expect(bubbles[0].getAttribute('stroke')).toContain('--success-color'); + const widths = bubbles.map((b) => parseFloat((b as SVGElement).style.strokeWidth)); + expect(Math.max(...widths)).toBe(20); + expect(Math.min(...widths)).toBe(4); + const [x, y] = bubbles[0] + .getAttribute('d')! + .match(/M([\d.-]+),([\d.-]+)/)! + .slice(1) + .map(Number); + point(q(card, '.plot-area')!, 'pointermove', x / 1000, y / 200); + await card.updateComplete; + const rows = qa(card, '.tooltip-row').map((e) => e.textContent!.replace(/\s+/g, ' ').trim()); + expect(rows).toHaveLength(3); + expect(rows[2]).toBe('Flow 45.0 °C'); + }); + + it('asks for three entities, and shows one size when every size reading is the same', async () => { + const two = await mount({ entities: ['sensor.out', 'sensor.heat'] }); + expect(q(two, '.chart-message')!.textContent).toContain('Add three entities'); + const flat = await mount({}, hassWith({ ...history, 'sensor.flow': [{ s: '40', lu: sec(30 * H) }] })); + expect(qa(flat, '.size-dot')).toHaveLength(1); + }); + + it('checks its options', () => { + const card = document.createElement('chartdeck-card') as Card; + const set = (extra: Record) => () => + card.setConfig({ type: 'x', style: 'bubble', entities: ['sensor.out'], ...extra } as CardConfig); + expect(set({ min_size: 40, max_size: 10 })).toThrow(/min_size/); + expect(set({ max_size: 0 })).toThrow(/max_size/); + }); +}); + +describe('bubble chart editor', () => { + it('writes the bubble sizes and jitter', 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: 'bubble', + entities: ['sensor.out', 'sensor.heat', 'sensor.flow'], + } 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({ min_size: 6, max_size: 32, jitter: 0 }); + expect(form.data).not.toHaveProperty('against'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), min_size: 3, max_size: 40, jitter: 2 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, min_size: 3, max_size: 40, jitter: 2 }); + 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).toEqual(['dot_color']); + }); +}); From df02e70c34bd656fa52fc251d4c07a42bc36739b Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:12:16 +0000 Subject: [PATCH 4/7] feat: heatmap of a sensor by hour and weekday, or a year as a calendar A new chart style, "Heatmap", in the Distributions group of the style picker. It shows when something happens: each cell is coloured by a value, so a house's power by hour of the day and day of the week shows the busy evenings at a glance, and a calendar of daily values shows a year. Two layouts (`grid`): - hours (the default): 7 rows of weekdays by 24 hours. Readings are grouped by hour in Home Assistant's time zone (the shared grouping), then every Tuesday 14:00 is averaged into one cell. Rows start on the user's first weekday. Without a period it covers the last 4 weeks of history. - calendar: a cell per day, a column per week, weekdays down the side and month names along the bottom, like a contribution graph. Without a period it covers the last 12 months of long-term statistics, since history rarely reaches back a year. Readings combine with the shared `aggregate` (mean by default; change for energy meters), and period, previous/next buttons and source work as on every history chart. `group_by` is set by the layout and ignored. Colour comes from a smooth scale of the entity's colour (faint for the lowest, full for the highest, or between `color_min` and `color_max`), or from the entity's colour bands (`color_by: bands`). A legend under each grid shows the gradient with its ends, or the bands. Cells can be rounded (default), square or hexagons, and keep their shape at any card width. Hours or days with no readings are drawn in the empty track colour, not as zero. Each entity gets its own grid, scale and legend; tapping its name runs its tap action. Hovering or touching a cell shows its time, value and, for the hours grid, how many days were averaged. Unavailable and missing entities draw an empty grid. The grid fades in, without motion when the browser asks for less. Every option is in the editor's Chart section: layout, period, source, how to combine, colour, cell shape, colour scale ends, legend and tooltip; the per-entity options hold the colour. Value-axis charts in this release share `src/chart/xy.ts` (picking a mark under the pointer and its tooltip, which the heatmap uses) and optional label formatters on `chartArea`. Closes #22 --- src/chart/heatmap-grid.ts | 141 +++++++++ src/styles/heatmap.ts | 584 ++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 3 +- test/heatmap.test.ts | 299 +++++++++++++++++++ 5 files changed, 1028 insertions(+), 1 deletion(-) create mode 100644 src/chart/heatmap-grid.ts create mode 100644 src/styles/heatmap.ts create mode 100644 test/heatmap.test.ts diff --git a/src/chart/heatmap-grid.ts b/src/chart/heatmap-grid.ts new file mode 100644 index 0000000..1cf1a4d --- /dev/null +++ b/src/chart/heatmap-grid.ts @@ -0,0 +1,141 @@ +import { startOf, wallTime } from '../history/timezone'; +import type { Zone } from '../history/timezone'; +import type { Point } from '../history/types'; + +/** + * The maths behind the heatmap: hourly groups folded into a week of hours, daily groups laid out + * as a calendar, and where a value sits on the colour scale. No drawing here. + */ + +/** One cell of a week grid: the mean of every hour that fell in it, and how many there were. */ +export interface WeekCell { + /** Row 0 is the zone's first weekday. */ + row: number; + /** Hour of the day, 0–23. */ + hour: number; + v: number | null; + count: number; +} + +/** + * Fold one-hour groups into 7 × 24 cells by their weekday and hour on the wall clock in `zone`: + * Tuesday 14:00 collects every Tuesday's 14:00–15:00. A cell is the plain mean of its hours (each + * hour is already one value, so each counts the same); a cell with no known hour is a gap, not 0. + * Rows start on the zone's first weekday. The hour the clocks go back happens twice and both + * count; the hour they skip is a gap that day. + */ +export function weekCells(points: Point[], zone: Zone): WeekCell[] { + const sums = new Array(7 * 24).fill(0); + const counts = new Array(7 * 24).fill(0); + for (const p of points) { + if (p.v === null) continue; + const w = wallTime(p.t, zone.timeZone); + const i = ((w.weekday - zone.firstDay + 7) % 7) * 24 + w.hour; + sums[i] += p.v; + counts[i] += 1; + } + return sums.map((sum, i) => ({ + row: Math.floor(i / 24), + hour: i % 24, + v: counts[i] ? sum / counts[i] : null, + count: counts[i], + })); +} + +/** One day of a calendar grid. */ +export interface DayCell { + /** The day's start. */ + t: number; + /** Week column, 0 at the week holding the first day. */ + col: number; + /** Row 0 is the zone's first weekday. */ + row: number; + v: number | null; +} + +export interface Calendar { + cells: DayCell[]; + /** Number of week columns. */ + weeks: number; + /** Where each month starts: its column, and the instant it starts (for its name). */ + months: { col: number; t: number }[]; +} + +/** + * Lay one-day groups out as a calendar: a column per week (from the zone's first weekday), a row + * per weekday, like a contribution graph. Weeks are counted from the first day's week by calendar + * date, so a 23- or 25-hour day doesn't shift a column. A month is labelled at the column of its + * first day, or at column 0 for the month the calendar opens in (unless the next month starts + * within three columns: the two names would overlap). + */ +export function calendarCells(points: Point[], zone: Zone): Calendar { + if (points.length === 0) return { cells: [], weeks: 0, months: [] }; + const dayNumber = (t: number) => { + const w = wallTime(t, zone.timeZone); + return Math.round(Date.UTC(w.year, w.month - 1, w.day) / 86_400_000); + }; + const firstWeek = dayNumber(startOf('week', points[0].t, zone)); + const cells: DayCell[] = []; + const months: { col: number; t: number }[] = []; + let lastMonth = -1; + for (const p of points) { + const w = wallTime(p.t, zone.timeZone); + const col = Math.floor((dayNumber(p.t) - firstWeek) / 7); + cells.push({ t: p.t, col, row: (w.weekday - zone.firstDay + 7) % 7, v: p.v }); + const month = w.year * 12 + w.month; + if (month !== lastMonth) { + if (lastMonth === -1 || w.day === 1) months.push({ col, t: p.t }); + lastMonth = month; + } + } + // The month the calendar opens in has no room for its name when the next starts soon after. + if (months.length > 1 && months[1].col - months[0].col < 3) months.shift(); + return { cells, weeks: cells[cells.length - 1].col + 1, months }; +} + +/** + * The ends of the colour scale: the lowest and highest known value, or the card's fixed ends where + * set. Undefined when there are no values. A flat set of values gets a scale of one unit, so every + * cell shows at full strength rather than dividing by zero. + */ +export function colorExtent( + values: (number | null)[], + fixed: { min?: number; max?: number } = {}, +): [number, number] | undefined { + const known = values.filter((v): v is number => v !== null); + if (known.length === 0 && (fixed.min === undefined || fixed.max === undefined)) return undefined; + let lo = fixed.min ?? Math.min(...known); + let hi = fixed.max ?? Math.max(...known); + if (hi <= lo) { + if (fixed.max === undefined) hi = lo + 1; + else lo = hi - 1; + } + return [lo, hi]; +} + +/** Where `v` sits between the scale's ends, 0–1, clamped. */ +export function fraction(v: number, [lo, hi]: [number, number]): number { + return Math.min(1, Math.max(0, (v - lo) / (hi - lo))); +} + +/** The lightest shade on the scale, as a percent of the colour mixed into the card's background. */ +export const SHADE_FLOOR = 12; + +/** A shade of `color` for fraction `f`: from a faint tint (low) to the full colour (high). */ +export function shade(color: string, f: number): string { + const pct = Math.round(SHADE_FLOOR + (100 - SHADE_FLOOR) * f); + return pct >= 100 ? color : `color-mix(in srgb, ${color} ${pct}%, var(--card-background-color, #fff))`; +} + +/** A pointy-topped hexagon centred on (`cx`, `cy`), `w` across its flats. */ +export function hexPath(cx: number, cy: number, w: number): string { + const r = w / Math.sqrt(3); + const pts = [0, 1, 2, 3, 4, 5].map((k) => { + const a = ((60 * k - 90) * Math.PI) / 180; + return `${Math.round((cx + r * Math.cos(a)) * 100) / 100},${Math.round((cy + r * Math.sin(a)) * 100) / 100}`; + }); + return `M${pts.join('L')}Z`; +} + +/** Vertical step between rows of hexagons one unit across: they nest, so less than one. */ +export const HEX_ROW = Math.sqrt(3) / 2; diff --git a/src/styles/heatmap.ts b/src/styles/heatmap.ts new file mode 100644 index 0000000..0d5ad6b --- /dev/null +++ b/src/styles/heatmap.ts @@ -0,0 +1,584 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { bandFor, baseColor, legendItems } from '../bands'; +import { hour12Of } from '../chart/axes'; +import { + disconnectHistory, + frameMessage, + frameStyles, + historyFrame, + periodNav, + validateHistoryOptions, +} from '../chart/frame'; +import type { ChartSeries, HistoryFrame } from '../chart/frame'; +import { chartWidth, stopChartWidth } from '../chart/grid'; +import { HEX_ROW, calendarCells, colorExtent, fraction, hexPath, shade, weekCells } from '../chart/heatmap-grid'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { pickHandlers, pickTooltip, pickedIndex, stopPick } from '../chart/xy'; +import { + HISTORY_FIELDS, + cachedSchema, + chartForm, + choice, + historySchema, + historyVariant, +} from '../editor/chart-editors'; +import type { FieldBinding } from '../editor/schemas'; +import { formatNumber, withUnit } from '../format'; +import { AGGREGATES } from '../history/group'; +import { localize, registerStrings } from '../localize'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; +import type { CardConfig, HomeAssistant } from '../types'; + +export const HEATMAP_GRIDS = ['hours', 'calendar'] as const; +export type HeatmapGrid = (typeof HEATMAP_GRIDS)[number]; +export const HEATMAP_COLORS = ['scale', 'bands'] as const; +export const CELL_SHAPES = ['square', 'rounded', 'hexagon'] as const; +export type CellShape = (typeof CELL_SHAPES)[number]; + +/** Cell pitch in the drawing's own units; cells are drawn a little smaller to leave a gap. */ +const CELL = 10; +const GAP = 1.2; +/** Grid, cell: one number for the picked cell (the pointer state holds a single index). */ +const PER_GRID = 10_000; + +/** + * What each layout covers and reads from when the card doesn't say: four weeks of history by + * hour (enough for each weekday hour to average a few days, inside the recorder's default 10 days + * of keeping… mostly), or twelve months of daily statistics (history rarely goes back a year). + */ +const DEFAULTS: Record = { + hours: { last: 4, unit: 'week', source: 'history' }, + calendar: { last: 12, unit: 'month', source: 'statistics' }, +}; + +registerStrings('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', +}); + +type Raw = CardConfig & { grid?: unknown; cell_shape?: unknown; color_min?: unknown; color_max?: unknown }; + +const isNumber = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + +function gridOf(raw: CardConfig): HeatmapGrid { + return ((raw as Raw).grid as HeatmapGrid | undefined) ?? 'hours'; +} + +/** The card's options with the layout's grouping, and its default span and source filled in. */ +export function heatmapRaw(raw: CardConfig): CardConfig { + const grid = gridOf(raw); + const d = DEFAULTS[grid]; + const period = (raw.period ?? {}) as Record; + return { + ...raw, + group_by: grid === 'hours' ? 'hour' : 'day', + source: raw.source ?? d.source, + period: + period.calendar !== undefined ? period : { ...period, last: period.last ?? d.last, unit: period.unit ?? d.unit }, + }; +} + +function validate(raw: CardConfig): void { + const r = raw as Raw; + const one = (key: string, values: readonly string[]) => { + const v = (r as Record)[key]; + if (v !== undefined && !values.includes(v as string)) + throw new Error(`"${key}" must be one of: ${values.join(', ')}`); + }; + one('grid', HEATMAP_GRIDS); + one('color_by', HEATMAP_COLORS); + one('cell_shape', CELL_SHAPES); + for (const k of ['color_min', 'color_max'] as const) { + if (r[k] !== undefined && !isNumber(r[k])) throw new Error(`"${k}" must be a number`); + } + if (isNumber(r.color_min) && isNumber(r.color_max) && r.color_min >= r.color_max) { + throw new Error('"color_min" must be below "color_max"'); + } + if (r.legend !== undefined && typeof r.legend !== 'boolean') throw new Error('"legend" must be true or false'); + validateHistoryOptions(heatmapRaw(raw)); + validateTooltipOptions(raw); +} + +/* -------------------------------------------------------------- labels -------------------------------------------------------------- */ + +const formats = new Map(); + +function dateFormat(hass: HomeAssistant | undefined, timeZone: string, opts: Intl.DateTimeFormatOptions) { + const language = hass?.locale?.language ?? hass?.language ?? 'en'; + const k = `${language}|${timeZone}|${JSON.stringify(opts)}`; + let f = formats.get(k); + if (!f) { + try { + f = new Intl.DateTimeFormat(language, { ...opts, timeZone }); + } catch { + f = new Intl.DateTimeFormat('en', { ...opts, timeZone: 'UTC' }); + } + formats.set(k, f); + } + return f; +} + +/** A weekday's short name, 1 = Monday … 7 = Sunday (1 Jan 2024 was a Monday). */ +function weekdayName(day: number, hass?: HomeAssistant): string { + return dateFormat(hass, 'UTC', { weekday: 'short' }).format(Date.UTC(2024, 0, day)); +} + +/** An hour of the day on the user's clock: "14" or "2 PM". */ +function hourName(hour: number, hass?: HomeAssistant, minutes = false): string { + return dateFormat(hass, 'UTC', { + hour: 'numeric', + ...(minutes ? { minute: '2-digit' } : {}), + hour12: hour12Of(hass), + }).format(Date.UTC(2024, 0, 1, hour)); +} + +/* -------------------------------------------------------------- drawing -------------------------------------------------------------- */ + +interface Cell { + col: number; + row: number; + v: number | null; + /** Tooltip heading. */ + title: () => string; + count?: number; +} + +interface Geometry { + width: number; + height: number; + centre(col: number, row: number): [number, number]; + /** The cell under a point given as fractions of the drawing. */ + at(fx: number, fy: number): [number, number]; +} + +function geometry(cols: number, rows: number, shape: CellShape): Geometry { + if (shape === 'hexagon') { + const r = CELL / Math.sqrt(3); + const pitch = CELL * HEX_ROW; + const width = cols * CELL + CELL / 2; + const height = (rows - 1) * pitch + 2 * r; + return { + width, + height, + centre: (col, row) => [col * CELL + CELL / 2 + (row % 2) * (CELL / 2), r + row * pitch], + at(fx, fy) { + const row = Math.min(rows - 1, Math.max(0, Math.round((fy * height - r) / pitch))); + const col = Math.floor((fx * width - (row % 2) * (CELL / 2)) / CELL); + return [Math.min(cols - 1, Math.max(0, col)), row]; + }, + }; + } + return { + width: cols * CELL, + height: rows * CELL, + centre: (col, row) => [col * CELL + CELL / 2, row * CELL + CELL / 2], + at: (fx, fy) => [Math.min(cols - 1, Math.floor(fx * cols)), Math.min(rows - 1, Math.floor(fy * rows))], + }; +} + +function cellMark( + g: Geometry, + cell: Cell, + shape: CellShape, + fill: string | undefined, + cls = 'cell', +): SVGTemplateResult { + const [cx, cy] = g.centre(cell.col, cell.row); + const style = fill ? `fill:${fill}` : ''; + const empty = fill ? '' : 'empty'; + if (shape === 'hexagon') { + return svg``; + } + const s = CELL - GAP; + return svg``; +} + +const pct = (n: number) => `${Math.round(n * 10000) / 100}%`; + +interface Built { + cells: Cell[]; + cols: number; + rows: number; + rowLabels: { row: number; text: string }[]; + colLabels: { col: number; text: string }[]; +} + +function hoursGrid(s: ChartSeries, frame: HistoryFrame, hass: HomeAssistant, wide: boolean): Built { + const { zone } = frame; + const weekday = (row: number) => ((zone.firstDay - 1 + row) % 7) + 1; + const cells = weekCells(s.points, zone).map((c): Cell => ({ + col: c.hour, + row: c.row, + v: c.v, + count: c.count, + title: () => + `${weekdayName(weekday(c.row), hass)} ${hourName(c.hour, hass, true)} – ${hourName((c.hour + 1) % 24, hass, true)}`, + })); + const every = wide ? 3 : 6; + return { + cells, + cols: 24, + rows: 7, + rowLabels: [0, 1, 2, 3, 4, 5, 6].map((row) => ({ row, text: weekdayName(weekday(row), hass) })), + colLabels: Array.from({ length: 24 / every }, (_, i) => ({ col: i * every, text: hourName(i * every, hass) })), + }; +} + +function calendarGrid(s: ChartSeries, frame: HistoryFrame, hass: HomeAssistant): Built { + const { zone } = frame; + const cal = calendarCells(s.points, zone); + const day = dateFormat(hass, zone.timeZone, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + const month = dateFormat(hass, zone.timeZone, { month: 'short' }); + const weekday = (row: number) => ((zone.firstDay - 1 + row) % 7) + 1; + return { + cells: cal.cells.map((c) => ({ col: c.col, row: c.row, v: c.v, title: () => day.format(c.t) })), + cols: Math.max(1, cal.weeks), + rows: 7, + rowLabels: [0, 1, 2, 3, 4, 5, 6].map((row) => ({ row, text: weekdayName(weekday(row), hass) })), + colLabels: cal.months.map((m) => ({ col: m.col, text: month.format(m.t) })), + }; +} + +function legend(s: ChartSeries, extent: [number, number] | undefined, byBands: boolean, ctx: ChartContext) { + const e = s.entity; + const fmt = (v: number) => formatNumber(v, e.decimals, ctx.hass); + if (byBands) { + return html`
+ ${legendItems(e.bands, fmt, e.unit).map( + (item) => + html`${item.text}`, + )} +
`; + } + if (!extent) return nothing; + return html`
+ ${fmt(extent[0])} + + ${withUnit(fmt(extent[1]), e.unit)} +
`; +} + +function drawGrid(s: ChartSeries, frame: HistoryFrame, ctx: ChartContext, multi: boolean): TemplateResult { + const raw = ctx.raw as Raw; + const grid = gridOf(ctx.raw); + const shape = (raw.cell_shape as CellShape | undefined) ?? 'rounded'; + const byBands = raw.color_by === 'bands'; + const width = chartWidth(ctx); + const built = grid === 'hours' ? hoursGrid(s, frame, ctx.hass, width >= 400) : calendarGrid(s, frame, ctx.hass); + const g = geometry(built.cols, built.rows, shape); + // Rough size of a cell on screen (the card less its padding and the weekday labels), so labels + // that would collide on a narrow card are thinned out. + const cellPx = (Math.max(0, width - 72) / g.width) * CELL; + const rowStep = Math.ceil(13 / Math.max(1, cellPx * (shape === 'hexagon' ? HEX_ROW : 1))); + const rowLabels = built.rowLabels.filter((l) => l.row % rowStep === 0); + let last = -Infinity; + const colLabels = built.colLabels.filter((l) => { + if ((l.col - last) * cellPx < 32) return false; + last = l.col; + return true; + }); + const extent = colorExtent( + built.cells.map((c) => c.v), + { + min: isNumber(raw.color_min) ? raw.color_min : undefined, + max: isNumber(raw.color_max) ? raw.color_max : undefined, + }, + ); + const fillOf = (v: number | null) => { + if (v === null) return undefined; + if (byBands) return baseColor(bandFor(v, s.entity.bands).color); + return extent ? shade(s.color, fraction(v, extent)) : s.color; + }; + const picked = pickedIndex(ctx.host); + const mine = picked !== undefined && Math.floor(picked / PER_GRID) === s.index ? picked % PER_GRID : undefined; + const byPos = new Map(built.cells.map((c, i) => [c.col * 8 + c.row, i])); + const tooltip = tooltipEnabled(ctx.raw); + const hover = tooltip + ? pickHandlers(ctx, (fx, fy) => { + const [col, row] = g.at(fx, fy); + const i = byPos.get(col * 8 + row); + return i === undefined ? undefined : s.index * PER_GRID + i; + }) + : undefined; + const pickedCell = mine === undefined ? undefined : built.cells[mine]; + let overlay: TemplateResult | typeof nothing = nothing; + if (pickedCell) { + const [cx, cy] = g.centre(pickedCell.col, pickedCell.row); + const value = + pickedCell.v === null + ? localize(ctx.hass, 'heatmap_empty') + : withUnit(formatNumber(pickedCell.v, s.entity.decimals, ctx.hass), s.entity.unit); + overlay = pickTooltip({ + x: cx / g.width, + y: cy / g.height, + plotWidth: Math.max(0, width - 80), + title: pickedCell.title(), + rows: [ + { color: fillOf(pickedCell.v) ?? 'var(--cd-track)', name: s.entity.name, value }, + ...(pickedCell.count !== undefined && pickedCell.v !== null + ? [{ name: localize(ctx.hass, 'heatmap_hours'), value: String(pickedCell.count) }] + : []), + ], + }); + } + const { show } = ctx.config; + const head = + show.name || show.value || multi + ? html`
+
+ ${byBands ? nothing : html``} + ${show.name || multi ? html`${s.entity.name}` : nothing} + ${show.value ? html`${s.entity.display}` : nothing} +
+
` + : nothing; + const label = localize(ctx.hass, 'heatmap_aria', { name: s.entity.name }); + return html`
+ ${head} +
+ +
+ + ${built.cells.map((c) => cellMark(g, c, shape, fillOf(c.v)))} + ${pickedCell ? cellMark(g, pickedCell, shape, 'none', 'cell picked') : nothing} + + ${overlay} +
+ +
+ ${raw.legend === false ? nothing : legend(s, extent, byBands, ctx)} +
`; +} + +/* -------------------------------------------------------------- editor -------------------------------------------------------------- */ + +const fieldCache = new Map(); + +/** The history fields without grouping (the layout sets it), defaulting to the layout's span. */ +function heatmapFields(grid: HeatmapGrid): FieldBinding[] { + let fields = fieldCache.get(grid); + if (!fields) { + const d = DEFAULTS[grid]; + const own: Record = { period_last: d.last, period_unit: d.unit, source: d.source }; + fields = [ + { name: 'grid', path: ['grid'], default: 'hours' }, + ...HISTORY_FIELDS.filter((f) => f.name !== 'group_by').map((f) => + f.name in own ? { ...f, default: own[f.name] } : f, + ), + { 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: true }, + { name: 'tooltip', path: ['tooltip'], default: true }, + ]; + fieldCache.set(grid, fields); + } + return fields; +} + +function heatmapEditor(ctx: ChartEditorContext): TemplateResult { + const grid = gridOf(ctx.config); + const variant = historyVariant({ ...ctx.config, group_by: undefined }); + const schema = cachedSchema(`heatmap|${variant}`, ctx.hass, () => [ + { name: 'grid', selector: choice(ctx.hass, 'grid', HEATMAP_GRIDS) }, + // The span and source; grouping is the layout's, so only how to combine readings stays. + ...historySchema(ctx.hass, { ...ctx.config, group_by: undefined }).filter( + (s) => !JSON.stringify(s).includes('"group_by"'), + ), + { name: 'aggregate', selector: choice(ctx.hass, 'aggregate', AGGREGATES) }, + { + type: 'grid', + name: '', + schema: [ + { name: 'color_by', selector: choice(ctx.hass, 'color_by', HEATMAP_COLORS) }, + { name: 'cell_shape', selector: choice(ctx.hass, 'cell_shape', CELL_SHAPES) }, + ], + }, + { + 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 chartForm(ctx, 'chart', schema, heatmapFields(grid)); +} + +/* -------------------------------------------------------------- module -------------------------------------------------------------- */ + +export const heatmap: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 220, + styles: css` + ${frameStyles} + ${tooltipStyles} + .hm + .hm { + margin-top: 12px; + } + .hm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 6px; + } + .hm-rows { + position: relative; + grid-column: 1; + grid-row: 1; + min-width: 2em; + } + .hm-plot { + position: relative; + grid-column: 2; + grid-row: 1; + } + .hm-plot.hoverable { + cursor: crosshair; + } + .hm-svg { + display: block; + width: 100%; + height: auto; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .hm-svg { + opacity: 1; + } + :host([static]) .hm-svg { + transition: none; + } + .hm-svg .cell.empty { + fill: var(--cd-track); + } + .hm-svg .cell.picked { + fill: none; + stroke: var(--primary-text-color); + stroke-width: 1px; + vector-effect: non-scaling-stroke; + } + .hm-cols { + position: relative; + grid-column: 2; + grid-row: 2; + height: 16px; + } + .hm-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + } + .hm-rows .hm-label { + right: 0; + transform: translateY(-50%); + } + .hm-cols .hm-label { + top: 4px; + } + .grid-calendar .hm-label { + font-size: 10px; + } + .hm-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + padding-top: 6px; + font-size: 11px; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .hm-legend.scale { + justify-content: flex-end; + gap: 6px; + } + .hm-gradient { + width: min(160px, 40%); + height: 8px; + border-radius: 4px; + } + .hm-key { + display: inline-flex; + align-items: center; + gap: 4px; + } + .hm-swatch { + width: 10px; + height: 10px; + border-radius: 2px; + } + `, + thumbnail: () => svg` + ${[0, 1, 2, 3].map((r) => + [0, 1, 2, 3, 4, 5, 6].map((c) => { + const f = ((c * 3 + r * 5) % 7) / 6; + return svg``; + }), + )}`, + validate, + editor: heatmapEditor, + entityFields: () => ({ + schema: [{ name: 'series_color', selector: { text: {} } }], + fields: [{ name: 'series_color', path: ['color'] }], + }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const frame = historyFrame({ ...ctx, raw: heatmapRaw(ctx.raw) }); + const nav = periodNav(frame, ctx); + const message = frameMessage(frame, ctx); + if (message) return html`
${nav}${message}
`; + const multi = frame.series.length > 1; + return html`
${nav}${frame.series.map((s) => drawGrid(s, frame, ctx, multi))}
`; + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 2e6c2f6..a9039cf 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -7,6 +7,7 @@ import { bar } from './bar'; import { bubble } from './bubble'; import { columnChart } from './column'; import { combined } from './combined'; +import { heatmap } from './heatmap'; import { histogram } from './histogram'; import { line } from './line'; import { needle } from './needle'; @@ -126,4 +127,5 @@ export const STYLES: Record = { histogram, scatter, bubble, + heatmap, }; diff --git a/src/types.ts b/src/types.ts index 110d687..34c87e8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ 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'; + | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -21,6 +21,7 @@ export const STYLE_NAMES: StyleName[] = [ 'histogram', 'scatter', 'bubble', + 'heatmap', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/heatmap.test.ts b/test/heatmap.test.ts new file mode 100644 index 0000000..b5e528b --- /dev/null +++ b/test/heatmap.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { calendarCells, colorExtent, fraction, hexPath, shade, weekCells } from '../src/chart/heatmap-grid'; +import { heatmapRaw } from '../src/styles/heatmap'; +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 D = 24 * H; +const utc = { timeZone: 'UTC', firstDay: 1 }; + +describe('heatmap maths', () => { + it('folds hours into weekday rows and hour columns, averaging across weeks', () => { + const mon14 = Date.UTC(2024, 0, 1, 14); + const cells = weekCells( + [ + { t: mon14, v: 10 }, + { t: mon14 + 7 * D, v: 20 }, + { t: mon14 + D, v: null }, + { t: mon14 + D + H, v: 4 }, + ], + utc, + ); + expect(cells).toHaveLength(168); + expect(cells[14]).toEqual({ row: 0, hour: 14, v: 15, count: 2 }); + expect(cells[24 + 14].v).toBeNull(); + expect(cells[24 + 15].v).toBe(4); + // Weeks from Sunday: Monday is the second row. + expect(weekCells([{ t: mon14, v: 1 }], { timeZone: 'UTC', firstDay: 7 })[24 + 14].v).toBe(1); + // The wall clock in the zone decides the hour: 14:00 UTC is 15:00 in Paris in winter. + expect(weekCells([{ t: mon14, v: 1 }], { timeZone: 'Europe/Paris', firstDay: 1 })[15].v).toBe(1); + }); + + it('lays days out in week columns from the first weekday, naming each month where it starts', () => { + const days = (from: number, n: number) => Array.from({ length: n }, (_, i) => ({ t: from + i * D, v: i })); + const jan = calendarCells(days(Date.UTC(2024, 0, 1), 70), utc); + expect(jan.cells[0]).toEqual({ t: Date.UTC(2024, 0, 1), col: 0, row: 0, v: 0 }); + expect(jan.cells[9]).toMatchObject({ col: 1, row: 2 }); + expect(jan.weeks).toBe(10); + // 1 Feb 2024 was a Thursday, in the week of Monday 29 January: column 4. + expect(jan.months.map((m) => m.col)).toEqual([0, 4, 8]); + // Opening on 31 January: no room to name January before February starts. + const late = calendarCells(days(Date.UTC(2024, 0, 31), 10), utc); + expect(late.cells[0]).toMatchObject({ col: 0, row: 2 }); + expect(late.months).toEqual([{ col: 0, t: Date.UTC(2024, 1, 1) }]); + expect(calendarCells([], utc)).toEqual({ cells: [], weeks: 0, months: [] }); + }); + + it('sets the colour scale from the values or fixed ends', () => { + expect(colorExtent([null, 3, 9])).toEqual([3, 9]); + expect(colorExtent([null])).toBeUndefined(); + expect(colorExtent([5, 5])).toEqual([5, 6]); + expect(colorExtent([5, 8], { max: 2 })).toEqual([1, 2]); + expect(colorExtent([], { min: 0, max: 10 })).toEqual([0, 10]); + expect(fraction(15, [0, 10])).toBe(1); + expect(fraction(-1, [0, 10])).toBe(0); + expect(fraction(5, [0, 10])).toBe(0.5); + expect(shade('red', 1)).toBe('red'); + expect(shade('red', 0)).toBe('color-mix(in srgb, red 12%, var(--card-background-color, #fff))'); + expect(hexPath(5, 5, 10).match(/[ML]/g)).toHaveLength(6); + }); + + it('fills in each layout’s grouping, span and source', () => { + expect(heatmapRaw({ type: 'x' })).toMatchObject({ + group_by: 'hour', + source: 'history', + period: { last: 4, unit: 'week' }, + }); + expect(heatmapRaw({ type: 'x', grid: 'calendar', group_by: 'week' })).toMatchObject({ + group_by: 'day', + source: 'statistics', + period: { last: 12, unit: 'month' }, + }); + expect(heatmapRaw({ type: 'x', grid: 'calendar', source: 'history', period: { calendar: 'year' } })).toMatchObject({ + source: 'history', + period: { calendar: 'year' }, + }); + }); +}); + +type Card = HTMLElement & { setConfig(c: CardConfig): void; hass?: HomeAssistant; updateComplete: Promise }; + +function hassWithData() { + const hass = makeHass({ + 'sensor.power': ['300', { friendly_name: 'Power', unit_of_measurement: 'W' }], + 'sensor.down': ['unavailable', { friendly_name: 'Down' }], + }); + // The server's zone is the browser's: statistics come as whole days. + hass.config = { time_zone: Intl.DateTimeFormat().resolvedOptions().timeZone }; + const now = Date.now(); + hass.callWS = vi.fn(async (msg: Record) => { + if (msg.type === 'history/history_during_period') { + const rows = []; + for (let t = now - 30 * D; t < now; t += H) rows.push({ s: String(Math.round(t / H) % 24), lu: t / 1000 }); + return { 'sensor.power': rows }; + } + const rows = []; + for (let t = Math.floor((now - 400 * D) / D) * D; t < now; t += D) { + rows.push({ start: t, end: t + D, mean: (t / D) % 10, state: 1, change: 2 }); + } + return { 'sensor.power': rows }; + }) as HomeAssistant['callWS']; + return hass; +} + +async function mount(config: Partial, hass = hassWithData()) { + const card = document.createElement('chartdeck-card') as Card; + card.setConfig({ + type: 'custom:chartdeck-card', + style: 'heatmap', + entities: ['sensor.power'], + ...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)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('heatmap card', () => { + it('draws an hour-by-weekday grid of rounded cells with a gradient legend', async () => { + const card = await mount({}); + expect(qa(card, '.hm')).toHaveLength(1); + expect(qa(card, 'rect.cell')).toHaveLength(168); + expect(q(card, 'rect.cell')!.getAttribute('rx')).toBe('2'); + expect(q(card, 'svg.hm-svg')!.getAttribute('role')).toBe('img'); + expect(q(card, 'svg.hm-svg')!.getAttribute('aria-label')).toBe('Heatmap of Power'); + expect(qa(card, '.hm-rows .hm-label').length).toBeGreaterThan(0); + expect(qa(card, '.hm-cols .hm-label').length).toBeGreaterThan(0); + expect(q(card, '.hm-gradient')).not.toBeNull(); + const ends = qa(card, '.hm-legend .hm-end').map((e) => e.textContent); + expect(ends).toHaveLength(2); + expect(ends[1]).toMatch(/ W$/); + // History asked for, from four weeks back. + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('history/history_during_period'); + expect(Date.now() - Date.parse(call.start_time)).toBeGreaterThan(27 * D); + }); + + it('shows a cell’s value on hover and opens the entity from its name', async () => { + const card = await mount({}); + const plot = q(card, '.hm-plot') as HTMLElement; + plot.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 0, clientY: 0 })); + await card.updateComplete; + expect(q(card, '.tooltip')).not.toBeNull(); + expect(q(card, '.cell.picked')).not.toBeNull(); + expect(qa(card, '.tooltip-row')).toHaveLength(2); + plot.dispatchEvent(new MouseEvent('pointerleave', { bubbles: true })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + const fired: string[] = []; + card.addEventListener('hass-action', (e) => fired.push((e as CustomEvent).detail.config.entity)); + const item = q(card, '.series-item') as HTMLElement; + item.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + item.dispatchEvent(new MouseEvent('pointerup', { bubbles: true })); + expect(fired).toEqual(['sensor.power']); + }); + + it('draws a calendar from daily statistics, in hexagons coloured by band, one grid per entity', async () => { + const card = await mount({ + grid: 'calendar', + cell_shape: 'hexagon', + color_by: 'bands', + severity: [ + { from: 0, color: 'blue' }, + { from: 5, color: 'red' }, + ], + entities: ['sensor.power', 'sensor.down', 'sensor.missing'], + } as Partial); + expect(qa(card, '.hm')).toHaveLength(3); + const call = (card.hass!.callWS as ReturnType).mock.calls[0][0]; + expect(call.type).toBe('recorder/statistics_during_period'); + expect(call.period).toBe('day'); + const first = q(card, '.hm')!; + const cells = [...first.querySelectorAll('path.cell')]; + expect(cells.length).toBeGreaterThan(360); + expect(cells.some((c) => (c as SVGElement).style.fill.includes('--error-color'))).toBe(true); + expect(first.querySelectorAll('.hm-key')).toHaveLength(2); + expect(first.querySelectorAll('.hm-cols .hm-label').length).toBeGreaterThan(3); + // The entities with no statistics draw empty grids rather than breaking the card. + const missing = qa(card, '.hm')[2]; + expect(missing.querySelectorAll('.cell:not(.empty)')).toHaveLength(0); + }); + + it('uses square cells, fixed colour ends, and hides the legend and tooltip when asked', async () => { + const card = await mount({ + cell_shape: 'square', + color_min: 0, + color_max: 100, + legend: false, + tooltip: false, + } as Partial); + expect(q(card, 'rect.cell')!.getAttribute('rx')).toBe('0.4'); + expect(q(card, '.hm-legend')).toBeNull(); + expect(q(card, '.hm-plot.hoverable')).toBeNull(); + const fills = qa(card, 'rect.cell').map((c) => (c as SVGElement).style.fill); + // Every value is under 24 of 100: nothing reaches the full colour. + expect(fills.every((f) => f.includes('color-mix'))).toBe(true); + }); + + it('says when there is nothing to draw', async () => { + const hass = hassWithData(); + hass.callWS = vi.fn(async () => ({})) as HomeAssistant['callWS']; + const card = await mount({}, hass); + 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: 'heatmap', entities: ['sensor.power'], ...extra } as CardConfig); + expect(set({ grid: 'year' })).toThrow('"grid" must be one of: hours, calendar'); + expect(set({ color_by: 'series' })).toThrow('"color_by" must be one of: scale, bands'); + expect(set({ cell_shape: 'circle' })).toThrow(/cell_shape/); + expect(set({ color_min: 'low' })).toThrow('"color_min" must be a number'); + expect(set({ color_min: 5, color_max: 1 })).toThrow('"color_min" must be below "color_max"'); + expect(set({ legend: 'yes' })).toThrow(/legend/); + expect(set({ aggregate: 'median' })).toThrow(/aggregate/); + expect(set({ tooltip: 1 })).toThrow(/tooltip/); + expect( + set({ grid: 'calendar', cell_shape: 'hexagon', color_by: 'bands', color_min: 0, color_max: 1 }), + ).not.toThrow(); + }); +}); + +describe('heatmap editor', () => { + it('shows the layout, span, combining, colour and cell options, with each layout’s defaults', 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: 'heatmap', entities: ['sensor.power'] } 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({ + grid: 'hours', + period_calendar: 'rolling', + period_last: 4, + period_unit: 'week', + period_offset: 0, + period_until: 'now', + period_navigate: false, + source: 'history', + aggregate: 'mean', + color_by: 'scale', + cell_shape: 'rounded', + legend: true, + tooltip: true, + }); + const names = JSON.stringify(form.schema); + expect(names).not.toContain('group_by'); + for (const n of ['grid', 'aggregate', 'cell_shape', 'color_min', 'color_max', 'legend']) + expect(names).toContain(`"${n}"`); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...(form.data as object), grid: 'calendar', cell_shape: 'hexagon', color_max: 50 } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, grid: 'calendar', cell_shape: 'hexagon', color_max: 50 }); + ed.setConfig(events.at(-1)!); + await ed.updateComplete; + const cal = ed.shadowRoot!.querySelector('.section-chart ha-form') as Stub; + expect(cal.data).toMatchObject({ grid: 'calendar', period_last: 12, period_unit: 'month', source: 'statistics' }); + 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).toEqual(['series_color']); + }); +}); From f3738e7e3909448e45550a94dc78b9406c40f8ce Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Thu, 29 Oct 2026 09:13:09 +0000 Subject: [PATCH 5/7] feat: box plot of how a sensor's readings spread, per entity or per day, week or month MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new chart style, "Box plot" (style: box), in the editor's Distributions group. Each box shows the median, the quartiles and the whiskers of an entity's readings over the period. Without grouping there is one box per entity; with group_by (hour, day, week, month) there is one box per group, and several entities sit side by side in their series colours. History records changes, so each value counts for as long as it held: a room that sat at 20 °C all day and flickered to 30 °C for a few minutes has a median of 20. The value carried in from before a group counts from its start, unavailable time doesn't count, and the current group stops at now. Long-term statistics are fetched as hourly rows (five-minute rows for hourly boxes); rows are equal length, so each counts once, and their own min and max widen the box's lowest and highest. Quantiles are weighted Hazen quantiles (four readings 1-4 have a median of 2.5). Whiskers reach 1.5 times the box's height with readings beyond drawn as outlier dots (whiskers: tukey, the default), or the lowest and highest (whiskers: range). orientation: horizontal turns the boxes on their side with the names up the side; readings: true draws each reading as a jittered dot over its box (at most 120 per box, always in the same place). Hovering or touching a box shows its highest, quartiles, median, lowest, average and reading count. It reuses the history charts' period, previous/next buttons, source, axes, gridlines, value range, compact layout and header. All options are in the editor's Chart section; each entity has a box colour. Grouping gives only the boundaries here, so there is no "combine with" choice. Also adds src/chart/xy.ts (value axes and a pick-one-mark tooltip shared with the other distribution charts) and lets chartArea take its own label text for each axis. Closes #23 --- src/chart/box.ts | 152 ++++++++++++ src/styles/box-plot.ts | 542 +++++++++++++++++++++++++++++++++++++++++ src/styles/index.ts | 2 + src/types.ts | 3 +- test/box-plot.test.ts | 318 ++++++++++++++++++++++++ 5 files changed, 1016 insertions(+), 1 deletion(-) create mode 100644 src/chart/box.ts create mode 100644 src/styles/box-plot.ts create mode 100644 test/box-plot.test.ts diff --git a/src/chart/box.ts b/src/chart/box.ts new file mode 100644 index 0000000..1ffabd0 --- /dev/null +++ b/src/chart/box.ts @@ -0,0 +1,152 @@ +import type { Point } from '../history/types'; + +/** + * Box plot maths: the spread of a sensor's readings over a span, as quartiles, whiskers and + * outliers. Readings come in two shapes: + * + * - History records changes, not samples. A value holds until the next one, so each reading is + * weighted by how long it held (as the grouped mean in group.ts is). A heater that sat at 20 °C + * all day and flickered to 30 °C ten times in a minute has a median of 20, not 30. + * - Long-term statistics rows are all the same length (an hour, five minutes), so each row's + * mean counts once, which is already time-weighted. Each row also carries the lowest and highest + * reading within it; whiskers drawn to the extremes use those, so a short spike inside an hour + * still shows. + */ + +/** One value and how much it counts (ms held, or 1 for a statistics row). */ +export interface Sample { + v: number; + w: number; +} + +/** The values that held within [start, stop), each weighted by how long it held. */ +export function heldSamples(points: readonly Point[], start: number, stop: number): Sample[] { + const out: Sample[] = []; + if (stop <= start) return out; + for (let i = 0; i < points.length; i++) { + const p = points[i]; + if (p.t >= stop) break; + const next = i + 1 < points.length ? points[i + 1].t : Infinity; + const from = Math.max(p.t, start); + const to = Math.min(next, stop); + if (to <= from || p.v === null) continue; + out.push({ v: p.v, w: to - from }); + } + return out; +} + +/** Statistics rows starting within [start, stop), one sample each, with the rows' extremes. */ +export function rowSamples( + rows: readonly Point[], + start: number, + stop: number, +): { samples: Sample[]; extremes?: [number, number] } { + const samples: Sample[] = []; + let lo = Infinity; + let hi = -Infinity; + for (const r of rows) { + if (r.t < start || r.t >= stop || r.carried || r.v === null) continue; + samples.push({ v: r.v, w: 1 }); + lo = Math.min(lo, r.v, typeof r.min === 'number' ? r.min : r.v); + hi = Math.max(hi, r.v, typeof r.max === 'number' ? r.max : r.v); + } + return { samples, extremes: samples.length ? [lo, hi] : undefined }; +} + +/** + * The `q` quantile (0–1) of weighted samples sorted by value. Each sample sits at the middle of + * its share of the total weight, and values between are interpolated; with equal weights this is + * the textbook (Hazen) quantile, so four readings 1, 2, 3, 4 have a median of 2.5. + */ +export function weightedQuantile(sorted: readonly Sample[], q: number): number { + const total = sorted.reduce((a, s) => a + s.w, 0); + let before = 0; + let prev: { at: number; v: number } | undefined; + for (const s of sorted) { + const at = (before + s.w / 2) / total; + before += s.w; + if (q <= at) { + if (!prev) return s.v; + return prev.v + ((s.v - prev.v) * (q - prev.at)) / (at - prev.at); + } + prev = { at, v: s.v }; + } + return sorted[sorted.length - 1].v; +} + +export const WHISKERS = ['tukey', 'range'] as const; +export type Whiskers = (typeof WHISKERS)[number]; + +/** Outliers kept per box; beyond this the most extreme ones are kept. */ +export const MAX_OUTLIERS = 60; + +export interface BoxStats { + /** How many readings (or rows) went into it. */ + count: number; + q1: number; + median: number; + q3: number; + /** Time-weighted mean. */ + mean: number; + /** Where the whiskers end. */ + low: number; + high: number; + /** Lowest and highest of everything, outliers included. */ + min: number; + max: number; + /** Values beyond the whiskers (Tukey whiskers only), distinct and sorted. */ + outliers: number[]; +} + +/** + * Quartiles, whiskers and outliers. Tukey whiskers reach the furthest reading within 1.5 × the + * box's height of it, and readings beyond are outliers; range whiskers reach the lowest and + * highest, with no outliers. `extremes` (statistics rows' own min and max) widen the range. + * Undefined when there are no samples. + */ +export function boxStats( + samples: readonly Sample[], + whiskers: Whiskers, + extremes?: [number, number], +): BoxStats | undefined { + const sorted = samples.filter((s) => s.w > 0 && Number.isFinite(s.v)).sort((a, b) => a.v - b.v); + if (sorted.length === 0) return undefined; + const q1 = weightedQuantile(sorted, 0.25); + const median = weightedQuantile(sorted, 0.5); + const q3 = weightedQuantile(sorted, 0.75); + const total = sorted.reduce((a, s) => a + s.w, 0); + const mean = sorted.reduce((a, s) => a + s.v * s.w, 0) / total; + const min = Math.min(sorted[0].v, extremes?.[0] ?? Infinity); + const max = Math.max(sorted[sorted.length - 1].v, extremes?.[1] ?? -Infinity); + const base = { count: sorted.length, q1, median, q3, mean, min, max }; + if (whiskers === 'range') return { ...base, low: min, high: max, outliers: [] }; + const reach = 1.5 * (q3 - q1); + const inside = sorted.filter((s) => s.v >= q1 - reach && s.v <= q3 + reach); + const low = Math.min(inside[0]?.v ?? q1, q1); + const high = Math.max(inside[inside.length - 1]?.v ?? q3, q3); + let outliers = [...new Set(sorted.filter((s) => s.v < low || s.v > high).map((s) => s.v))]; + if (outliers.length > MAX_OUTLIERS) { + // Keep the furthest from the box on each side. + outliers = outliers + .map((v) => ({ v, d: v < low ? low - v : v - high })) + .sort((a, b) => b.d - a.d) + .slice(0, MAX_OUTLIERS) + .map((o) => o.v) + .sort((a, b) => a - b); + } + return { ...base, low, high, outliers }; +} + +/** At most `max` items, picked evenly through the list. */ +export function spread(items: readonly T[], max: number): T[] { + if (items.length <= max) return [...items]; + const out: T[] = []; + for (let i = 0; i < max; i++) out.push(items[Math.floor((i * items.length) / max)]); + return out; +} + +/** A repeatable sideways offset in [-1, 1) for reading `i`, so dots don't jump on each redraw. */ +export function jitter(i: number): number { + const x = Math.sin((i + 1) * 12.9898) * 43758.5453; + return (x - Math.floor(x)) * 2 - 1; +} diff --git a/src/styles/box-plot.ts b/src/styles/box-plot.ts new file mode 100644 index 0000000..a71975c --- /dev/null +++ b/src/styles/box-plot.ts @@ -0,0 +1,542 @@ +import { css, html, nothing, svg } from 'lit'; +import type { SVGTemplateResult, TemplateResult } from 'lit'; +import { baseColor } from '../bands'; +import { axisOptions, timeLabel, timeZoneOf, validateAxisOptions, valueAxis, hour12Of } from '../chart/axes'; +import { MAX_OUTLIERS, WHISKERS, boxStats, heldSamples, jitter, rowSamples, spread } from '../chart/box'; +import type { BoxStats, Sample, Whiskers } from '../chart/box'; +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, chartWidth, gridStyles, plotWidth, stopChartWidth } from '../chart/grid'; +import type { PlotAxes } from '../chart/grid'; +import { chartFrame, chartLayout, headerOver, layoutStyles, validateLayout } from '../chart/layout'; +import { linear } from '../chart/scale'; +import { dotsPath } from '../chart/series-style'; +import { tooltipEnabled, tooltipStyles, validateTooltipOptions } from '../chart/tooltip'; +import { pickHandlers, pickStyles, pickTooltip, pickedIndex, stopPick, xDecimals } from '../chart/xy'; +import type { PickRow } from '../chart/xy'; +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 { key } from '../history/fetch'; +import type { StatisticsQuery } from '../history/fetch'; +import { statisticsPeriod } from '../history/fetch'; +import { parseGrouping } from '../history/group'; +import type { 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 { Range, Source } from '../history/types'; +import { localize, registerStrings } from '../localize'; +import type { CardConfig } from '../types'; +import type { ChartContext, ChartEditorContext, ChartStyleModule } from './index'; + +export const ORIENTATIONS = ['vertical', 'horizontal'] as const; +export type Orientation = (typeof ORIENTATIONS)[number]; + +/** Readings drawn per box when `readings: true`; more are thinned evenly. */ +const MAX_READINGS = 120; +/** Widest a box gets, in px, however few there are. */ +const MAX_BOX_PX = 44; +/** Share of a category's room the boxes take together; the rest is the gap between categories. */ +const SLOT = 0.72; + +registerStrings('en', { + editor_style_box: 'Box plot', + box_aria: 'Box plot of {names}', + box_highest: 'Highest', + box_upper_quartile: 'Upper quartile', + box_median: 'Median', + box_lower_quartile: 'Lower quartile', + box_lowest: 'Lowest', + box_mean: 'Average', + box_count: 'Readings', + box_outliers: 'Outliers', + editor_orientation: 'Boxes', + editor_orientation_vertical: 'Upright', + editor_orientation_horizontal: 'On their side', + 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_box_color: 'Box colour', +}); + +type BoxRaw = CardConfig & { orientation?: unknown; whiskers?: unknown; readings?: unknown }; + +/** One box: where it sits (category, and its slot among the series there) and what it shows. */ +export interface Box { + category: number; + slot: number; + series: ChartSeries; + stats: BoxStats; + /** The values drawn as dots when the card shows each reading (thinned). */ + readings: number[]; +} + +export interface BoxFrame extends HistoryFrame { + /** Group boundaries when grouped (each start, then the last end); else empty. */ + bounds: number[]; + unit?: GroupUnit; + until: number; +} + +/** + * Load the card's readings as they were recorded: never grouped into one value per group (a box + * needs every reading in it). Grouped, the range starts where its first group does. Statistics + * come in hours (five minutes for hourly boxes) so each box has rows to spread. + */ +export function boxFrame(ctx: ChartContext, now = Date.now()): BoxFrame { + const period = parsePeriod(ctx.raw.period as CardConfig['period']); + 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 group = parseGrouping(ctx.raw); + 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 statistics: StatisticsQuery | undefined = + source === 'statistics' + ? { + period: group ? (group.unit === 'hour' ? '5minute' : 'hour') : statisticsPeriod(range), + types: ['mean', 'min', 'max'], + value: 'mean', + } + : undefined; + const loaded = storeFor(ctx.host, ctx.requestUpdate).get(ctx.hass, requests, range, source, { + live: span.live, + statistics, + }); + const series = ctx.items.map((entity, index): ChartSeries => { + const own = entity.config as typeof entity.config & { color?: string }; + return { + index, + entity, + points: loaded.data.get(key(requests[index])) ?? [], + color: baseColor(own.color ?? SERIES_COLORS[index % SERIES_COLORS.length]), + curve: 'straight', + dash: false, + }; + }); + return { + status: loaded.status, + error: loaded.error, + range, + period, + source, + series, + live: span.live, + back: span.back, + zone, + bounds: group ? boundaries(group.unit, range.start, range.end, zone) : [], + unit: group?.unit, + until: Math.min(range.end, now), + }; +} + +/** Every box to draw: one per series per group, or one per series when not grouped. */ +export function boxesOf(frame: BoxFrame, whiskers: Whiskers): { boxes: Box[]; categories: number; slots: number } { + const grouped = frame.bounds.length > 1; + const spans: [number, number][] = grouped + ? frame.bounds.slice(0, -1).map((start, i) => [start, frame.bounds[i + 1]]) + : [[frame.range.start, frame.range.end]]; + const boxes: Box[] = []; + spans.forEach(([start, end], g) => { + const stop = Math.min(end, frame.until); + frame.series.forEach((s, j) => { + let samples: Sample[]; + let extremes: [number, number] | undefined; + if (frame.source === 'statistics') ({ samples, extremes } = rowSamples(s.points, start, stop)); + else samples = heldSamples(s.points, start, stop); + const stats = boxStats(samples, whiskers, extremes); + if (!stats) return; + boxes.push({ + category: grouped ? g : j, + slot: grouped ? j : 0, + series: s, + stats, + readings: spread( + samples.map((x) => x.v), + MAX_READINGS, + ), + }); + }); + }); + return { boxes, categories: grouped ? spans.length : frame.series.length, slots: grouped ? frame.series.length : 1 }; +} + +/** Keep every `step`-th category label so they fit `room` labels. */ +export function categoryTicks(count: number, room: number): number[] { + const step = Math.max(1, Math.ceil(count / Math.max(1, room))); + const out: number[] = []; + for (let i = 0; i < count; i += step) out.push(i); + return out; +} + +/** + * Axes with the categories along one side and values along the other. Upright boxes put the + * categories along the bottom (as `times`); boxes on their side put them up the side (as value + * ticks, first at the top). Gridlines follow the value labels whichever way the chart is turned: + * "lines across" draws at each value label, "lines down" at each category. + */ +export function boxAxes( + ctx: ChartContext, + orientation: Orientation, + categories: number, + data: [number, number] | undefined, + labelPx: number, + sidePx = 0, +): PlotAxes { + const options = axisOptions(ctx.raw); + const fixed = { min: options.min, max: options.max }; + const cat: [number, number] = [-0.5, categories - 0.5]; + if (orientation === 'vertical') { + const width = plotWidth(ctx); + const value = valueAxis(data, fixed, 4); + return { + options, + value, + times: categoryTicks(categories, Math.floor(width / labelPx)), + unit: 'day', + range: { start: cat[0], end: cat[1] }, + scales: { x: linear(cat, [0, VIEW_W]), y: linear(value.domain, [VIEW_H, 0]), domain: value.domain }, + }; + } + // The category names take a column of their own beside the plot. + const width = chartWidth(ctx) - 32 - (options.y && options.labels === 'outside' ? sidePx : 0); + const count = Math.max(2, Math.min(8, Math.round(width / 70))); + const x = valueAxis(data, fixed, count); + const rows = ctx.raw.compact === true ? 6 : 10; + return { + options: { ...options, horizontal: options.vertical, vertical: options.horizontal }, + value: { domain: cat, ticks: categoryTicks(categories, rows), decimals: 0 }, + times: x.ticks, + unit: 'day', + range: { start: x.domain[0], end: x.domain[1] }, + scales: { x: linear(x.domain, [0, VIEW_W]), y: linear(cat, [0, VIEW_H]), domain: cat }, + }; +} + +const formatters = new Map(); + +/** A group's name for its tooltip: "Tue 29 Oct, 14:00", "Tue 29 Oct", "October 2026". */ +function groupTitle(t: number, unit: GroupUnit, hass: ChartContext['hass']): string { + const opts: Intl.DateTimeFormatOptions = + unit === 'month' + ? { month: 'long', year: 'numeric' } + : unit === 'hour' + ? { + weekday: 'short', + day: 'numeric', + month: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: hour12Of(hass), + } + : { weekday: unit === 'day' ? 'short' : undefined, day: 'numeric', month: 'short' }; + const language = hass?.locale?.language ?? 'en'; + const k = `${language}|${unit}|${timeZoneOf(hass)}|${opts.hour12}`; + let f = formatters.get(k); + if (!f) { + try { + f = new Intl.DateTimeFormat(language, { ...opts, timeZone: timeZoneOf(hass) }); + } catch { + f = new Intl.DateTimeFormat('en', opts); + } + formatters.set(k, f); + } + return f.format(t); +} + +/** A group's short name under its box: as the time axis would label it. */ +function groupLabel(frame: BoxFrame, i: number, hass: ChartContext['hass']): string { + const t = frame.bounds[i]; + const span = frame.range.end - frame.range.start; + if (frame.unit === 'hour') return timeLabel(t, 'hour', span, hass); + if (frame.unit === 'month') return timeLabel(t, 'month', span, hass); + return timeLabel(t, 'day', frame.unit === 'week' ? Infinity : span, hass); +} + +const shorten = (s: string, n = 16) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); + +/** Where a box's centre and half-width sit along the category axis, in category units. */ +export function boxPlace(box: Pick, slots: number, maxHalf: number) { + const width = SLOT / slots; + return { centre: box.category - SLOT / 2 + width * (box.slot + 0.5), half: Math.min(width * 0.4, maxHalf) }; +} + +/** + * Which box the pointer is on, from its position along the category axis (a fraction): the + * category under it, then the series' slot within. Undefined in a gap or where there's no box. + */ +export function pickBox(boxes: readonly Box[], categories: number, slots: number, along: number): number | undefined { + const at = along * categories - 0.5; + const category = Math.min(categories - 1, Math.max(0, Math.round(at))); + const within = at - category + SLOT / 2; + if (slots > 1 && (within < 0 || within >= SLOT)) return undefined; + const slot = Math.min(slots - 1, Math.max(0, Math.floor(within / (SLOT / slots)))); + const i = boxes.findIndex((b) => b.category === category && b.slot === slot); + return i < 0 ? undefined : i; +} + +function drawBox( + box: Box, + place: { centre: number; half: number }, + at: (c: number, v: number) => [number, number], + showReadings: boolean, +): SVGTemplateResult { + const { stats, series } = box; + const { centre: c, half } = place; + const line = (a: [number, number], b: [number, number], cls: string) => + svg``; + const [x0, y0] = at(c - half, stats.q1); + const [x1, y1] = at(c + half, stats.q3); + const readings = + showReadings && box.readings.length + ? svg` at(c + jitter(k) * half * 0.8, v)))}>` + : nothing; + return svg` + ${line(at(c, stats.low), at(c, stats.q1), 'whisker')} + ${line(at(c, stats.q3), at(c, stats.high), 'whisker')} + ${line(at(c - half / 2, stats.low), at(c + half / 2, stats.low), 'cap')} + ${line(at(c - half / 2, stats.high), at(c + half / 2, stats.high), 'cap')} + + ${readings} + ${line(at(c - half, stats.median), at(c + half, stats.median), 'median')} + ${stats.outliers.length ? svg` at(c, v)))}>` : nothing} + `; +} + +function validate(raw: CardConfig): void { + validateHistoryOptions(raw); + validateAxisOptions(raw); + validateTooltipOptions(raw); + validateLayout(raw); + const r = raw as BoxRaw; + if (r.orientation !== undefined && !ORIENTATIONS.includes(r.orientation as Orientation)) { + throw new Error(`"orientation" must be one of: ${ORIENTATIONS.join(', ')}`); + } + if (r.whiskers !== undefined && !WHISKERS.includes(r.whiskers as Whiskers)) { + throw new Error(`"whiskers" must be one of: ${WHISKERS.join(', ')}`); + } + if (r.readings !== undefined && typeof r.readings !== 'boolean') throw new Error('"readings" must be true or false'); +} + +const BOX_FIELDS: FieldBinding[] = [ + ...HISTORY_FIELDS, + { name: 'orientation', path: ['orientation'], default: 'vertical' }, + { name: 'whiskers', path: ['whiskers'], default: 'tukey' }, + { name: 'readings', path: ['readings'], default: false }, + ...AXES_FIELDS, + ...LAYOUT_FIELDS, +]; + +/** The Chart section: period and source, one box per group or per entity, the boxes' look, axes, layout. */ +export function boxEditor(ctx: ChartEditorContext): TemplateResult { + // Boxes need every reading, not one combined value per group: no "combine with" choice. + const ungrouped = { ...ctx.config, group_by: undefined }; + const schema = cachedSchema(`box|${historyVariant(ungrouped)}`, ctx.hass, () => [ + ...historySchema(ctx.hass, ungrouped), + { + type: 'grid', + name: '', + schema: [ + { name: 'orientation', selector: choice(ctx.hass, 'orientation', ORIENTATIONS) }, + { name: 'whiskers', selector: choice(ctx.hass, 'whiskers', WHISKERS) }, + ], + }, + { name: 'readings', selector: { boolean: {} } }, + ...axesSchema(ctx.hass), + ...layoutSchema(ctx.hass), + ]); + return chartForm(ctx, 'chart', schema, BOX_FIELDS); +} + +const ENTITY_SCHEMA = [{ name: 'box_color', selector: { text: {} } }]; +const ENTITY_FIELDS: FieldBinding[] = [{ name: 'box_color', path: ['color'] }]; + +export const box: ChartStyleModule = { + layout: 'chart', + group: 'distribution', + height: 240, + styles: css` + ${frameStyles} + ${gridStyles} + ${tooltipStyles} + ${layoutStyles} + ${pickStyles} + .style-box .box line { + stroke: var(--box-color); + stroke-width: 1.5; + } + .style-box .box .median { + stroke-width: 3; + } + .style-box .box .body { + fill: color-mix(in srgb, var(--box-color) 22%, transparent); + stroke: var(--box-color); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + } + .style-box .box .outliers { + fill: none; + stroke: var(--box-color); + stroke-width: 6px; + stroke-linecap: round; + } + .style-box .box .readings { + fill: none; + stroke: var(--box-color); + stroke-width: 4px; + stroke-linecap: round; + opacity: 0.45; + } + `, + thumbnail: () => svg` + + + + + + + + + `, + validate, + editor: boxEditor, + entityFields: () => ({ schema: ENTITY_SCHEMA, fields: ENTITY_FIELDS }), + disconnect(host) { + disconnectHistory(host); + stopChartWidth(host); + stopPick(host); + }, + render(ctx) { + const frame = boxFrame(ctx); + const nav = periodNav(frame, ctx); + const names = seriesHeader(frame, ctx); + const header = nav === nothing && names === nothing ? nothing : html`${nav}${names}`; + const layout = chartLayout(ctx.raw); + const message = frameMessage(frame, ctx); + if (message) return chartFrame(layout.header === 'over' ? { ...layout, header: 'above' } : layout, header, message); + const raw = ctx.raw as BoxRaw; + const orientation = (raw.orientation as Orientation | undefined) ?? 'vertical'; + const whiskers = (raw.whiskers as Whiskers | undefined) ?? 'tukey'; + const { boxes, categories, slots } = boxesOf(frame, whiskers); + let lo = Infinity; + let hi = -Infinity; + for (const b of boxes) { + lo = Math.min(lo, b.stats.low, ...b.stats.outliers, ...(raw.readings === true ? b.readings : [])); + hi = Math.max(hi, b.stats.high, ...b.stats.outliers, ...(raw.readings === true ? b.readings : [])); + } + const grouped = frame.bounds.length > 1; + const vertical = orientation === 'vertical'; + // Every entity is named; under upright boxes, cut to the room each one has (about 6.5 px a letter). + const room = vertical ? Math.max(4, Math.floor(plotWidth(ctx) / Math.max(1, categories) / 6.5)) : 16; + const categoryName = (i: number) => + grouped ? groupLabel(frame, i, ctx.hass) : shorten(frame.series[i]?.entity.name ?? '', room); + const longest = Math.max(0, ...Array.from({ length: categories }, (_, i) => categoryName(i).length)); + const axes = boxAxes( + ctx, + orientation, + categories, + boxes.length ? [lo, hi] : undefined, + grouped ? 56 : 1, + longest * 6 + 12, + ); + const cs = vertical ? axes.scales.x : axes.scales.y; + const vs = vertical ? axes.scales.y : axes.scales.x; + const at = (c: number, v: number): [number, number] => (vertical ? [cs(c), vs(v)] : [vs(v), cs(c)]); + // Along the category axis, how many category units make MAX_BOX_PX / 2. + const alongPx = vertical ? plotWidth(ctx) : layout.compact ? 110 : 160; + const maxHalf = alongPx > 0 ? (MAX_BOX_PX / 2) * (categories / alongPx) : Infinity; + const content = svg`${boxes.map((b) => drawBox(b, boxPlace(b, slots, maxHalf), at, raw.readings === true))}`; + const label = localize(ctx.hass, 'box_aria', { names: frame.series.map((s) => s.entity.name).join(', ') }); + const tooltip = tooltipEnabled(ctx.raw); + const picked = tooltip ? pickedIndex(ctx.host) : undefined; + const fmt = (b: Box, v: number) => + withUnit(formatNumber(v, b.series.entity.decimals, ctx.hass), b.series.entity.unit); + let tip: TemplateResult | typeof nothing = nothing; + const box = picked === undefined ? undefined : boxes[picked]; + if (box) { + const t = (k: string) => localize(ctx.hass, k); + const { stats } = box; + const rows: PickRow[] = [ + { name: t('box_highest'), value: fmt(box, stats.max) }, + { name: t('box_upper_quartile'), value: fmt(box, stats.q3) }, + { name: t('box_median'), value: fmt(box, stats.median), color: box.series.color }, + { name: t('box_lower_quartile'), value: fmt(box, stats.q1) }, + { name: t('box_lowest'), value: fmt(box, stats.min) }, + { name: t('box_mean'), value: fmt(box, stats.mean) }, + { name: t('box_count'), value: String(stats.count) }, + ]; + if (stats.outliers.length) { + const n = stats.outliers.length; + rows.push({ name: t('box_outliers'), value: n >= MAX_OUTLIERS ? `${n}+` : String(n) }); + } + const place = boxPlace(box, slots, maxHalf); + const title = grouped + ? `${groupTitle(frame.bounds[box.category], frame.unit!, ctx.hass)}${slots > 1 ? ` · ${box.series.entity.name}` : ''}` + : box.series.entity.name; + tip = vertical + ? pickTooltip({ + x: cs(place.centre) / VIEW_W, + y: vs(stats.median) / VIEW_H, + plotWidth: plotWidth(ctx), + title, + rows, + band: [cs(box.category - 0.5) / VIEW_W, cs(box.category + 0.5) / VIEW_W], + }) + : pickTooltip({ + x: vs(stats.high) / VIEW_W, + y: cs(place.centre) / VIEW_H, + plotWidth: plotWidth(ctx), + title, + rows, + }); + } + return chartFrame( + layout, + header, + chartArea({ + axes, + ctx, + content, + label, + overlay: html`${layout.header === 'over' ? headerOver(header) : nothing}${tip}`, + hover: tooltip + ? pickHandlers(ctx, (fx, fy) => pickBox(boxes, categories, slots, vertical ? fx : fy)) + : undefined, + ...(vertical + ? { xLabel: categoryName } + : { yLabel: categoryName, xLabel: (v: number) => formatNumber(v, xDecimals(axes), ctx.hass) }), + }), + ); + }, +}; diff --git a/src/styles/index.ts b/src/styles/index.ts index a9039cf..6e0645d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4,6 +4,7 @@ import type { ResolvedEntity } from '../resolve'; import type { CardConfig, HomeAssistant, NormalizedConfig, ShowOptions, StyleName } from '../types'; import { area } from './area'; import { bar } from './bar'; +import { box } from './box-plot'; import { bubble } from './bubble'; import { columnChart } from './column'; import { combined } from './combined'; @@ -128,4 +129,5 @@ export const STYLES: Record = { scatter, bubble, heatmap, + box, }; diff --git a/src/types.ts b/src/types.ts index 34c87e8..bb7e564 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,7 @@ 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'; + | 'pie' | 'rings' | 'pictograph' | 'waffle' | 'histogram' | 'scatter' | 'bubble' | 'heatmap' | 'box'; export const STYLE_NAMES: StyleName[] = [ 'bar', 'thin', @@ -22,6 +22,7 @@ export const STYLE_NAMES: StyleName[] = [ 'scatter', 'bubble', 'heatmap', + 'box', ]; export type SortMode = 'none' | 'ascending' | 'descending'; /** A number, or a string naming an entity whose state supplies the number. */ diff --git a/test/box-plot.test.ts b/test/box-plot.test.ts new file mode 100644 index 0000000..753952d --- /dev/null +++ b/test/box-plot.test.ts @@ -0,0 +1,318 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import '../src/chartdeck-card'; +import { boxStats, heldSamples, jitter, rowSamples, spread, weightedQuantile, MAX_OUTLIERS } from '../src/chart/box'; +import { boxPlace, categoryTicks, pickBox } from '../src/styles/box-plot'; +import type { Box } from '../src/styles/box-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'); + +const eq = (vs: number[]) => vs.map((v) => ({ v, w: 1 })); + +describe('box maths', () => { + it('takes textbook quartiles from equal weights', () => { + const s = eq([1, 2, 3, 4]); + expect(weightedQuantile(s, 0.5)).toBe(2.5); + expect(weightedQuantile(s, 0)).toBe(1); + expect(weightedQuantile(s, 1)).toBe(4); + expect(weightedQuantile(eq([7]), 0.25)).toBe(7); + }); + + it('weights history by how long each value held', () => { + // 20 for 9 hours, a flicker to 30 for ten minutes in all, then 20 again. + const H = 3_600_000; + const pts = [ + { t: 0, v: 20 }, + { t: 9 * H, v: 30 }, + { t: 9 * H + 600_000, v: null }, + { t: 9.5 * H, v: 20 }, + ]; + const samples = heldSamples(pts, 0, 10 * H); + expect(samples).toEqual([ + { v: 20, w: 9 * H }, + { v: 30, w: 600_000 }, + { v: 20, w: 0.5 * H }, + ]); + const stats = boxStats(samples, 'tukey')!; + expect(stats.median).toBe(20); + expect(stats.count).toBe(3); + // Only the part inside the window counts; nothing before the first reading. + expect(heldSamples(pts, 8 * H, 9 * H)).toEqual([{ v: 20, w: H }]); + expect(heldSamples(pts, 5, 5)).toEqual([]); + expect(heldSamples([{ t: 10, v: 1 }], 0, 5)).toEqual([]); + }); + + it('counts each statistics row once and widens the range with their extremes', () => { + const rows = [ + { t: 0, v: 5, min: 1, max: 6, carried: true }, + { t: 10, v: 10, min: 8, max: 12 }, + { t: 20, v: 12, min: null, max: 20 }, + { t: 30, v: null }, + { t: 40, v: 11 }, + ]; + const { samples, extremes } = rowSamples(rows, 0, 40); + expect(samples).toEqual(eq([10, 12])); + expect(extremes).toEqual([8, 20]); + expect(rowSamples(rows, 50, 60)).toEqual({ samples: [], extremes: undefined }); + const range = boxStats(samples, 'range', extremes)!; + expect([range.low, range.high, range.outliers]).toEqual([8, 20, []]); + }); + + it('draws Tukey whiskers to the last reading within 1.5 × the box, the rest outliers', () => { + const stats = boxStats(eq([1, 2, 3, 4, 5, 6, 7, 8, 30, -20]), 'tukey')!; + expect(stats.low).toBe(1); + expect(stats.high).toBe(8); + expect(stats.outliers).toEqual([-20, 30]); + expect(stats.min).toBe(-20); + expect(stats.max).toBe(30); + expect(stats.mean).toBeCloseTo(4.6); + expect(boxStats([], 'tukey')).toBeUndefined(); + expect(boxStats([{ v: 3, w: 0 }], 'range')).toBeUndefined(); + }); + + it('keeps only the furthest outliers when there are very many', () => { + const many = [...eq(Array.from({ length: 400 }, () => 0)), ...eq(Array.from({ length: 100 }, (_, i) => 100 + i))]; + const stats = boxStats(many, 'tukey')!; + expect(stats.outliers).toHaveLength(MAX_OUTLIERS); + expect(stats.outliers.at(-1)).toBe(199); + expect(stats.outliers[0]).toBe(140); + }); + + it('thins readings evenly and jitters them the same way every time', () => { + expect(spread([1, 2, 3], 5)).toEqual([1, 2, 3]); + expect(spread([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 5)).toEqual([0, 2, 4, 6, 8]); + expect(jitter(3)).toBe(jitter(3)); + for (let i = 0; i < 50; i++) { + expect(jitter(i)).toBeGreaterThanOrEqual(-1); + expect(jitter(i)).toBeLessThan(1); + } + }); + + it('places boxes side by side and picks the one under the pointer', () => { + expect(categoryTicks(10, 4)).toEqual([0, 3, 6, 9]); + expect(categoryTicks(3, 0)).toEqual([0]); + const a = boxPlace({ category: 1, slot: 0 }, 2, Infinity); + const b = boxPlace({ category: 1, slot: 1 }, 2, Infinity); + expect(a.centre).toBeLessThan(1); + expect(b.centre).toBeGreaterThan(1); + expect(boxPlace({ category: 0, slot: 0 }, 1, 0.1).half).toBe(0.1); + const boxes = [ + { category: 0, slot: 0 }, + { category: 1, slot: 0 }, + { category: 1, slot: 1 }, + ] as Box[]; + // Two categories: the second spans 0.5–1 of the axis, its slots either side of 0.75. + expect(pickBox(boxes, 2, 2, 0.7)).toBe(1); + expect(pickBox(boxes, 2, 2, 0.8)).toBe(2); + expect(pickBox(boxes, 2, 2, 0.26)).toBeUndefined(); + expect(pickBox(boxes, 2, 2, 0.51)).toBeUndefined(); + expect(pickBox(boxes, 2, 1, 0.2)).toBe(0); + }); +}); + +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; + +function hassWith(history: Record, statistics: Record = {}) { + const hass = makeHass({ + 'sensor.in': ['21', { friendly_name: 'Inside', unit_of_measurement: '°C' }], + 'sensor.out': ['9', { friendly_name: 'Outside', unit_of_measurement: '°C' }], + 'sensor.off': ['unavailable', { friendly_name: 'Off' }], + }); + hass.callWS = vi.fn(async (msg: Record) => + msg.type === 'recorder/statistics_during_period' ? statistics : history, + ) as HomeAssistant['callWS']; + return hass; +} + +const history = { + 'sensor.in': [ + { s: '19', lu: sec(20 * H) }, + { s: '20', lu: sec(15 * H) }, + { s: '22', lu: sec(10 * H) }, + { s: '40', lu: sec(9 * H) }, + { s: '21', lu: sec(8.9 * H) }, + { s: '23', lu: sec(4 * H) }, + ], + 'sensor.out': [ + { s: '5', lu: sec(20 * H) }, + { s: '9', lu: sec(4 * H) }, + ], + 'sensor.off': [{ 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: 'box', + entities: ['sensor.in', 'sensor.out'], + ...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)]; + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('box plot', () => { + it('draws one upright box per entity, named along the bottom, with the outlier as a dot', async () => { + const card = await mount({ entities: ['sensor.in', 'sensor.out', 'sensor.off', 'sensor.gone'] }); + expect(qa(card, 'g.box')).toHaveLength(2); + expect(qa(card, '.x-axis .axis-label').map((e) => e.textContent!.trim())).toEqual([ + 'Inside', + 'Outside', + 'Off', + 'sensor.gone', + ]); + // 40 °C held for six minutes is beyond the whisker. + expect(qa(card, 'g.box')[0].querySelector('.outliers')).not.toBeNull(); + expect(q(card, 'svg.plot')!.getAttribute('aria-label')).toBe('Box plot of Inside, Outside, Off, sensor.gone'); + expect(q(card, '.content.style-box')).not.toBeNull(); + }); + + it('groups by day into side-by-side boxes, shows readings, and turns on its side', async () => { + const card = await mount({ + group_by: 'day', + period: { last: 2, unit: 'day' }, + readings: true, + } as Partial); + const boxes = qa(card, 'g.box'); + expect(boxes.length).toBeGreaterThanOrEqual(2); + expect(new Set(boxes.map((b) => b.getAttribute('data-series')))).toEqual(new Set(['0', '1'])); + expect(qa(card, '.readings').length).toBe(boxes.length); + const side = await mount({ orientation: 'horizontal', whiskers: 'range' } as Partial); + expect(qa(side, '.y-axis .axis-label').map((e) => e.textContent!.trim())).toEqual(['Inside', 'Outside']); + expect(qa(side, '.x-axis .axis-label').every((e) => /^-?[\d.,]+$/.test(e.textContent!.trim()))).toBe(true); + expect(qa(side, '.outliers')).toHaveLength(0); + }); + + it('reads statistics rows in hours when grouped', async () => { + const t0 = Math.floor((now - 30 * H) / H) * H; + const rows = Array.from({ length: 30 }, (_, i) => ({ + start: t0 + i * H, + end: t0 + (i + 1) * H, + mean: 10 + (i % 5), + min: 5, + max: 20, + })); + const hass = hassWith({}, { 'sensor.in': rows }); + const card = await mount( + { source: 'statistics', group_by: 'day', entities: ['sensor.in'] } as Partial, + hass, + ); + const call = (hass.callWS as ReturnType).mock.calls[0][0]; + expect(call.period).toBe('hour'); + expect(call.types).toEqual(['mean', 'min', 'max']); + expect(qa(card, 'g.box').length).toBeGreaterThanOrEqual(1); + }); + + it('shows the picked box in a tooltip', async () => { + const card = await mount({ group_by: 'day', period: { last: 1, unit: 'day' } } as Partial); + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + // Find a spot with a box: the last day's first slot sits just left of its centre. + const ticks = qa(card, '.x-axis .axis-label').length; + let tip: Element | null = null; + for (let x = 1; x < 400 && !tip; x += 4) { + area.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: 50, pointerType: 'mouse' })); + await card.updateComplete; + tip = q(card, '.tooltip'); + } + expect(ticks).toBeGreaterThan(0); + expect(tip).not.toBeNull(); + const names = qa(card, '.tooltip-name').map((e) => e.textContent); + expect(names).toEqual(['Highest', 'Upper quartile', 'Median', 'Lower quartile', 'Lowest', 'Average', 'Readings']); + expect(q(card, '.pick-band')).not.toBeNull(); + area.dispatchEvent(new PointerEvent('pointerleave', { pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip')).toBeNull(); + }); + + it('shows a tooltip on its side, and none when turned off', async () => { + const card = await mount({ orientation: 'horizontal' } as Partial); + const area = q(card, '.plot-area') as HTMLElement; + area.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 180 }) as DOMRect; + area.dispatchEvent(new PointerEvent('pointermove', { clientX: 100, clientY: 40, pointerType: 'mouse' })); + await card.updateComplete; + expect(q(card, '.tooltip-time')!.textContent).toBe('Inside'); + expect(qa(card, '.tooltip-name')).toContainEqual(expect.objectContaining({ textContent: 'Outliers' })); + const off = await mount({ tooltip: false } as Partial); + expect(q(off, '.plot-area.hoverable')).toBeNull(); + }); + + it('says when there is nothing to draw', async () => { + const empty = await mount({}, hassWith({})); + 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: 'box', entities: ['sensor.in'], ...extra } as CardConfig); + expect(set({ orientation: 'sideways' })).toThrow('"orientation" must be one of: vertical, horizontal'); + expect(set({ whiskers: 'long' })).toThrow('"whiskers" must be one of: tukey, range'); + expect(set({ readings: 'yes' })).toThrow('"readings" must be true or false'); + expect(set({ group_by: 'year' })).toThrow(/group_by/); + expect(set({ orientation: 'horizontal', whiskers: 'range', readings: true })).not.toThrow(); + }); +}); + +describe('box plot editor', () => { + it('writes orientation, whiskers and readings, without a "combine with" choice', 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: 'box', group_by: 'day', 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 data = form.data as Record; + expect(data).toMatchObject({ orientation: 'vertical', whiskers: 'tukey', readings: false, group_by: 'day' }); + const names = JSON.stringify(form.schema); + expect(names).toContain('"orientation"'); + expect(names).not.toContain('"aggregate"'); + form.dispatchEvent( + new CustomEvent('value-changed', { + detail: { value: { ...data, orientation: 'horizontal', whiskers: 'range', readings: true } }, + }), + ); + expect(events.at(-1)).toEqual({ ...raw, orientation: 'horizontal', whiskers: 'range', readings: true }); + const list = ed.shadowRoot!.querySelector('cd-entities-editor') as Stub; + const fields = (list.chartFields as { fields: { name: string; path: string[] }[] }).fields; + expect(fields).toEqual([{ name: 'box_color', path: ['color'] }]); + }); +}); From 568d5d650842621e15a83e64c4e32a078f0ebbd7 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 14:15:35 +0000 Subject: [PATCH 6/7] test: the period card tests no longer fail between midnight and 1 am The mocked history returned a reading an hour after the period start, which is in the future just after midnight, so the line ran past now. The mock now never returns a reading later than now, like the recorder. --- test/period-card.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/period-card.test.ts b/test/period-card.test.ts index fb2c20b..811e6a4 100644 --- a/test/period-card.test.ts +++ b/test/period-card.test.ts @@ -32,10 +32,13 @@ function hassWith() { ); hass.callWS = vi.fn(async (msg: Record) => { const start = Date.parse(msg.start_time as string) / 1000; + // Like the recorder, never return a reading from the future (just after midnight the period's + // first hour hasn't finished yet). + const second = Math.min(start + 3600, Math.max(start + 1, Date.now() / 1000 - 60)); return { 'sensor.in': [ { s: '20', lu: start }, - { s: '22', lu: start + 3600 }, + { s: '22', lu: second }, ], }; }) as HomeAssistant['callWS']; From ae6505320392a96e7e753e845dfbf4d440305611 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Fri, 6 Nov 2026 14:15:35 +0000 Subject: [PATCH 7/7] chore: rebuild dist and format the style list --- dist/chartdeck-card.js | 1012 ++++++++++++++++++++++++++-------------- src/types.ts | 22 +- 2 files changed, 687 insertions(+), 347 deletions(-) diff --git a/dist/chartdeck-card.js b/dist/chartdeck-card.js index 15162ee..aa9682a 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 i=(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)},r=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,p=globalThis,m=p.trustedTypes,f=m?m.emptyScript:"",g=p.reactiveElementPolyfillSupport,y=(e,t)=>e,_={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}},v=(e,t)=>!s(e,t),b={attribute:!0,type:String,converter:_,reflect:!1,useDefault:!1,hasChanged:v}; +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:_}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */Symbol.metadata??=Symbol("metadata"),p.litPropertyMetadata??=new WeakMap;let $=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 i=o?.call(this);a?.call(this,t),this.requestUpdate(e,i,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(r(e))}else void 0!==e&&t.push(r(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:_).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:_;this._$Em=o;const i=a.fromAttribute(t,e.type);this[o]=i??this._$Ej?.get(o)??i,this._$Em=null}}requestUpdate(e,t,n,o=!1,a){if(void 0!==e){const i=this.constructor;if(!1===o&&(a=this[e]),n??=i.getPropertyOptions(e),!((n.hasChanged??v)(a,t)||n.useDefault&&n.reflect&&a===this._$Ej?.get(e)&&!this.hasAttribute(i._$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},i){n&&!(this._$Ej??=new Map).has(e)&&(this._$Ej.set(e,i??t??this[e]),!0!==a||void 0!==i)||(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){}};$.elementStyles=[],$.shadowRootOptions={mode:"open"},$[y("elementProperties")]=new Map,$[y("finalized")]=new Map,g?.({ReactiveElement:$}),(p.reactiveElementVersions??=[]).push("2.1.2"); + */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"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const x=globalThis,w=e=>e,k=x.trustedTypes,M=k?k.createPolicy("lit-html",{createHTML:e=>e}):void 0,A="$lit$",E=`lit$${Math.random().toFixed(9).slice(2)}$`,S="?"+E,C=`<${S}>`,O=document,L=()=>O.createComment(""),z=e=>null===e||"object"!=typeof e&&"function"!=typeof e,T=Array.isArray,N="[ \t\n\f\r]",j=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,P=/-->/g,U=/>/g,F=RegExp(`>|${N}(?:([^\\s"'>=/]+)(${N}*=${N}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),H=/'/g,D=/"/g,B=/^(?:script|style|textarea|title)$/i,V=e=>(t,...n)=>({_$litType$:e,strings:t,values:n}),R=V(1),I=V(2),W=Symbol.for("lit-noChange"),Z=Symbol.for("lit-nothing"),q=new WeakMap,Y=O.createTreeWalker(O,129);function G(e,t){if(!T(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,i=0;const r=e.length-1,s=this.parts,[l,c]=((e,t)=>{const n=e.length-1,o=[];let a,i=2===t?"":3===t?"":"",r=j;for(let s=0;s"===l[0]?(r=a??j,c=-1):void 0===l[1]?c=-2:(c=r.lastIndex-l[2].length,n=l[1],r=void 0===l[3]?F:'"'===l[3]?D:H):r===D||r===H?r=F:r===P||r===U?r=j:(r=F,a=void 0);const h=r===F&&e[s+1].startsWith("/>")?" ":"";i+=r===j?t+C:c>=0?(o.push(n),t.slice(0,c)+A+t.slice(c)+E+h):t+E+(-2===c?s:h)}return[G(e,i+(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;nT(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!==Z&&z(this._$AH)?this._$AA.nextSibling.data=e:this.T(O.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=q.get(e.strings);return void 0===t&&q.set(e.strings,t=new X(e)),t}k(e){T(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=Z}_$AI(e,t=this,n,o){const a=this.strings;let i=!1;if(void 0===a)e=J(this,e,t,0),i=!z(e)||e!==this._$AH&&e!==W,i&&(this._$AH=e);else{const o=e;let r,s;for(e=a[0],r=0;re,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;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 W}};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,n)=>{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"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const de=1,he=2,ue=3,pe=4,me=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,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)}}; /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */const{I:ge}=ie,ye=e=>e,_e=()=>document.createComment(""),ve=(e,t,n)=>{const o=e._$AA.parentNode,a=void 0===t?e._$AB:t._$AA;if(void 0===n){const t=o.insertBefore(_e(),a),i=o.insertBefore(_e(),a);n=new ge(t,i,e,e.options)}else{const t=n._$AB.nextSibling,i=n._$AM,r=i!==e;if(r){let t;n._$AQ?.(e),n._$AM=e,void 0!==n._$AP&&(t=e._$AU)!==i._$AU&&n._$AP(t)}if(t!==a||r){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),$e={},xe=(e,t=$e)=>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=me(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=[],i=[];let r=0;for(const s of e)a[r]=o?o(s,r):r,i[r]=n(s,r),r++;return{values:i,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:i,keys:r}=this.dt(t,n,o);if(!Array.isArray(a))return this.ut=r,i;const s=this.ut??=[],l=[];let c,d,h=0,u=a.length-1,p=0,m=i.length-1;for(;h<=u&&p<=m;)if(null===a[h])h++;else if(null===a[u])u--;else if(s[h]===r[p])l[p]=be(a[h],i[p]),h++,p++;else if(s[u]===r[m])l[m]=be(a[u],i[m]),u--,m--;else if(s[h]===r[m])l[m]=be(a[h],i[m]),ve(e,l[m+1],a[h]),h++,m--;else if(s[u]===r[p])l[p]=be(a[u],i[p]),ve(e,a[h],a[u]),u--,p++;else if(void 0===c&&(c=ke(r,p,m),d=ke(s,h,u)),c.has(s[h]))if(c.has(s[u])){const t=d.get(r[p]),n=void 0!==t?a[t]:null;if(null===n){const t=ve(e,a[h]);be(t,i[p]),l[p]=t}else l[p]=be(n,i[p]),ve(e,a[h],n),a[t]=null;p++}else we(a[u]),u--;else we(a[h]),h++;for(;p<=m;){const t=ve(e,l[m+1]);be(t,i[p]),l[p++]=t}for(;h<=u;){const e=a[h++];null!==e&&we(e)}return this.ut=r,xe(e,l),W}}),Ae=e=>!!e&&"none"!==e.action; + */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; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Ee(e){return Ae(e.tap_action)||Ae(e.hold_action)||Ae(e.double_tap_action)}function Se(e,t){let n,o,a=0,i=0,r=!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||(r=!0,s=!1,a=o.clientX,i=o.clientY,c(),Ae(t().hold_action)&&(n=setTimeout(()=>{n=void 0,s=!0,l("hold")},500)))},h=e=>{if(!r)return;const t=e;Math.hypot(t.clientX-a,t.clientY-i)>10&&(c(),r=!1)},u=()=>{c(),r=!1},p=()=>{const e=r;c(),r=!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},m=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",p),e.addEventListener("pointercancel",u),e.addEventListener("pointerleave",u),e.addEventListener("keydown",m),e.addEventListener("contextmenu",f),()=>{c(),void 0!==o&&clearTimeout(o),o=void 0,e.removeEventListener("pointerdown",d),e.removeEventListener("pointermove",h),e.removeEventListener("pointerup",p),e.removeEventListener("pointercancel",u),e.removeEventListener("pointerleave",u),e.removeEventListener("keydown",m),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 Oe(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 ze(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 Te(e,t){let n=t[0];for(const o of t){if(!(o.from<=e))break;n=o}return n}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},je=["label","area","floor","device_class","domain","entity"];function Pe(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?je.filter(e=>"entity"!==e):je,o={};for(const[a,i]of Object.entries(e)){if(!n.includes(a))throw new Error(`"${t}" can't match on "${a}". Use: ${n.join(", ")}`);const e=(Array.isArray(i)?i:[i]).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 Ue(e){const t=e.state;return"unavailable"===t||"unknown"===t||""!==t.trim()&&Number.isFinite(Number(t))}function Fe(e,t){const n=e.entity_id,o=t.entities?.[n],a=o?.device_id?t.devices?.[o.device_id]:void 0,i=o?.area_id??a?.area_id??void 0,r=e.attributes?.device_class;return{label:[...o?.labels??[],...a?.labels??[]],area:i,floor:i?t.areas?.[i]?.floor_id??void 0:void 0,device_class:"string"==typeof r?r: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 De(e,t){return Object.keys(e).every(n=>He(e,n,t))}function Be(e,t){return!!e&&Object.keys(e).some(n=>He(e,n,t))}const Ve=new WeakMap;const Re=["bar","thin","segmented","tank","ring","needle","arc","line","column","area","combined","pie","rings","pictograph","waffle"],Ie=[{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 qe(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 Ye(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 Ge(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=qe(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 Xe(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 Je(e,t){return void 0!==e?e:t}function Ke(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=Je(qe(o.min,"min"),qe(t.min,"min"))??0,i=Je(qe(o.max,"max"),qe(t.max,"max"))??100;if("number"==typeof a&&"number"==typeof i&&a>=i)throw new Error(`"max" (${i}) must be greater than "min" (${a})`);const r={...o,entity:o.entity,min:a,max:i,severity:Ge(o.severity)??Ge(t.severity)??Ie,tap_action:Xe(Je(o.tap_action,t.tap_action),"more-info","tap_action"),hold_action:Xe(Je(o.hold_action,t.hold_action),"none","hold_action"),double_tap_action:Xe(Je(o.double_tap_action,t.double_tap_action),"none","double_tap_action")},s=Je(o.name,t.name),l=Je(o.icon,t.icon),c=Je(o.unit,t.unit),d=Je(qe(o.target,"target"),qe(t.target,"target")),h=Je(Ye(o.decimals,"decimals",0,20),Ye(t.decimals,"decimals",0,20));return void 0!==s&&(r.name=s),void 0!==l&&(r.icon=l),void 0!==c&&(r.unit=c),void 0!==d&&(r.target=d),void 0!==h&&(r.decimals=h),o.attribute||delete r.attribute,r} + */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} /** * @license * Copyright 2020 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de&&e.type!==pe)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===W||t===Z)return t;const n=e.element,o=e.name;if(e.type===ue){if(t===n[o])return W}else if(e.type===pe){if(!!t===n.hasAttribute(o))return W}else if(e.type===de&&n.getAttribute(o)===t+"")return W;return xe(e),t}}),et={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 tt(e,t){et[e]={...et[e],...t}}function nt(e){return(e?.locale?.language??e?.language??"en").split("-")[0]}function ot(e,t,n){const o=nt(e),a=et[o]?.[t]??et.en[t]??t;return n?a.replace(/\{(\w+)\}/g,(e,t)=>t in n?String(n[t]):e):a}function at(e){return null==e||""===e}function it(e){return"object"==typeof e&&null!==e&&!Array.isArray(e)}function rt(e,t,n){const[o,...a]=t,i=function(e,t){return Array.isArray(e)?[...e]:it(e)?{...e}:"number"==typeof t?[]:{}}(e,o),r=i,s=a.length?rt(r[o],a,n):n,l=a.length>0&&it(s)&&0===Object.keys(s).length;return at(s)?Array.isArray(i)&&"number"==typeof o?i.splice(o,1):delete r[o]:l&&it(i)?delete r[o]:r[o]=s,i}function st(e,t,n){return 0===t.length||at(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:rt(e,t,n)}function lt(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 ct(e,t){return[...e.filter(e=>!t(e)),...e.filter(t)]}function dt(e){return e.map(([e,t])=>`M${Math.round(100*e)/100},${Math.round(100*t)/100}h0`).join("")}function ht(e){return Math.round(10*(e+3.5))/10}function ut(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 mt(e){ut(e.line_width,'"line_width"'),pt(e.dots,'"dots"');for(const t of e.entities??[])"object"==typeof t&&(ut(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 ft=[{name:"title",path:["title"]}],gt=[{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}],yt=[{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"]}],vt=[{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"}}],bt=[{name:"name",path:["name"]},{name:"unit",path:["unit"]},{name:"decimals",path:["decimals"]}],$t=[{name:"attribute",path:["attribute"]}],xt=[{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}],wt={number:{min:.5,max:12,step:.5,mode:"box",unit_of_measurement:"px"}},kt=new Map;function Mt(e){let t=kt.get(e);return t||(t=[...xt,{name:"dots",path:["dots"],default:e}],kt.set(e,t)),t}const At=vt.map(({name:e,path:t})=>({name:e,path:t})),Et=[{name:"title",selector:{text:{}}}],St=vt.map(({name:e})=>({name:e,selector:{ui_action:{}}})),Ct=[{type:"grid",name:"",schema:[{name:"name",selector:{text:{}}},{name:"unit",selector:{text:{}}},{name:"decimals",selector:{number:{min:0,max:20,mode:"box"}}}]}],Ot=new Map;let Lt;function zt(e){const t=nt(e);if(Lt?.language!==t){const n=t=>ot(e,t);Lt={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:wt},{name:"dash",selector:{boolean:{}}},{name:"dots",selector:{boolean:{}}},{name:"on_top",selector:{boolean:{}}}]}]}}return Lt.schema}function Tt(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:ot(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 jt(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 Pt=new Map;function Ut(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=Pt.get(e);if(a?.key===o)return a.schema;const i=Nt[e].map(t=>({name:`${e}_${t}`,selector:jt(t,n)}));return Pt.set(e,{key:o,schema:i}),i}let Ft;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 Dt(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=>Dt(e[n],t[n]))}function Bt(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 Vt(e,t,n){const o=Bt(e,t);let a=e;for(const i of t){const e=n[i.name];Dt(o[i.name],e)||(a=st(a,i.path,void 0!==i.default&&Dt(e,i.default)?void 0:e))}return a}const Rt="var(--divider-color, rgba(127, 127, 127, 0.3))",It="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 qt(e,t,n,o){return Zt(e,t,n,o).join(" ")}function Yt(e,t,n,o,a){const i=a-o>180?1:0;return`M ${qt(e,t,n,o)} A ${n} ${n} 0 ${i} 1 ${qt(e,t,n,a)}`}function Gt(e){return R`
- - ${r} + ${i} ${0!==a?R``:Z} -
`}const To=i` + `:W} + `}const Fo=r` .period-nav { display: flex; align-items: center; @@ -142,16 +142,16 @@ const Qe=me(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 No(e){!function(e){_o.get(e)?.pause()}(e)}function jo(e,t){const{show:n}=t.config;return n.name||n.value?R`
+`;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`
${e.series.map(e=>R`
- ${n.name?R`${e.entity.name}`:Z} - ${n.value?R`${e.entity.display}`:Z} + ${n.name?R`${e.entity.name}`:W} + ${n.value?R`${e.entity.display}`:W}
`)} -
`:Z}function Po(e,t){return"loading"===e.status?R`
${ot(t.hass,"chart_loading")}
`:"error"===e.status?R`
- ${ot(t.hass,"chart_error",{error:e.error??""})} -
`:e.series.every(e=>e.points.every(e=>null===e.v))?R`
${ot(t.hass,"chart_no_data")}
`:void 0}const Uo=i` - ${To} +
`: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} .series-header { display: flex; flex-wrap: wrap; @@ -239,50 +239,50 @@ const Qe=me(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 Fo(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),i=new Intl.NumberFormat(o,{minimumFractionDigits:t,maximumFractionDigits:t,useGrouping:a});let r=i.format(0===e?0:e);return 0===Number(e.toFixed(t))&&(r=i.format(0)),r}function Ho(e,t){return t?`${e} ${t}`:e}const Do=new WeakMap;function Bo(e){Do.get(e)?.observer?.disconnect(),Do.delete(e)}function Vo(e){const t=sn(e.raw);return Math.max(0,function(e){let t=Do.get(e.host);if(!t){const n={width:e.host.clientWidth||400};t=n,Do.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}(e)-32-(t.y&&"outside"===t.labels?40:0))}function Ro(e,t,n){const o=sn(e.raw),a=dn(n,o,4),i=Math.max(2,Math.min(8,Math.floor(Vo(e)/70))),{unit:r,ticks:s}=vn(t,i,gn(bn(e.hass)));return{options:o,value:a,times:s,unit:r,range:t,scales:{x:Jt([t.start,t.end],[0,ko]),y:Jt(a.domain,[Mo,0]),domain:a.domain}}}const Io=e=>Math.round(1e4*e)/100+"%";function Wo({axes:e,ctx:t,content:n,label:o,defs:a=Z,overlay:i=Z,hover:r,right:s}){const{options:l}=e,c="outside"===l.labels,d=l.y&&e.value.ticks.length>0,h=l.x&&e.times.length>0,u=d?function(e,t){const{scales:n,value:o}=e;return o.ticks.map(e=>{const a=n.y(e)/Mo;return R`.92?"bottom":""}" style="top:${Io(a)}" - >${Fo(e,o.decimals,t.hass)}`})}(e,t):[],p=d?R`
${u}
`:Z,m=h?R`
${function(e,t){const{scales:n,range:o}=e,a=gn(bn(t.hass));return e.times.map(i=>{const r=n.x(i)/ko;return R`.95?"end":""}" style="left:${Io(r)}" - >${e.label?e.label(i):An(i,e.unit,o.end-o.start,t.hass,a)}`})}(e,t)}
`:Z,f=d?R``:Z,g=s&&l.y&&s.value.ticks.length>0?s:void 0,y=g?R``:Z,_=g?R`
${c?y:Z}${function(e,t){return e.value.ticks.map(n=>{const o=e.y(n)/Mo;return R`.92?"bottom":""}" style="top:${Io(o)}" - >${Fo(n,e.value.decimals,t.hass)}`})}(g,t)}
`:Z;return R`
{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`
- ${c&&d?R`
${f}${u}
`:Z} + ${h&&u?R`
${y}${p}
`:W}
- ${function(e,t,n=Z,o=Z){return R` - + ${n} ${o} ${e} - `}(n,o,a,function(e){const{options:t,scales:n}=e;return I` - ${t.horizontal?e.value.ticks.map(e=>I``):Z} - ${t.vertical?e.times.map(e=>I``):Z} - `}(e))} ${c?Z:R`${p}${_}${m}`} - ${i} + `}(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}`} + ${r}
- ${c?R`${_}${m}`:Z} -
`}const Zo=i` + ${h?R`${b}${g}`:W} +
`}const Jo=r` .plot-wrap { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -392,9 +392,9 @@ const Qe=me(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); } -`,qo=["above","over","hidden"];function Yo(e){if(void 0!==e.compact&&"boolean"!=typeof e.compact)throw new Error('"compact" must be true or false');if(void 0!==e.header&&!qo.includes(e.header))throw new Error(`"header" must be one of: ${qo.join(", ")}`)}function Go(e){return{compact:!0===e.compact,header:e.header??"above"}}function Xo(e,t,n){return R`
- ${"above"===e.header?t:Z}${n} -
`}function Jo(e){return e===Z?Z:R`
e.stopPropagation()}>${e}
`}const Ko=i` +`,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` .header-over { position: absolute; top: 2px; @@ -447,26 +447,26 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .frame.compact .axes-outside .x-axis .axis-label { top: 3px; } -`,Qo=["none","normal","percent"];function ea(e){const t=e.stack;if(void 0!==t&&!Qo.includes(t))throw new Error(`"stack" must be one of: ${Qo.join(", ")}`)}function ta(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 na(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 i=0,r=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([i,i+l]),i+=l):(o[t].push([r,r+l]),r+=l)})}return o}function oa(e){const t=e[0]?.length??0,n=[];for(let o=0;o>1;e[a].t0&&t-e[n-1].t<=e[n].t-t?n-1:n}const ra=new WeakMap;function sa(e){return ra.get(e)?.t}function la(e){const t=ra.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ra.delete(e)}function ca(e){return!1!==e.tooltip}function da(e){const t=e.tooltip;if(void 0!==t&&"boolean"!=typeof t)throw new Error('"tooltip" must be true or false')}function ha(e,t,n){const o=function(e){let t=ra.get(e);return t||(t={},ra.set(e,t)),t}(e.host),a=t=>{t!==o.t&&(o.t=t,e.requestUpdate())},i=()=>{void 0!==o.timer&&clearTimeout(o.timer),o.timer=void 0};return{move(e){i();const o=e.currentTarget.getBoundingClientRect(),r=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=ia(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 ua=e=>Math.round(1e4*e)/100+"%";function pa(e){const t=new Set(e.map(e=>e.entity.unit));return 1===t.size?[...t][0]:""}function ma(e,t,n,o){return R`
+`,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`
- ${ot(e.hass,"chart_total")} - ${null===t?"—":Ho(Fo(t,o,e.hass),n)} -
`}function fa(e,t,n,o,a,i={}){if(void 0===o)return Z;const r=n.x(o)/ko;if(!(r>=0&&r<=1))return Z;const s=function(e,t){const n=e*t;return n+190<=t?"right":n>=190?"left":e>.5?"pin-left":"pin-right"}(r,a),l=t.map(e=>{const t=ia(e.points,o);return{s:e,p:t<0?void 0:e.points[t]}});return R``}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``}nt("en",{chart_total:"Total"});const $a=r` .plot-area.hoverable { /* A finger dragging sideways scrubs the chart; up and down still scrolls the page. */ touch-action: pan-y; @@ -554,29 +554,29 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .tooltip-total .swatch { background: none; } -`;function ya(e,t,n){return{select:{mode:"dropdown",options:n.map(n=>({value:n,label:ot(e,`editor_${t}_${n}`)}))}}}tt("en",{editor_period_calendar:"Period",editor_period_calendar_rolling:"The last…",editor_period_calendar_day:"Today",editor_period_calendar_week:"This week",editor_period_calendar_month:"This month",editor_period_calendar_year:"This year",editor_period_offset:"Periods back (0 is the current one)",editor_period_until:"Draw up to",editor_period_until_now:"Now",editor_period_until_end:"The end of the period",editor_period_navigate:"Show previous and next buttons",editor_group_by:"Group readings by",editor_group_by_none:"Don't group",editor_group_by_hour:"Hour",editor_group_by_day:"Day",editor_group_by_week:"Week",editor_group_by_month:"Month",editor_aggregate:"Combine with",editor_aggregate_mean:"Average",editor_aggregate_sum:"Sum",editor_aggregate_min:"Lowest",editor_aggregate_max:"Highest",editor_aggregate_first:"First",editor_aggregate_last:"Last",editor_aggregate_change:"Change (meters that count up)"});const _a=[{name:"period_calendar",path:["period","calendar"],default:"rolling"},{name:"period_last",path:["period","last"],default:24},{name:"period_unit",path:["period","unit"],default:"hour"},{name:"period_offset",path:["period","offset"],default:0},{name:"period_until",path:["period","until"],default:"now"},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"source",path:["source"],default:"history"},{name:"group_by",path:["group_by"],default:"none"},{name:"aggregate",path:["aggregate"],default:"mean"}];function va(e){const t=e?.period;return`${t?.calendar?"calendar":"rolling"}${e?.group_by?"|grouped":""}`}function ba(e,t){const[n,o]=va(t).split("|"),a="calendar"===n;return[{type:"grid",name:"",schema:[{name:"period_calendar",selector:ya(e,"period_calendar",["rolling",...Vn])},...a?[{name:"period_until",selector:ya(e,"period_until",Rn)}]:[{name:"period_last",selector:{number:{min:1,mode:"box"}}},{name:"period_unit",selector:ya(e,"period_unit",Bn)}],{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"source",selector:ya(e,"source",Eo)},{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e,"group_by",["none",...so])},...o?[{name:"aggregate",selector:ya(e,"aggregate",lo)}]:[]]}]}tt("en",{editor_x_axis:"Time labels",editor_y_axis:"Value labels",editor_axis_labels:"Axis labels",editor_axis_labels_outside:"Beside the chart",editor_axis_labels_inside:"Over the chart",editor_grid_horizontal:"Lines across",editor_grid_vertical:"Lines down",editor_y_min:"Lowest value shown",editor_y_max:"Highest value shown",editor_tooltip:"Show values on hover or touch",editor_line_width:"Line width (px)",editor_dots:"Dot at each reading",editor_on_top:"Draw above the other lines",editor_compact:"Compact",editor_header:"Names and values",editor_header_above:"Above the chart",editor_header_over:"Over the chart",editor_header_hidden:"Hidden"});const $a=[{name:"header",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];function xa(e){return[{type:"grid",name:"",schema:[{name:"header",selector:ya(e,"header",qo)},{name:"compact",selector:{boolean:{}}}]}]}const wa=[{name:"x_axis",path:["x_axis"],default:Qt},{name:"y_axis",path:["y_axis"],default:en},{name:"axis_labels",path:["axis_labels"],default:tn},{name:"grid_horizontal",path:["gridlines","horizontal"],default:nn},{name:"grid_vertical",path:["gridlines","vertical"],default:on},{name:"y_min",path:["y_min"]},{name:"y_max",path:["y_max"]},{name:"tooltip",path:["tooltip"],default:!0}];function ka(e){return[{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:ya(e,"axis_labels",Kt)},{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:{}}}]}const Ma=new Map;function Aa(e,t,n){const o=`${e}|${nt(t)}`;let a=Ma.get(o);return a||(a=n(),Ma.set(o,a)),a}function Ea(e,t,n,o){return R`({value:n,label:at(e,`editor_${t}_${n}`)}))}}}nt("en",{editor_period_calendar:"Period",editor_period_calendar_rolling:"The last…",editor_period_calendar_day:"Today",editor_period_calendar_week:"This week",editor_period_calendar_month:"This month",editor_period_calendar_year:"This year",editor_period_offset:"Periods back (0 is the current one)",editor_period_until:"Draw up to",editor_period_until_now:"Now",editor_period_until_end:"The end of the period",editor_period_navigate:"Show previous and next buttons",editor_group_by:"Group readings by",editor_group_by_none:"Don't group",editor_group_by_hour:"Hour",editor_group_by_day:"Day",editor_group_by_week:"Week",editor_group_by_month:"Month",editor_aggregate:"Combine with",editor_aggregate_mean:"Average",editor_aggregate_sum:"Sum",editor_aggregate_min:"Lowest",editor_aggregate_max:"Highest",editor_aggregate_first:"First",editor_aggregate_last:"Last",editor_aggregate_change:"Change (meters that count up)"});const ka=[{name:"period_calendar",path:["period","calendar"],default:"rolling"},{name:"period_last",path:["period","last"],default:24},{name:"period_unit",path:["period","unit"],default:"hour"},{name:"period_offset",path:["period","offset"],default:0},{name:"period_until",path:["period","until"],default:"now"},{name:"period_navigate",path:["period","navigate"],default:!1},{name:"source",path:["source"],default:"history"},{name:"group_by",path:["group_by"],default:"none"},{name:"aggregate",path:["aggregate"],default:"mean"}];function Ma(e){const t=e?.period;return`${t?.calendar?"calendar":"rolling"}${e?.group_by?"|grouped":""}`}function Aa(e,t){const[n,o]=Ma(t).split("|"),a="calendar"===n;return[{type:"grid",name:"",schema:[{name:"period_calendar",selector:wa(e,"period_calendar",["rolling",...qn])},...a?[{name:"period_until",selector:wa(e,"period_until",Wn)}]:[{name:"period_last",selector:{number:{min:1,mode:"box"}}},{name:"period_unit",selector:wa(e,"period_unit",Vn)}],{name:"period_offset",selector:{number:{min:0,mode:"box"}}}]},{name:"period_navigate",selector:{boolean:{}}},{name:"source",selector:wa(e,"source",Lo)},{type:"grid",name:"",schema:[{name:"group_by",selector:wa(e,"group_by",["none",...ho])},...o?[{name:"aggregate",selector:wa(e,"aggregate",uo)}]:[]]}]}nt("en",{editor_x_axis:"Time labels",editor_y_axis:"Value labels",editor_axis_labels:"Axis labels",editor_axis_labels_outside:"Beside the chart",editor_axis_labels_inside:"Over the chart",editor_grid_horizontal:"Lines across",editor_grid_vertical:"Lines down",editor_y_min:"Lowest value shown",editor_y_max:"Highest value shown",editor_tooltip:"Show values on hover or touch",editor_line_width:"Line width (px)",editor_dots:"Dot at each reading",editor_on_top:"Draw above the other lines",editor_compact:"Compact",editor_header:"Names and values",editor_header_above:"Above the chart",editor_header_over:"Over the chart",editor_header_hidden:"Hidden"});const Sa=[{name:"header",path:["header"],default:"above"},{name:"compact",path:["compact"],default:!1}];function Ea(e){return[{type:"grid",name:"",schema:[{name:"header",selector:wa(e,"header",Ko)},{name:"compact",selector:{boolean:{}}}]}]}const Ca=[{name:"x_axis",path:["x_axis"],default:en},{name:"y_axis",path:["y_axis"],default:tn},{name:"axis_labels",path:["axis_labels"],default:nn},{name:"grid_horizontal",path:["gridlines","horizontal"],default:on},{name:"grid_vertical",path:["gridlines","vertical"],default:an},{name:"y_min",path:["y_min"]},{name:"y_max",path:["y_max"]},{name:"tooltip",path:["tooltip"],default:!0}];function za(e){return[{type:"grid",name:"",schema:[{name:"x_axis",selector:{boolean:{}}},{name:"y_axis",selector:{boolean:{}}},{name:"grid_horizontal",selector:{boolean:{}}},{name:"grid_vertical",selector:{boolean:{}}}]},{name:"axis_labels",selector:wa(e,"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:{}}}]}const La=new Map;function Ta(e,t,n){const o=`${e}|${ot(t)}`;let a=La.get(o);return a||(a=n(),La.set(o,a)),a}function ja(e,t,n,o){return R`ot(e.hass,`editor_${t.name}`)} - @value-changed=${t=>{t.stopPropagation();const n=Vt(e.config,o,t.detail?.value??{});n!==e.config&&e.commit(n)}} - >`}const Sa=[..._a,{name:"curve",path:["curve"],default:"smooth"},{name:"color_by",path:["color_by"],default:"series"},{name:"labels",path:["labels"],default:!1},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},...wa,...$a];function Ca(e,t){if(!e)return t;const[n,o]=e,a={};return void 0===t.y_min&&n>=0&&n<=o/2&&(a.y_min=0),void 0===t.y_max&&o<=0&&o>=n/2&&(a.y_max=0),Object.keys(a).length?{...t,...a}:t}function Oa(e){const t=[];return e.forEach((n,o)=>{o>0&&t.push([n[0],e[o-1][1]]),t.push(n)}),t}function La(e,t,n){if(e.length<2)return"";const[o,a,i]="step"===n?[Oa(e),Oa(t),"straight"]:[e,t,n];return`${xo(o,i)}${xo([...a].reverse(),i).replace(/^M/,"L")}Z`}function za(e,t){return"step"===t?xo(Oa(e),"straight"):xo(e,t)}function Ta(e,t,n,o,a={}){const i=lt(e.entity.config,o),r=e.entity.config.line_width??o.line_width,s=n.y(Math.min(Math.max(0,n.domain[0]),n.domain[1])),l=function(e){const t=e.entity.config.negative_color;return"string"==typeof t&&t.trim()?Oe(t.trim()):e.color}(e),c=`cd-area-${e.index}`,d=`cd-area-line-${e.index}`,h=l!==e.color,u=I`${function(e,t,n,o,a=.42,i=.05){const r=Math.min(1,Math.max(0,o/Mo));return I` + .computeLabel=${t=>at(e.hass,`editor_${t.name}`)} + @value-changed=${t=>{t.stopPropagation();const n=Rt(e.config,o,t.detail?.value??{});n!==e.config&&e.commit(n)}} + >`}const Oa=[...ka,{name:"curve",path:["curve"],default:"smooth"},{name:"color_by",path:["color_by"],default:"series"},{name:"labels",path:["labels"],default:!1},{name:"line_width",path:["line_width"],default:2.5},{name:"dots",path:["dots"],default:!1},...Ca,...Sa];function Na(e,t){if(!e)return t;const[n,o]=e,a={};return void 0===t.y_min&&n>=0&&n<=o/2&&(a.y_min=0),void 0===t.y_max&&o<=0&&o>=n/2&&(a.y_max=0),Object.keys(a).length?{...t,...a}:t}function Ua(e){const t=[];return e.forEach((n,o)=>{o>0&&t.push([n[0],e[o-1][1]]),t.push(n)}),t}function Fa(e,t,n){if(e.length<2)return"";const[o,a,r]="step"===n?[Ua(e),Ua(t),"straight"]:[e,t,n];return`${Ao(o,r)}${Ao([...a].reverse(),r).replace(/^M/,"L")}Z`}function Pa(e,t){return"step"===t?Ao(Ua(e),"straight"):Ao(e,t)}function Da(e,t,n,o,a={}){const r=ct(e.entity.config,o),i=e.entity.config.line_width??o.line_width,s=n.y(Math.min(Math.max(0,n.domain[0]),n.domain[1])),l=function(e){const t=e.entity.config.negative_color;return"string"==typeof t&&t.trim()?ze(t.trim()):e.color}(e),c=`cd-area-${e.index}`,d=`cd-area-line-${e.index}`,h=l!==e.color,u=V`${function(e,t,n,o,a=.42,r=.05){const i=Math.min(1,Math.max(0,o/Co));return V` - - + + `}(c,e.color,l,s,a.stacked?.6:.42,a.stacked?.35:.05)} - ${h?function(e,t,n,o){const a=Math.min(1,Math.max(0,o/Mo));return I` + ${h?function(e,t,n,o){const a=Math.min(1,Math.max(0,o/Co));return V` - `}(d,e.color,l,s):Z}`,p=[],m=[],f=[];if(a.layers){const o=a.layers;let i=[];const r=()=>{if(i.length){const a=i.map(e=>[n.x(t[e].t),n.y(o[e][1])]),r=i.map(e=>[n.x(t[e].t),n.y(o[e][0])]);p.push(La(a,r,e.curve)),m.push(za(a,e.curve))}i=[]};for(let e=0;e[n.x(e.t),n.y(e.v)]);p.push(La(t,t.map(([e])=>[e,s]),e.curve)),m.push(za(t,e.curve))}if(i.dots){const e=a.range;t.forEach((t,o)=>{if(null===t.v||e&&(t.t===e.start||t.t===e.end))return;const i=a.layers?a.layers[o]?.[1]:t.v;void 0!==i&&f.push([n.x(t.t),n.y(i)])})}const g=r??2,y=h?`url(#${d})`:e.color;return{defs:u,body:I` - - `}(d,e.color,l,s):W}`,m=[],p=[],f=[];if(a.layers){const o=a.layers;let r=[];const i=()=>{if(r.length){const a=r.map(e=>[n.x(t[e].t),n.y(o[e][1])]),i=r.map(e=>[n.x(t[e].t),n.y(o[e][0])]);m.push(Fa(a,i,e.curve)),p.push(Pa(a,e.curve))}r=[]};for(let e=0;e[n.x(e.t),n.y(e.v)]);m.push(Fa(t,t.map(([e])=>[e,s]),e.curve)),p.push(Pa(t,e.curve))}if(r.dots){const e=a.range;t.forEach((t,o)=>{if(null===t.v||e&&(t.t===e.start||t.t===e.end))return;const r=a.layers?a.layers[o]?.[1]:t.v;void 0!==r&&f.push([n.x(t.t),n.y(r)])})}const g=i??2,y=h?`url(#${d})`:e.color;return{defs:u,body:V` + + - ${f.length>0&&f.length<=200?I``:Z} - `}}tt("en",{editor_style_area:"Area",editor_stack:"Stack",editor_stack_none:"Don't stack",editor_stack_normal:"On top of each other",editor_stack_percent:"To 100 %",editor_negative_color:"Colour below zero"});const Na=[..._a,{name:"curve",path:["curve"],default:"smooth"},{name:"stack",path:["stack"],default:"none"},{name:"line_width",path:["line_width"],default:2},{name:"dots",path:["dots"],default:!1},...wa,...$a];const ja={name:"negative_color",path:["negative_color"]},Pa=new Map;const Ua=i` + ${f.length>0&&f.length<=200?V``:W} + `}}nt("en",{editor_style_area:"Area",editor_stack:"Stack",editor_stack_none:"Don't stack",editor_stack_normal:"On top of each other",editor_stack_percent:"To 100 %",editor_negative_color:"Colour below zero"});const Ha=[...ka,{name:"curve",path:["curve"],default:"smooth"},{name:"stack",path:["stack"],default:"none"},{name:"line_width",path:["line_width"],default:2},{name:"dots",path:["dots"],default:!1},...Ca,...Sa];const Ba={name:"negative_color",path:["negative_color"]},Ia=new Map;const Ra=r` .plot .fill { vector-effect: none; } @@ -591,13 +591,13 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .plot .series .line.dashed { stroke-dasharray: 6 5; } -`,Fa={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${Ua} - `,thumbnail:()=>I` +`,Va={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${Ra} + `,thumbnail:()=>V` @@ -606,8 +606,8 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de `,validate(e){So(e),rn(e),da(e),mt(e),Yo(e),ea(e),function(e){for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t.negative_color;if(void 0!==e&&"string"!=typeof e)throw new Error(`"negative_color" of ${t.entity} must be a colour`)}}(e)},editor:function(e){const t=Aa(`area|${va(e.config)}`,e.hass,()=>[...ba(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:ya(e.hass,"curve",vo)},{name:"stack",selector:ya(e.hass,"stack",Qo)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",t,Na)},entityFields:function(e,t){const n=!0===t.dots;let o=Pa.get(n);return o||(o=[...Mt(n),ja],Pa.set(n,o)),{schema:Aa("area-entity",e,()=>[...zt(e),{name:"negative_color",selector:{text:{}}}]),fields:o}},disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t=Co(e),n=zo(t,e),o=jo(t,e),a=n===Z&&o===Z?Z:R`${n}${o}`,i=Po(t,e),r=Go(e.raw);if(i)return Xo("over"===r.header?{...r,header:"above"}:r,a,i);const s=e.raw.stack??"none",l=t.series.map(e=>({...e,points:En(e.points,500)}));let c,d,h=l;const u={};if("none"===s){const n=Xt(l.map(e=>e.points));d=Ro({...e,raw:Ca(n,e.raw)},t.range,n);const o=d.scales,a=ct(l,t=>lt(t.entity.config,e.raw).onTop);c=a.map(n=>Ta(n,n.points,o,e.raw,{range:t.range}))}else{const n=function(e){const t=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t);return{times:t,values:e.map(e=>t.map(t=>ta(e,t)))}}(l.map(e=>e.points)),o=na(n.values,s),a=oa(n.values);h=l.map((e,t)=>({...e,points:n.times.map((e,o)=>({t:e,v:n.values[t][o]}))}));const i=aa(o),r="percent"===s?{y_min:i[0]<0?-100:0,y_max:i[1]>0?100:0}:{};d=Ro({...e,raw:Ca(i,{...r,...e.raw})},t.range,i);const p=d.scales;c=h.map((n,a)=>Ta(n,n.points,p,e.raw,{layers:o[a],stacked:!0,range:t.range})).reverse();const m=new Map;h.forEach(e=>e.points.forEach((e,t)=>m.set(e,t))),u.dotY=(e,t)=>{const n=o[e.index][m.get(t)??-1];return n?p.y(n[1]):null};const f=pa(h),g=Math.max(...h.map(e=>e.entity.decimals));u.footer=t=>{const n=t.length?m.get(t[0].p):void 0;return ma(e,void 0===n?null:a[n],f,g)},"percent"===s&&(u.text=(t,n)=>{const a=n?o[t.index][m.get(n)??-1]:null;if(!n||null===n.v||!a)return"—";const i=Math.round(Math.abs(a[1]-a[0]));return`${Ho(Fo(n.v,t.entity.decimals,e.hass),t.entity.unit)} · ${i} %`})}const p=ca(e.raw),m=ot(e.hass,"chart_aria",{style:ot(e.hass,"editor_style_area"),names:t.series.map(e=>e.entity.name).join(", ")});return Xo(r,a,Wo({axes:d,ctx:e,content:I`${c.map(e=>e.body)}`,defs:I`${c.map(e=>e.defs)}`,label:m,overlay:R`${"over"===r.header?Jo(a):Z} - ${p?fa(e,h,d.scales,sa(e.host),Vo(e),u):Z}`,hover:p?ha(e,t.range,h.map(e=>e.points)):void 0}))}};function Ha(e,t,n){return!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t?0:Math.min(1,Math.max(0,(e-t)/(n-t)))}function Da(e,t,n){if(!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t)return[0,0];const o=Ha(e,t,n);if(t<0&&n>0){const e=Ha(0,t,n);return o"number"==typeof e.from))return e;const n=[];for(const o of e){const e=t?Wa(o.from,t,"band start"):null;null!==e&&n.push({...o,from:e})}return n.length?n.sort((e,t)=>e.from-t.from):Ie}function qa(e,t){const n=e.entity,o=t.states[n],a=e.name??o?.attributes?.friendly_name??n,i=e.unit??(e.attribute?"":o?.attributes?.unit_of_measurement??""),r=function(e,t){if(t)return e.attribute?t.attributes?.[e.attribute]:t.state}(e,o),s=function(e){return"number"==typeof e?Number.isFinite(e)?e:null:"string"==typeof e?Ia(e):null}(r),l=null!==s;let c;c=Ra(e.decimals)?e.attribute?l?function(e){const t=String(e),n=t.indexOf(".");return-1===n||/e/i.test(t)?0:Math.min(2,t.length-n-1)}(r):0:function(e,t,n){const o=e?.entities?.[t]?.display_precision;return"number"==typeof o?o:"%"===n?0:1}(t,n,i):e.decimals;const d=Wa(e.min,t)??0,h=Wa(e.max,t)??100,u=Wa(e.target,t),p=Za(e.severity,t);let m,f="";return l?(f=Fo(s,c,t),m=Ho(f,i)):m=o?function(e,t,n,o){const a=e.attribute;return a&&"string"==typeof n&&""!==n.trim()?o.formatEntityAttributeValue?.(t,a)??n:o.formatEntityState?.(t)??ot(o,"unavailable")}(e,o,r,t):ot(t,"entity_not_found"),{entityId:n,config:e,stateObj:o,available:l,value:s,min:d,max:h,target:u,name:a,unit:i,decimals:c,position:l?Ha(s,d,h):0,span:l?Da(s,d,h):[0,0],targetPosition:null===u?null:Ha(u,d,h),band:Te(l?s:-1/0,p),bands:p,valueText:f,display:m,label:`${a}, ${m}`}}function Ya(e,t){if(!e.include)return[];const n=new Set(e.entities.map(e=>e.entity));return function(e,t,n){const o=Ve.get(e);if(o&&o.exclude===t&&o.states===n.states&&o.entities===n.entities&&o.devices===n.devices&&o.areas===n.areas)return o.ids;const a=[];for(const r of Object.values(n.states)){if(!Ue(r)||n.entities?.[r.entity_id]?.hidden)continue;const o=Fe(r,n);if(!De(e,o)||Be(t,o))continue;const i=r.attributes?.friendly_name;a.push({id:r.entity_id,name:"string"==typeof i&&i?i:r.entity_id})}a.sort((e,t)=>e.name.localeCompare(t.name)||(e.ide.id);return Ve.set(e,{exclude:t,states:n.states,entities:n.entities,devices:n.devices,areas:n.areas,ids:i}),i}(e.include,e.exclude,t).filter(e=>!n.has(e))}const Ga={layout:"rows",group:"bars",height:46,minColumnWidth:160,styles:i` + stroke="var(--primary-color, #03a9f4)" stroke-width="2.5" stroke-linecap="round" />`,validate(e){To(e),sn(e),ga(e),ft(e),Qo(e),ra(e),function(e){for(const t of e.entities??[]){if("object"!=typeof t)continue;const e=t.negative_color;if(void 0!==e&&"string"!=typeof e)throw new Error(`"negative_color" of ${t.entity} must be a colour`)}}(e)},editor:function(e){const t=Ta(`area|${Ma(e.config)}`,e.hass,()=>[...Aa(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:wa(e.hass,"curve",wo)},{name:"stack",selector:wa(e.hass,"stack",aa)}]},{type:"grid",name:"",schema:[{name:"line_width",selector:kt},{name:"dots",selector:{boolean:{}}}]},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",t,Ha)},entityFields:function(e,t){const n=!0===t.dots;let o=Ia.get(n);return o||(o=[...At(n),Ba],Ia.set(n,o)),{schema:Ta("area-entity",e,()=>[...jt(e),{name:"negative_color",selector:{text:{}}}]),fields:o}},disconnect(e){Po(e),Wo(e),pa(e)},render:function(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=e.raw.stack??"none",l=t.series.map(e=>({...e,points:Cn(e.points,500)}));let c,d,h=l;const u={};if("none"===s){const n=Jt(l.map(e=>e.points));d=Yo({...e,raw:Na(n,e.raw)},t.range,n);const o=d.scales,a=dt(l,t=>ct(t.entity.config,e.raw).onTop);c=a.map(n=>Da(n,n.points,o,e.raw,{range:t.range}))}else{const n=function(e){const t=[...new Set(e.flatMap(e=>e.map(e=>e.t)))].sort((e,t)=>e-t);return{times:t,values:e.map(e=>t.map(t=>ia(e,t)))}}(l.map(e=>e.points)),o=sa(n.values,s),a=la(n.values);h=l.map((e,t)=>({...e,points:n.times.map((e,o)=>({t:e,v:n.values[t][o]}))}));const r=ca(o),i="percent"===s?{y_min:r[0]<0?-100:0,y_max:r[1]>0?100:0}:{};d=Yo({...e,raw:Na(r,{...i,...e.raw})},t.range,r);const m=d.scales;c=h.map((n,a)=>Da(n,n.points,m,e.raw,{layers:o[a],stacked:!0,range:t.range})).reverse();const p=new Map;h.forEach(e=>e.points.forEach((e,t)=>p.set(e,t))),u.dotY=(e,t)=>{const n=o[e.index][p.get(t)??-1];return n?m.y(n[1]):null};const f=_a(h),g=Math.max(...h.map(e=>e.entity.decimals));u.footer=t=>{const n=t.length?p.get(t[0].p):void 0;return ba(e,void 0===n?null:a[n],f,g)},"percent"===s&&(u.text=(t,n)=>{const a=n?o[t.index][p.get(n)??-1]:null;if(!n||null===n.v||!a)return"—";const r=Math.round(Math.abs(a[1]-a[0]));return`${Ro(Io(n.v,t.entity.decimals,e.hass),t.entity.unit)} · ${r} %`})}const m=fa(e.raw),p=at(e.hass,"chart_aria",{style:at(e.hass,"editor_style_area"),names:t.series.map(e=>e.entity.name).join(", ")});return ta(i,a,Xo({axes:d,ctx:e,content:V`${c.map(e=>e.body)}`,defs:V`${c.map(e=>e.defs)}`,label:p,overlay:R`${"over"===i.header?na(a):W} + ${m?xa(e,h,d.scales,ma(e.host),Zo(e),u):W}`,hover:m?ya(e,t.range,h.map(e=>e.points)):void 0}))}};function qa(e,t,n){return!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t?0:Math.min(1,Math.max(0,(e-t)/(n-t)))}function Wa(e,t,n){if(!Number.isFinite(e)||!Number.isFinite(t)||!Number.isFinite(n)||n<=t)return[0,0];const o=qa(e,t,n);if(t<0&&n>0){const e=qa(0,t,n);return o"number"==typeof e.from))return e;const n=[];for(const o of e){const e=t?Ja(o.from,t,"band start"):null;null!==e&&n.push({...o,from:e})}return n.length?n.sort((e,t)=>e.from-t.from):qe}function Qa(e,t){const n=e.entity,o=t.states[n],a=e.name??o?.attributes?.friendly_name??n,r=e.unit??(e.attribute?"":o?.attributes?.unit_of_measurement??""),i=function(e,t){if(t)return e.attribute?t.attributes?.[e.attribute]:t.state}(e,o),s=function(e){return"number"==typeof e?Number.isFinite(e)?e:null:"string"==typeof e?Xa(e):null}(i),l=null!==s;let c;c=Ga(e.decimals)?e.attribute?l?function(e){const t=String(e),n=t.indexOf(".");return-1===n||/e/i.test(t)?0:Math.min(2,t.length-n-1)}(i):0:function(e,t,n){const o=e?.entities?.[t]?.display_precision;return"number"==typeof o?o:"%"===n?0:1}(t,n,r):e.decimals;const d=Ja(e.min,t)??0,h=Ja(e.max,t)??100,u=Ja(e.target,t),m=Ka(e.severity,t);let p,f="";return l?(f=Io(s,c,t),p=Ro(f,r)):p=o?function(e,t,n,o){const a=e.attribute;return a&&"string"==typeof n&&""!==n.trim()?o.formatEntityAttributeValue?.(t,a)??n:o.formatEntityState?.(t)??at(o,"unavailable")}(e,o,i,t):at(t,"entity_not_found"),{entityId:n,config:e,stateObj:o,available:l,value:s,min:d,max:h,target:u,name:a,unit:r,decimals:c,position:l?qa(s,d,h):0,span:l?Wa(s,d,h):[0,0],targetPosition:null===u?null:qa(u,d,h),band:je(l?s:-1/0,m),bands:m,valueText:f,display:p,label:`${a}, ${p}`}}function er(e,t){if(!e.include)return[];const n=new Set(e.entities.map(e=>e.entity));return function(e,t,n){const o=Re.get(e);if(o&&o.exclude===t&&o.states===n.states&&o.entities===n.entities&&o.devices===n.devices&&o.areas===n.areas)return o.ids;const a=[];for(const i of Object.values(n.states)){if(!Pe(i)||n.entities?.[i.entity_id]?.hidden)continue;const o=De(i,n);if(!Be(e,o)||Ie(t,o))continue;const r=i.attributes?.friendly_name;a.push({id:i.entity_id,name:"string"==typeof r&&r?r:i.entity_id})}a.sort((e,t)=>e.name.localeCompare(t.name)||(e.ide.id);return Re.set(e,{exclude:t,states:n.states,entities:n.entities,devices:n.devices,areas:n.areas,ids:r}),r}(e.include,e.exclude,t).filter(e=>!n.has(e))}const tr={layout:"rows",group:"bars",height:46,minColumnWidth:160,styles:r` .style-bar .entity { display: flex; align-items: center; @@ -689,31 +689,180 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de white-space: nowrap; margin-left: auto; } - `,render(e,{show:t,hass:n}){const o=t.value||!e.available,a=R`${t.name?R`${e.name}`:Z}${o?R`${e.display}`:Z}`;return R` + `,render(e,{show:t,hass:n}){const o=t.value||!e.available,a=R`${t.name?R`${e.name}`:W}${o?R`${e.display}`:W}`;return R` ${t.icon?R``:Z} + .icon=${Ya(e)} + >`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
${a}
- ${e.available?R``:Z} + ${e.available?R``:W}
- `}},Xa=["auto","none","hour","day","week","month"],Ja=["auto","mean","sum","min","max","first","last","change"],Ka={last:7,unit:"day"},Qa={hour:1/24,day:1,week:7,month:30};function ei(e){if(e.calendar)return{day:"hour",week:"day",month:"day",year:"month"}[e.calendar];const t=e.last*Qa[e.unit];return t<=2?"hour":t<=62?"day":t<=190?"week":"month"}function ti(e){if(e.config.attribute)return!1;const t=e.stateObj?.attributes?.state_class;return"total_increasing"===t||"total"===t}function ni(e,t){const n={...e,period:e.period??{...Ka}};return"none"===e.group_by?delete n.group_by:void 0!==e.group_by&&"auto"!==e.group_by||(n.group_by=ei(Yn(n.period))),void 0!==e.aggregate&&"auto"!==e.aggregate||(n.aggregate=!0!==e.range&&t.length>0&&t.every(ti)?"change":"mean"),n}function oi(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 r of e)for(const e of r)void 0!==e.end&&a.set(e.t,e.end);const i={times:[],ends:[],values:e.map(()=>[]),lows:e.map(()=>[]),highs:e.map(()=>[])};return n.forEach((r,s)=>{const l=Math.min(a.get(r)??n[s+1]??t,t);l>r&&(i.times.push(r),i.ends.push(l),e.forEach((e,t)=>{const n=o[t].get(r);i.values[t].push(n?n.v:ta(e,r)),i.lows[t].push(n?.min??null),i.highs[t].push(n?.max??null)}))}),i}function ai(e,t,n,o){const a=t-e,i=.8*a/o,r=o>1?.12*i:0;return[e+.2*a/2+n*i+r/2,i-r]}function ii(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 ri={hour:66,day:52,week:52,month:60};tt("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 si={hour:"hour",day:"day",week:"day",month:"month"};function li(e,t,n,o){if("hour"===n)return`${Mn(e,o)} – ${kn(t,o)}`;const a="day"===n?{weekday:"short",day:"numeric",month:"short"}:"week"===n?{day:"numeric",month:"short"}:{month:"long",year:"numeric"};let i;try{i=new Intl.DateTimeFormat(o?.locale?.language??"en",{...a,timeZone:bn(o)})}catch{i=new Intl.DateTimeFormat("en",a)}return"week"===n?i.formatRange(e,t-1):i.format(e)}const ci=(e,t)=>Math.round(e/t*1e4)/100+"%",di=e=>Math.min(Mo,Math.max(0,e));function hi(e,t,n,o,a,i,r,s){const l=di(e.y(a)),c=di(e.y(o));return R`
e+t.w,0);let o,a=0;for(const r of e){const e=(a+r.w/2)/n;if(a+=r.w,t<=e)return o?o.v+(r.v-o.v)*(t-o.at)/(e-o.at):r.v;o={at:e,v:r.v}}return e[e.length-1].v}const or=["tukey","range"];function ar(e,t){if(e.length<=t)return[...e];const n=[];for(let o=0;o"number"==typeof e&&Number.isFinite(e);function ir(e){for(const t of["x_min","x_max"])if(void 0!==e[t]&&!rr(e[t]))throw new Error(`"${t}" must be a number`);if(rr(e.x_min)&&rr(e.x_max)&&e.x_min>=e.x_max)throw new Error('"x_min" must be below "x_max"')}nt("en",{editor_x_min:"Lowest value across",editor_x_max:"Highest value across"});const sr=[{name:"x_min",path:["x_min"]},{name:"x_max",path:["x_max"]}],lr=[{type:"grid",name:"",schema:[{name:"x_min",selector:{number:{mode:"box",step:"any"}}},{name:"x_max",selector:{number:{mode:"box",step:"any"}}}]}];function cr(e){return{min:rr(e.x_min)?e.x_min:void 0,max:rr(e.x_max)?e.x_max:void 0}}function dr(e,t,n,o={}){const a=ln(e.raw),r=Math.max(2,Math.min(8,Math.round(qo(e)/70))),i=un(t,o.x??{},r),s=un(n,{min:a.min??o.yFloor,max:a.max},4);return{options:a,value:s,times:i.ticks,unit:"minute",range:{start:i.domain[0],end:i.domain[1]},scales:{x:Kt(i.domain,[0,Eo]),y:Kt(s.domain,[Co,0]),domain:s.domain}}}function hr(e){const[t,n]=e.times;return void 0===t||void 0===n?0:Math.max(0,-Math.floor(Math.log10(Math.abs(n-t))+1e-9))}const ur=new WeakMap;function mr(e){return ur.get(e)?.i}function pr(e){const t=ur.get(e);void 0!==t?.timer&&clearTimeout(t.timer),ur.delete(e)}function fr(e,t){const n=function(e){let t=ur.get(e);return t||(t={},ur.set(e,t)),t}(e.host),o=t=>{t!==n.i&&(n.i=t,e.requestUpdate())},a=()=>{void 0!==n.timer&&clearTimeout(n.timer),n.timer=void 0};return{move(e){a();const n=e.currentTarget.getBoundingClientRect(),r=n.width>0?Math.min(1,Math.max(0,(e.clientX-n.left)/n.width)):0,i=n.height>0?Math.min(1,Math.max(0,(e.clientY-n.top)/n.height)):0;o(t(r,i,n.width,n.height))},leave(e){a(),"touch"===e.pointerType||"pen"===e.pointerType?n.timer=setTimeout(()=>{n.timer=void 0,o(void 0)},3e3):o(void 0)}}}const gr=e=>Math.round(1e4*e)/100+"%";function yr(e){const{x:t,y:n,plotWidth:o,title:a,rows:r,dot:i,band:s}=e;if(!(t>=0&&t<=1))return W;const l=da(t,o);return R``}const vr=r` + .pick-band { + position: absolute; + top: 0; + bottom: 0; + background: color-mix(in srgb, var(--primary-text-color) 8%, transparent); + } +`,_r=["vertical","horizontal"],br=.72;function xr(e,t){const n=e.bounds.length>1,o=n?e.bounds.slice(0,-1).map((t,n)=>[t,e.bounds[n+1]]):[[e.range.start,e.range.end]],a=[];return o.forEach(([o,r],i)=>{const s=Math.min(r,e.until);e.series.forEach((r,l)=>{let c,d;"statistics"===e.source?({samples:c,extremes:d}=function(e,t,n){const o=[];let a=1/0,r=-1/0;for(const i of e)i.t=n||i.carried||null===i.v||(o.push({v:i.v,w:1}),a=Math.min(a,i.v,"number"==typeof i.min?i.min:i.v),r=Math.max(r,i.v,"number"==typeof i.max?i.max:i.v));return{samples:o,extremes:o.length?[a,r]:void 0}}(r.points,o,s)):c=function(e,t,n){const o=[];if(n<=t)return o;for(let a=0;a=n)break;const i=a+1e.w>0&&Number.isFinite(e.v)).sort((e,t)=>e.v-t.v);if(0===o.length)return;const a=nr(o,.25),r=nr(o,.5),i=nr(o,.75),s=o.reduce((e,t)=>e+t.w,0),l=o.reduce((e,t)=>e+t.v*t.w,0)/s,c=Math.min(o[0].v,n?.[0]??1/0),d=Math.max(o[o.length-1].v,n?.[1]??-1/0),h={count:o.length,q1:a,median:r,q3:i,mean:l,min:c,max:d};if("range"===t)return{...h,low:c,high:d,outliers:[]};const u=1.5*(i-a),m=o.filter(e=>e.v>=a-u&&e.v<=i+u),p=Math.min(m[0]?.v??a,a),f=Math.max(m[m.length-1]?.v??i,i);let g=[...new Set(o.filter(e=>e.vf).map(e=>e.v))];return g.length>60&&(g=g.map(e=>({v:e,d:et.d-e.d).slice(0,60).map(e=>e.v).sort((e,t)=>e-t)),{...h,low:p,high:f,outliers:g}}(c,t,d);h&&a.push({category:n?i:l,slot:n?l:0,series:r,stats:h,readings:ar(c.map(e=>e.v),120)})})}),{boxes:a,categories:n?o.length:e.series.length,slots:n?e.series.length:1}}function $r(e,t){const n=Math.max(1,Math.ceil(e/Math.max(1,t))),o=[];for(let a=0;aV``,[c,d]=n(i-s,a.q1),[h,u]=n(i+s,a.q3),m=o&&e.readings.length?V`n(i+function(e){const t=43758.5453*Math.sin(12.9898*(e+1));return 2*(t-Math.floor(t))-1}(t)*s*.8,e)))}>`:W;return V` + ${l(n(i,a.low),n(i,a.q1),"whisker")} + ${l(n(i,a.q3),n(i,a.high),"whisker")} + ${l(n(i-s/2,a.low),n(i+s/2,a.low),"cap")} + ${l(n(i-s/2,a.high),n(i+s/2,a.high),"cap")} + + ${m} + ${l(n(i-s,a.median),n(i+s,a.median),"median")} + ${a.outliers.length?V`n(i,e)))}>`:W} + `}const Ar=[...ka,{name:"orientation",path:["orientation"],default:"vertical"},{name:"whiskers",path:["whiskers"],default:"tukey"},{name:"readings",path:["readings"],default:!1},...Ca,...Sa];const Sr=[{name:"box_color",selector:{text:{}}}],Er=[{name:"box_color",path:["color"]}],Cr={layout:"chart",group:"distribution",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${vr} + .style-box .box line { + stroke: var(--box-color); + stroke-width: 1.5; + } + .style-box .box .median { + stroke-width: 3; + } + .style-box .box .body { + fill: color-mix(in srgb, var(--box-color) 22%, transparent); + stroke: var(--box-color); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + } + .style-box .box .outliers { + fill: none; + stroke: var(--box-color); + stroke-width: 6px; + stroke-linecap: round; + } + .style-box .box .readings { + fill: none; + stroke: var(--box-color); + stroke-width: 4px; + stroke-linecap: round; + opacity: 0.45; + } + `,thumbnail:()=>V` + + + + + + + + + `,validate:function(e){To(e),sn(e),ga(e),Qo(e);const t=e;if(void 0!==t.orientation&&!_r.includes(t.orientation))throw new Error(`"orientation" must be one of: ${_r.join(", ")}`);if(void 0!==t.whiskers&&!or.includes(t.whiskers))throw new Error(`"whiskers" must be one of: ${or.join(", ")}`);if(void 0!==t.readings&&"boolean"!=typeof t.readings)throw new Error('"readings" must be true or false')},editor:function(e){const t={...e.config,group_by:void 0},n=Ta(`box|${Ma(t)}`,e.hass,()=>[...Aa(e.hass,t),{type:"grid",name:"",schema:[{name:"orientation",selector:wa(e.hass,"orientation",_r)},{name:"whiskers",selector:wa(e.hass,"whiskers",or)}]},{name:"readings",selector:{boolean:{}}},...za(e.hass),...Ea(e.hass)]);return ja(e,"chart",n,Ar)},entityFields:()=>({schema:Sr,fields:Er}),disconnect(e){Po(e),Wo(e),pr(e)},render(e){const t=function(e,t=Date.now()){const n=Jn(e.raw.period),o=e.raw.source??"history",a=Rn(e.hass),r=Qn(n,t,a,n.navigate?to(e.host,n):0),i=mo(e.raw),s={start:i?Dn(i.unit,r.start,a):r.start,end:r.end},l=e.items.map(e=>({entity:e.entityId,attribute:e.config.attribute})),c="statistics"===o?{period:i?"hour"===i.unit?"5minute":"hour":so(s),types:["mean","min","max"],value:"mean"}:void 0,d=$o(e.host,e.requestUpdate).get(e.hass,l,s,o,{live:r.live,statistics:c}),h=e.items.map((e,t)=>{const n=e.config;return{index:t,entity:e,points:d.data.get(co(l[t]))??[],color:ze(n.color??zo[t%zo.length]),curve:"straight",dash:!1}});return{status:d.status,error:d.error,range:s,period:n,source:o,series:h,live:r.live,back:r.back,zone:a,bounds:i?Bn(i.unit,s.start,s.end,a):[],unit:i?.unit,until:Math.min(s.end,t)}}(e),n=Uo(t,e),o=Do(t,e),a=n===W&&o===W?W:R`${n}${o}`,r=ea(e.raw),i=Ho(t,e);if(i)return ta("over"===r.header?{...r,header:"above"}:r,a,i);const s=e.raw,l=s.orientation??"vertical",c=s.whiskers??"tukey",{boxes:d,categories:h,slots:u}=xr(t,c);let m=1/0,p=-1/0;for(const j of d)m=Math.min(m,j.stats.low,...j.stats.outliers,...!0===s.readings?j.readings:[]),p=Math.max(p,j.stats.high,...j.stats.outliers,...!0===s.readings?j.readings:[]);const f=t.bounds.length>1,g="vertical"===l,y=g?Math.max(4,Math.floor(Zo(e)/Math.max(1,h)/6.5)):16,v=n=>f?function(e,t,n){const o=e.bounds[t],a=e.range.end-e.range.start;return"hour"===e.unit?En(o,"hour",a,n):"month"===e.unit?En(o,"month",a,n):En(o,"day","week"===e.unit?1/0:a,n)}(t,n,e.hass):((e,t=16)=>e.length>t?`${e.slice(0,t-1)}…`:e)(t.series[n]?.entity.name??"",y),_=Math.max(0,...Array.from({length:h},(e,t)=>v(t).length)),b=function(e,t,n,o,a,r=0){const i=ln(e.raw),s={min:i.min,max:i.max},l=[-.5,n-.5];if("vertical"===t){const t=Zo(e),r=un(o,s,4);return{options:i,value:r,times:$r(n,Math.floor(t/a)),unit:"day",range:{start:l[0],end:l[1]},scales:{x:Kt(l,[0,Eo]),y:Kt(r.domain,[Co,0]),domain:r.domain}}}const c=qo(e)-32-(i.y&&"outside"===i.labels?r:0),d=un(o,s,Math.max(2,Math.min(8,Math.round(c/70)))),h=!0===e.raw.compact?6:10;return{options:{...i,horizontal:i.vertical,vertical:i.horizontal},value:{domain:l,ticks:$r(n,h),decimals:0},times:d.ticks,unit:"day",range:{start:d.domain[0],end:d.domain[1]},scales:{x:Kt(d.domain,[0,Eo]),y:Kt(l,[0,Co]),domain:l}}}(e,l,h,d.length?[m,p]:void 0,f?56:1,6*_+12),x=g?b.scales.x:b.scales.y,$=g?b.scales.y:b.scales.x,w=(e,t)=>g?[x(e),$(t)]:[$(t),x(e)],k=g?Zo(e):r.compact?110:160,M=k>0?h/k*22:1/0,A=V`${d.map(e=>Mr(e,kr(e,u,M),w,!0===s.readings))}`,S=at(e.hass,"box_aria",{names:t.series.map(e=>e.entity.name).join(", ")}),E=fa(e.raw),C=E?mr(e.host):void 0,z=(t,n)=>Ro(Io(n,t.series.entity.decimals,e.hass),t.series.entity.unit);let L=W;const T=void 0===C?void 0:d[C];if(T){const n=t=>at(e.hass,t),{stats:o}=T,a=[{name:n("box_highest"),value:z(T,o.max)},{name:n("box_upper_quartile"),value:z(T,o.q3)},{name:n("box_median"),value:z(T,o.median),color:T.series.color},{name:n("box_lower_quartile"),value:z(T,o.q1)},{name:n("box_lowest"),value:z(T,o.min)},{name:n("box_mean"),value:z(T,o.mean)},{name:n("box_count"),value:String(o.count)}];if(o.outliers.length){const e=o.outliers.length;a.push({name:n("box_outliers"),value:e>=60?`${e}+`:String(e)})}const r=kr(T,u,M),i=f?`${function(e,t,n){const o="month"===t?{month:"long",year:"numeric"}:"hour"===t?{weekday:"short",day:"numeric",month:"short",hour:"numeric",minute:"2-digit",hour12:wn(n)}:{weekday:"day"===t?"short":void 0,day:"numeric",month:"short"},a=n?.locale?.language??"en",r=`${a}|${t}|${$n(n)}|${o.hour12}`;let i=wr.get(r);if(!i){try{i=new Intl.DateTimeFormat(a,{...o,timeZone:$n(n)})}catch{i=new Intl.DateTimeFormat("en",o)}wr.set(r,i)}return i.format(e)}(t.bounds[T.category],t.unit,e.hass)}${u>1?` · ${T.series.entity.name}`:""}`:T.series.entity.name;L=yr(g?{x:x(r.centre)/Eo,y:$(o.median)/Co,plotWidth:Zo(e),title:i,rows:a,band:[x(T.category-.5)/Eo,x(T.category+.5)/Eo]}:{x:$(o.high)/Eo,y:x(r.centre)/Co,plotWidth:Zo(e),title:i,rows:a})}return ta(r,a,Xo({axes:b,ctx:e,content:A,label:S,overlay:R`${"over"===r.header?na(a):W}${L}`,hover:E?fr(e,(e,t)=>function(e,t,n,o){const a=o*t-.5,r=Math.min(t-1,Math.max(0,Math.round(a))),i=a-r+.36;if(n>1&&(i<0||i>=br))return;const s=Math.min(n-1,Math.max(0,Math.floor(i/(br/n)))),l=e.findIndex(e=>e.category===r&&e.slot===s);return l<0?void 0:l}(d,h,u,g?e:t)):void 0,...g?{xLabel:v}:{yLabel:v,xLabel:t=>Io(t,hr(b),e.hass)}}))}},zr=["entity","time"],Lr=[2,80],Tr=[2,30],jr=[0,20];function Or(e,t){const n=new Set;for(const i of e)for(const e of i)e.t!==t&&n.add(e.t);const o=[...n].sort((e,t)=>e-t),a=e.map(()=>-1),r=[];for(const i of o){const t=[];let n=!0;e.forEach((e,o)=>{for(;a[o]+1>>16,73244475),n=Math.imul(n^n>>>16,73244475),n^=n>>>16,(n>>>0)/4294967295*2-1}function Fr(e,[t,n],o,a){const r=o*o+(a*a-o*o)*(n>t?Math.min(1,Math.max(0,(e-t)/(n-t))):.5);return Math.round(10*Math.sqrt(r))/10}nt("en",{editor_style_scatter:"Scatter",editor_style_bubble:"Bubble",editor_against:"Along the bottom",editor_against_entity:"The first entity",editor_against_time:"Time",editor_dot_size:"Dot size (px)",editor_jitter:"Jitter (px)",editor_min_size:"Smallest bubble (px)",editor_max_size:"Largest bubble (px)",editor_dot_color:"Dot colour",editor_xy_x_axis:"Labels along the bottom",scatter_needs_two:"Add a second entity. The first goes along the bottom, the others up the side.",bubble_needs_three:"Add three entities: one along the bottom, one up the side, and one for the size of each bubble.",scatter_role_x:"Across",bubble_role_size:"Size"});const Pr=e=>"number"==typeof e&&Number.isFinite(e);function Dr(e,t,[n,o]){const a=e[t];if(void 0!==a&&(!Pr(a)||ao))throw new Error(`"${t}" must be a number from ${n} to ${o}`)}function Hr(e,t){return{against:"bubble"===t?"entity":e.against??"entity",dotSize:Pr(e.dot_size)?e.dot_size:6,jitter:Pr(e.jitter)?e.jitter:0,minSize:Pr(e.min_size)?e.min_size:6,maxSize:Pr(e.max_size)?e.max_size:32}}function Br(e,t){if(To(e),sn(e),ga(e),Qo(e),ir(e),Dr(e,"jitter",jr),"scatter"===t){if(void 0!==e.against&&!zr.includes(e.against))throw new Error(`"against" must be one of: ${zr.join(", ")}`);Dr(e,"dot_size",Tr)}else{Dr(e,"min_size",Lr),Dr(e,"max_size",Lr);const n=Hr(e,t);if(n.minSize>n.maxSize)throw new Error('"min_size" must not be above "max_size"')}}function Ir(e,t){return ze(e.entity.config.color??zo[(e.index-t)%zo.length])}const Rr=(e,t,n)=>Ro(Io(e,t.entity.decimals,n),t.entity.unit);function Vr(e,t,n=Date.now()){const o=Hr(e.raw,t),{against:a}=o,r=ea(e.raw),i=jo(e,n),s=Uo(i,e),l="time"===a,c=l?0:1,d=i.series.map((e,n)=>l?e.color:"bubble"===t?1===n?Ir(e,1):void 0:0===n?void 0:Ir(e,c)),h=function(e,t,n,o){const{show:a}=n.config;return a.name||a.value?R`
+ ${e.map((e,r)=>{const i=t[r],s=void 0!==i?R``:R`${at(n.hass,"bubble"===o&&2===r?"bubble_role_size":"scatter_role_x")}`;return R`
+ ${s} ${a.name?R`${e.entity.name}`:W} + ${a.value?R`${e.entity.display}`:W} +
`})} +
`:W}(i.series,d,e,t),u=s===W&&h===W?W:R`${s}${h}`,m="over"===r.header?{...r,header:"above"}:r;if(i.series.length + ${at(e.hass,"bubble"===t?"bubble_needs_three":"scatter_needs_two")} +
`);const p=Ho(i,e);if(p)return ta(m,u,p);const f="history"!==i.source||i.group?void 0:Math.min(i.range.end,n),g=function(e,t,n,o){const{series:a}=e;if("time"===n)return a.map(e=>({s:e,samples:Nr(e.points.filter(e=>null!==e.v&&e.t!==o&&!e.carried).map(e=>({t:e.t,v:[e.t,e.v]})),800)}));if("bubble"===t){const[e,t,n]=a;return[{s:t,samples:Nr(Or([e.points,t.points,n.points],o),400)}]}const[r,...i]=a;return i.map(e=>({s:e,samples:Nr(Or([r.points,e.points],o),800)}))}(i,t,a,f),y=g.flatMap(e=>e.samples);if(0===y.length)return ta(m,u,R`
${at(e.hass,"chart_no_data")}
`);const v=y.map(e=>e.v[1]),_=[Math.min(...v),Math.max(...v)];let b,x;if(l)b=Yo(e,i.range,Jt(i.series.map(e=>e.points)));else{const t=y.map(e=>e.v[0]);b=dr(e,[Math.min(...t),Math.max(...t)],_,{x:cr(e.raw)});const n=hr(b);x=t=>Io(t,n,e.hass)}const{scales:$}=b,w=Zo(e)||1,k=o.jitter*Eo/w,M=o.jitter*Co/(r.compact?96:162);const A="bubble"===t?y.map(e=>e.v[2]):[],S=A.length?[Math.min(...A),Math.max(...A)]:[0,0],E=g.flatMap(({s:e,samples:n})=>{const a=l?e.color:Ir(e,c);return n.map(n=>({series:e,t:n.t,x:$.x(n.v[0])+(k?Ur(n.t,2*e.index)*k:0),y:$.y(n.v[1])+(M?Ur(n.t,2*e.index+1)*M:0),v:l?[n.v[1]]:n.v,color:a,d:"bubble"===t?Fr(n.v[2],S,o.minSize,o.maxSize):void 0}))}),C="bubble"===t?V`${[...E].sort((e,t)=>(t.d??0)-(e.d??0)).map(e=>V``)}`:V`${g.map(({s:e})=>{const t=E.filter(t=>t.series===e);return t.length?V`[e.x,e.y]))} + stroke=${t[0].color} style="stroke-width:${o.dotSize}px">`:W})}`,z=fa(e.raw),L=z?mr(e.host):void 0,T=void 0!==L?E[L]:void 0,j=at(e.hass,"chart_aria",{style:at(e.hass,`editor_style_${t}`),names:i.series.map(e=>e.entity.name).join(", ")}),O=Math.max(16,"bubble"===t?o.maxSize/2:o.dotSize);return ta(r,u,R`${Xo({axes:b,ctx:e,content:C,label:j,xLabel:x,overlay:R`${"over"===r.header?na(u):W} + ${T?qr(T,i,t,a,e,w):W}`,hover:z?fr(e,(e,t,n,o)=>function(e,t,n,o,a,r=24){let i,s=r*r;const l=t*o,c=n*a;return e.forEach(([e,t],n)=>{const r=e/Eo*o-l,d=t/Co*a-c,h=r*r+d*d;h<=s&&(s=h,i=n)}),i}(E.map(e=>[e.x,e.y]),e,t,n,o,O)):void 0})} + ${"bubble"===t?function(e,[t,n],o,a){const r=(t,n)=>R`${Rr(t,e,a)}`;return R``}(i.series[2],S,o,e.hass):W}`)}function qr(e,t,n,o,a,r){const i="time"===o?[e.series]:"bubble"===n?t.series.slice(0,3):[t.series[0],e.series];return yr({x:e.x/Eo,y:e.y/Co,plotWidth:r,title:Sn(e.t,a.hass),dot:e.color,rows:i.map((t,n)=>({color:t===e.series?e.color:void 0,name:t.entity.name,value:Rr(e.v[n],t,a.hass)}))})}const Wr=r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${vr} + .plot .dots, + .plot .bubble { + fill: none; + stroke-linecap: round; + } + .plot .dots { + stroke-opacity: 0.8; + } + .plot .bubble { + stroke-opacity: 0.55; + } + .role { + flex: none; + align-self: center; + font-size: 0.6875em; + line-height: 1; + padding: 2px 5px; + border-radius: 8px; + color: var(--secondary-text-color); + border: 1px solid var(--divider-color, rgba(127, 127, 127, 0.3)); + } + .size-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; + padding-top: 6px; + font-size: 0.8125em; + color: var(--secondary-text-color); + } + .size-item { + display: inline-flex; + align-items: center; + gap: 6px; + } + .size-dot { + flex: none; + border-radius: 50%; + border: 1px solid var(--secondary-text-color); + background: color-mix(in srgb, var(--secondary-text-color) 25%, transparent); + box-sizing: border-box; + } + .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` + + + + `,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`
`}function ui(e,t,n,o,a,i){return R`${Fo(o,a,e.hass)}`}function pi(e,t,n,o){const a=e.y(o);return a<0||a>Mo?R``:R`
`}function vi(e,t,n,o,a,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`
`}const mi=_a.map(e=>"period_last"===e.name?{...e,default:Ka.last}:"period_unit"===e.name?{...e,default:Ka.unit}:"group_by"===e.name||"aggregate"===e.name?{...e,default:"auto"}:e),fi=[...mi,{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},...wa,...$a];const gi=[{name:"column_color",path:["color"]}],yi=i` + style="left:${fi(t-.1*n,Eo)};width:${fi(1.2*n,Eo)};top:${fi(a,Co)}" + >
`}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` .columns { position: absolute; inset: 0; @@ -773,53 +922,53 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .axes-inside .x-axis { z-index: 1; } -`,_i={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${yi} - `,thumbnail:()=>I` +`,ki={layout:"chart",group:"history",height:240,styles:r` + ${Bo} + ${Jo} + ${$a} + ${oa} + ${wi} + `,thumbnail:()=>V` - `,validate:function(e){if(void 0!==e.group_by&&!(e=>Xa.includes(e))(e.group_by))throw new Error(`"group_by" must be one of: ${Xa.join(", ")}`);if(void 0!==e.aggregate&&!Ja.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${Ja.join(", ")}`);const t={...e};"auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,So(t),rn(e),da(e),Yo(e),ea(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=Aa(`column|${va(n)}|${t}`,e.hass,()=>[...ba(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e.hass,"group_by",Xa)},...t?[]:[{name:"aggregate",selector:ya(e.hass,"aggregate",Ja)}]]},{type:"grid",name:"",schema:[{name:"stack",selector:ya(e.hass,"stack",Qo)},{name:"color_by",selector:ya(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"range",selector:{boolean:{}}},{name:"column_labels",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea({...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:Ka.last,unit:Ka.unit,...t}}:e}(t))},"chart",o,fi)},entityFields:e=>({schema:Aa("column-entity",e,()=>[{name:"column_color",selector:{text:{}}}]),fields:gi}),disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t={...e,raw:ni(e.raw,e.items)},n=t.raw,o=!0===n.range,a=Co(t,Date.now(),{range:o}),i=zo(a,t),r=jo(a,t),s=i===Z&&r===Z?Z:R`${i}${r}`,l=Po(a,t),c=Go(n);if(l)return Xo("over"===c.header?{...c,header:"above"}:c,s,l);const d=o?"none":n.stack??"none",h=a.group,u=oi(a.series.map(e=>e.points),h?1/0:a.range.end),p=h&&u.ends.length?{...a.range,end:Math.max(a.range.end,...u.ends)}:a.range,m="none"===d?void 0:na(u.values,d),f=a.series.map(e=>e.entity.target).filter(e=>null!==e);let g;g=m?aa(m):o?ii([...u.lows.flat(),...u.highs.flat(),...u.values.flat(),...f],!1):ii([...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 _=Ro({...t,raw:y},p,g);const{scales:v}=_,b=u.times.map((e,t)=>(e+u.ends[t])/2),$=new Map(b.map((e,t)=>[e,t]));if(h&&b.length){const e=(x=b.length,w=Vo(t)/ri[h.unit],Math.max(1,Math.ceil(x/Math.max(1,w)))),n=si[h.unit],o="day"===h.unit&&b.length<=8?6048e5:a.range.end-a.range.start;_={..._,unit:n,times:b.filter((t,n)=>n%e===0),label:e=>An(u.times[$.get(e)],n,o,t.hass)}}var x,w;const k=function(e,t,n,o,a,i){const r=e.raw,s=t.series,l=s.length,c=!0===r.range,d="bands"===r.color_by,h=Vo(e)/ko,u=!0===r.labels&&"percent"!==a&&n.times.length*(i?1:l)<=60,p=e.config.show.target&&"percent"!==a,m=Math.min(Math.max(0,o.domain[0]),o.domain[1]),f=(e,t)=>d?Oe(Te(t,e.entity.bands).color):e.color,g={columns:[],labels:[],targets:[]},y=i?oa(n.values):[];return n.times.forEach((t,a)=>{const r=o.x(t),d=o.x(n.ends[a]);if(i){const[t,l]=ai(r,d,0,1);let c=-1,m=-1;i.forEach((e,t)=>{const n=e[a];n&&n[1]>n[0]&&(c=t),n&&n[1]{const r=e[a];if(!r||r[0]===r[1])return;const d=n.values[i][a],h=i===c?"top":i===m?"bottom":"";g.columns.push(hi(o,t,l,Math.min(...r),Math.max(...r),f(s[i],d),h,i))});const _=y[a];if(u&&null!==_&&l*h>=24){const n=_>=0?Math.max(0,...i.map(e=>e[a]?.[1]??0)):Math.min(0,...i.map(e=>e[a]?.[1]??0)),r=Math.max(...s.map(e=>e.entity.decimals));g.labels.push(ui(e,t+l/2,o.y(n),_,r,_<0))}const v=s[0].entity.target;return void(p&&null!==v&&g.targets.push(pi(o,t,l,v)))}s.forEach((t,i)=>{const s=n.values[i][a];if(null===s)return;const[y,_]=ai(r,d,i,l);if(c){const e=n.lows[i][a]??s,r=n.highs[i][a]??s;g.columns.push(hi(o,y,_,e,r,f(t,s),"both",i)),g.columns.push(R`
`,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`
`)}else g.columns.push(hi(o,y,_,Math.min(m,s),Math.max(m,s),f(t,s),s>=m?"top":"bottom",i)),u&&_*h>=24&&g.labels.push(ui(e,y+_/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=$.get(e);return void 0===n?Mn(e,t.hass):h?li(u.times[n],u.ends[n],h.unit,t.hass):Mn(u.times[n],t.hass)}},E=(e,n)=>Fo(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 Ho(`${E(n,e)} – ${E(o,e)}`,e.entity.unit)}),m){const e=oa(u.values),n=pa(M),o=Math.max(...M.map(e=>e.entity.decimals));A.footer=a=>ma(t,e[$.get(a[0]?.p?.t??NaN)??-1]??null,n,o),"percent"===d&&(A.text=(e,t)=>{const n=t?m[e.index][$.get(t.t)??-1]:null;return t&&null!==t.v&&n?`${Ho(E(t.v,e),e.entity.unit)} · ${Math.round(Math.abs(n[1]-n[0]))} %`:"—"})}const S=ca(n),C=S?sa(t.host):void 0,O=void 0===C?void 0:$.get(C),L=void 0===O?Z: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`
`,z=ot(t.hass,"chart_aria",{style:ot(t.hass,"editor_style_column"),names:a.series.map(e=>e.entity.name).join(", ")}),T=!1===n.rounded?0:4;return Xo(c,s,Wo({axes:_,ctx:t,content:I``,label:z,overlay:R`
${L}${k.columns}${k.targets}
+ style="left:${fi(_.x(u.times[z]),Eo)};width:${fi(_.x(u.ends[z])-_.x(u.times[z]),Eo)}" + >
`,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}
${k.labels}
- ${"over"===c.header?Jo(s):Z} - ${S?fa(t,M,v,C,Vo(t),A):Z}`,hover:S?ha(t,p,M.map(e=>e.points)):void 0}))}},vi=["series","bands"];function bi(e,t,n,o,a){const i=lt(e.entity.config,a),r=En(e.points,500),s=wo(r).map(n=>xo(n.map(e=>[t.x(e.t),t.y(e.v)]),e.curve)).join(""),l=i.dots?r.filter(e=>null!==e.v&&e.t!==o.start&&e.t!==o.end):[],c=l.length>0&&l.length<=200?I`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`[t.x(e.t),t.y(e.v)]))} + d=${ht(l.map(e=>[t.x(e.t),t.y(e.v)]))} fill="none" stroke=${n} - style="stroke-width:${ht(i.width)}px" - >`:Z;return I` + style="stroke-width:${ut(r.width)}px" + >`:W;return V` ${c} - `}function $i(e,t,n){return R`
+ `}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`${Fo(e.v,o.entity.decimals,n.hass)}${Io(e.v,o.entity.decimals,n.hass)}`)})} -
`}tt("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 xi={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} +
`}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} .style-line .line { stroke-linecap: round; stroke-linejoin: round; @@ -830,21 +979,21 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .style-line .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){if(So(e),rn(e),da(e),mt(e),Yo(e),void 0!==e.color_by&&!vi.includes(e.color_by))throw new Error(`"color_by" must be one of: ${vi.join(", ")}`)},editor:function(e){const t=Aa(`line|${va(e.config)}`,e.hass,()=>[...ba(e.hass,e.config),{type:"grid",name:"",schema:[{name:"curve",selector:ya(e.hass,"curve",vo)},{name:"color_by",selector:ya(e.hass,"color_by",["series","bands"])}]},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}}]},{name:"labels",selector:{boolean:{}}},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",t,Sa)},entityFields:(e,t)=>({schema:zt(e),fields:Mt(!0===t.dots)}),disconnect(e){No(e),Bo(e),la(e)},render(e){const t=Co(e),n=zo(t,e),o=jo(t,e),a=n===Z&&o===Z?Z:R`${n}${o}`,i=Po(t,e),r=Go(e.raw);if(i)return Xo("over"===r.header?{...r,header:"above"}:r,a,i);const s=Ro(e,t.range,Xt(t.series.map(e=>e.points))),{scales:l}=s,c=ca(e.raw),d="bands"===e.raw.color_by,h=d?I`${t.series.map(e=>function(e,t,n){const[o,a]=n.domain,i=e=>Math.min(1,Math.max(0,(e-o)/(a-o))),r=[];return t.forEach((e,n)=>{const o=Number.isFinite(e.from)?i(e.from):0,a=n+1`)}),I` - ${r} - `}(`cd-bands-${e.index}`,e.entity.bands,l))}`:Z,u=ct(t.series,t=>lt(t.entity.config,e.raw).onTop),p=I`${u.map(n=>bi(n,l,d?`url(#cd-bands-${n.index})`:n.color,t.range,e.raw))}`,m=ot(e.hass,"chart_aria",{style:ot(e.hass,"editor_style_line"),names:t.series.map(e=>e.entity.name).join(", ")});return Xo(r,a,Wo({axes:s,ctx:e,content:p,label:m,defs:h,overlay:R`${"over"===r.header?Jo(a):Z} - ${!0===e.raw.labels?$i(t,l,e):Z} - ${c?fa(e,t.series,l,sa(e.host),Vo(e)):Z}`,hover:c?ha(e,t.range,t.series.map(e=>e.points)):void 0}))}},wi=["line","column","area"],ki=["auto","left","right"];tt("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 Mi=e=>e.config,Ai=e=>Mi(e).type??"line";function Ei(e,t){const n={...e},o=t.some(e=>"column"===Ai(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=ei(Yn(e.period))):delete n.group_by,"auto"===e.aggregate&&delete n.aggregate,n}function Si(e,t){const n=e.flatMap(e=>e.points.map(e=>e.v));return ii(n,t.includes("column"))}const Ci=[..._a.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},...wa,...$a];const Oi=[{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"]}],Li={layout:"chart",group:"history",height:240,styles:i` - ${Uo} - ${Zo} - ${ga} - ${Ko} - ${Ua} - ${yi} + stroke-width="2.5" stroke-linecap="round" stroke-dasharray="4 3" />`,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} .lines-over { pointer-events: none; } @@ -858,17 +1007,182 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de .lines-over .line.dashed { stroke-dasharray: 6 5; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate:function(e){if(void 0!==e.group_by&&!Xa.includes(e.group_by))throw new Error(`"group_by" must be one of: ${Xa.join(", ")}`);if(void 0!==e.aggregate&&!Ja.includes(e.aggregate))throw new Error(`"aggregate" must be one of: ${Ja.join(", ")}`);const t={...e};if("auto"!==e.group_by&&"none"!==e.group_by||delete t.group_by,"auto"===e.aggregate&&delete t.aggregate,So(t),co(t),rn(e),da(e),mt(e),Yo(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&&!wi.includes(e.type))throw new Error(`"type" of ${n.entity} must be one of: ${wi.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&&!lo.includes(e.aggregate))throw new Error(`"aggregate" of ${n.entity} must be one of: ${lo.join(", ")}`)}},editor:function(e){const t="none"===e.config.group_by,n={...e.config,group_by:void 0},o=Aa(`combined|${va(n)}|${t}`,e.hass,()=>[...ba(e.hass,n).slice(0,-1),{type:"grid",name:"",schema:[{name:"group_by",selector:ya(e.hass,"group_by",Xa)},...t?[]:[{name:"aggregate",selector:ya(e.hass,"aggregate",Ja)}]]},{name:"curve",selector:ya(e.hass,"curve",vo)},{type:"grid",name:"",schema:[{name:"line_width",selector:wt},{name:"dots",selector:{boolean:{}}},{name:"rounded",selector:{boolean:{}}}]},...ka(e.hass),...xa(e.hass)]);return Ea(e,"chart",o,Ci)},entityFields:e=>({schema:Aa("combined-entity",e,()=>[{type:"grid",name:"",schema:[{name:"series_type",selector:ya(e,"series_type",wi)},{name:"series_axis",selector:ya(e,"series_axis",ki)},{name:"series_color",selector:{text:{}}},{name:"series_aggregate",selector:ya(e,"series_aggregate",["auto",...lo])}]},{type:"grid",name:"",schema:[{name:"curve",selector:ya(e,"curve",vo)},{name:"line_width",selector:wt},{name:"dash",selector:{boolean:{}}},{name:"negative_color",selector:{text:{}}}]}]),fields:Oi}),disconnect(e){No(e),Bo(e),la(e)},render:function(e){const t={...e,raw:Ei(e.raw,e.items)},n=t.raw,o=Co(t,Date.now(),{aggregate:t=>function(e,t){const n=t.aggregate;return Mi(e).aggregate??(n&&"auto"!==n?n:ti(e)?"change":"mean")}(t,e.raw)}),a=zo(o,t),i=jo(o,t),r=a===Z&&i===Z?Z:R`${a}${i}`,s=Po(o,t),l=Go(n);if(s)return Xo("over"===l.header?{...l,header:"above"}:l,r,s);const c=o.series.map(e=>Ai(e.entity)),d=function(e){const t=e[0]?.unit,n=e.map(e=>Mi(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),p=h?Math.max(o.range.end,...o.series.flatMap(e=>e.points.map(e=>e.end??0))):0,m=h?{...o.range,end:p}:o.range,f=e=>u.filter((t,n)=>d[n]===e),g=e=>c.filter((t,n)=>d[n]===e),y=Si(f("left"),g("left"));let _=n;y&&g("left").includes("column")?_={...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")&&(_=Ca(y,n));const v=Ro({...t,raw:_},m,y),b=v.scales,$=f("right"),x=Si($,g("right")),w={};x&&(g("right").includes("column")||g("right").includes("area"))&&(x[0]>=0&&x[0]<=x[1]/2&&(w.min=0),x[1]<=0&&x[1]>=x[0]/2&&(w.max=0));const k=$.length?dn(x,w,4):void 0,M=k?{x:b.x,y:Jt(k.domain,[Mo,0]),domain:k.domain}:void 0,A=e=>"right"===d[e.index]&&M?M:b,E=u.filter(e=>"area"===c[e.index]).map(e=>Ta(e,e.points,A(e),n,{range:m})),S=u.filter(e=>"line"===c[e.index]).map(e=>bi({...e,points:e.points},A(e),e.color,m,n)),C=u.filter(e=>"column"===c[e.index]),O=oi(o.series.filter(e=>"column"===c[e.index]).map(e=>e.points),h?1/0:o.range.end),L=!1===n.rounded?0:4,z=[];O.times.forEach((e,t)=>{const n=b.x(e),o=b.x(O.ends[t]);C.forEach((e,a)=>{const i=O.values[a][t];if(null===i)return;const r=A(e),s=Math.min(Math.max(0,r.domain[0]),r.domain[1]),[l,c]=ai(n,o,a,C.length);z.push(hi(r,l,c,Math.min(s,i),Math.max(s,i),e.color,i>=s?"top":"bottom",e.index))})});const T=O.times.map((e,t)=>(e+O.ends[t])/2),N=u.map(e=>{if("column"!==c[e.index]||h)return e;const t=C.indexOf(e);return{...e,points:T.map((e,n)=>({t:e,v:O.values[t][n]}))}}),j=new Map;for(const F of o.series)for(const e of F.points)void 0!==e.end&&j.set((e.t+e.end)/2,[e.t,e.end]);const P=ca(n),U=ot(t.hass,"chart_aria",{style:ot(t.hass,"editor_style_combined"),names:o.series.map(e=>e.entity.name).join(", ")});return Xo(l,r,Wo({axes:v,ctx:t,right:k&&M?{value:k,y:M.y}:void 0,content:I`${E.map(e=>e.body)}`,defs:I`${E.map(e=>e.defs)}`,label:U,overlay:R`
${z}
-
${T}
+ + ${"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`
+
+ ${s?W:R``} + ${k.name||o?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`
+ ${M} +
+ +
+ + ${c.cells.map(e=>Qi(d,e,i,y(e.v)))} + ${$?Qi(d,$,i,"none","cell picked"):W} - ${"over"===l.header?Jo(r):Z} - ${P?fa(t,N,b,sa(t.host),Vo(t),{dotY:(e,t)=>"column"===c[e.index]||null===t.v?null:A(e).y(t.v),heading:e=>{const n=j.get(e);return n&&h?li(n[0],n[1],h.unit,t.hass):Mn(e,t.hass)}}):Z}`,hover:P?ha(t,m,N.map(e=>e.points)):void 0}))}},zi=e=>(Math.abs(e)<.005?0:e).toFixed(2);function Ti(e,t,n,o){const a=o*Math.PI/180;return[e+n*Math.sin(a),t-n*Math.cos(a)]}function Ni(e,t,n,o,a){const i=String(Number(n.toFixed(2))),r=a-o,[s,l]=Ti(e,t,n,o);if(r>=359.99){const[a,r]=Ti(e,t,n,o+180);return`M${zi(s)} ${zi(l)} A${i} ${i} 0 1 1 ${zi(a)} ${zi(r)} A${i} ${i} 0 1 1 ${zi(s)} ${zi(l)}`}const[c,d]=Ti(e,t,n,a),h=r>180?1:0;return`M${zi(s)} ${zi(l)} A${i} ${i} 0 ${h} 1 ${zi(c)} ${zi(d)}`}function ji(e,t){const n=e/2;return[-n+t[0]*e,-n+t[1]*e]}function Pi(e){let t=0;for(const n of e)t+=n.codePointAt(0)>=11904?1/.58:1;return t}function Ui(e,t=76,n=12,o=8,a=11){const i=e.trim();let r=[i];if(Pi(i)>a){let e=Pi(i);for(let t=i.indexOf(" ");t>0;t=i.indexOf(" ",t+1)){const n=Math.max(Pi(i.slice(0,t)),Pi(i.slice(t+1)));nString(Math.round(1e3*e)/1e3+0);function Bi(e,t,n,o,a,i=11){const{lines:r,size:s}=Ui(e,t,n,o,i),l=a-1.2*(r.length-1)*s;return I`${r.map((e,t)=>I`${e}`)}`}const Vi={layout:"tiles",group:"gauges",height:140,minColumnWidth:134,styles:i` + ${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`${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} + .hm + .hm { + margin-top: 12px; + } + .hm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto; + column-gap: 6px; + } + .hm-rows { + position: relative; + grid-column: 1; + grid-row: 1; + min-width: 2em; + } + .hm-plot { + position: relative; + grid-column: 2; + grid-row: 1; + } + .hm-plot.hoverable { + cursor: crosshair; + } + .hm-svg { + display: block; + width: 100%; + height: auto; + opacity: 0; + transition: opacity var(--cd-duration) var(--cd-easing); + } + :host([ready]) .hm-svg { + opacity: 1; + } + :host([static]) .hm-svg { + transition: none; + } + .hm-svg .cell.empty { + fill: var(--cd-track); + } + .hm-svg .cell.picked { + fill: none; + stroke: var(--primary-text-color); + stroke-width: 1px; + vector-effect: non-scaling-stroke; + } + .hm-cols { + position: relative; + grid-column: 2; + grid-row: 2; + height: 16px; + } + .hm-label { + position: absolute; + font-size: 11px; + line-height: 1; + color: var(--secondary-text-color); + white-space: nowrap; + pointer-events: none; + } + .hm-rows .hm-label { + right: 0; + transform: translateY(-50%); + } + .hm-cols .hm-label { + top: 4px; + } + .grid-calendar .hm-label { + font-size: 10px; + } + .hm-legend { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + padding-top: 6px; + font-size: 11px; + color: var(--secondary-text-color); + font-variant-numeric: tabular-nums; + } + .hm-legend.scale { + justify-content: flex-end; + gap: 6px; + } + .hm-gradient { + width: min(160px, 40%); + height: 8px; + border-radius: 4px; + } + .hm-key { + display: inline-flex; + align-items: center; + gap: 4px; + } + .hm-swatch { + width: 10px; + 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} + .style-histogram .bin { + shape-rendering: crispEdges; + } + .style-histogram .overlap .bin { + fill-opacity: 0.35; + stroke-width: 1.5px; + vector-effect: non-scaling-stroke; + } + `,thumbnail:()=>V` + + + + + + + + + `,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` /* A definite width that shrinks with a narrow tile (the svg inside is sized in %). */ .needle-gauge { display: flex; @@ -944,39 +1258,39 @@ const Qe=me(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((i,r)=>{const s=0===r?t:Math.max(i.from,t),l=Math.min(r+1s&&a.push({from:(s-t)/o,to:(l-t)/o,color:i.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?I``:Z}),i=Array.from({length:11},(e,t)=>{const[n,o]=Ti(Fi,Hi,36,18*t-90),[a,i]=Ti(Fi,Hi,40,18*t-90);return I``});let r=Z;if(t.target&&null!==e.targetPosition&&e.available){const t=180*e.targetPosition-90,[n,o]=Ti(Fi,Hi,57,t),[a,i]=Ti(Fi,Hi,64,t-3),[s,l]=Ti(Fi,Hi,64,t+3);r=I``}const s=!t.value;return R`
+ `,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`
${t.icon?R``:Z} - ${t.name?R`${e.name}`:Z} -
`:Z} -
`;var l,c,d,h,u}};function Ri(e){return e.map(e=>e.available&&e.value>0?e.value:0)}function Ii(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 i=t-a.reduce((e,t)=>e+t,0);const r=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 r){if(i<=0)break;a[s]+=1,i-=1}return a}function Wi(e,t){return t>0?e/t*(180/Math.PI):0}function Zi(e){const t=Math.max(0,e.end-e.start);return`--a:${(e.start-90).toFixed(3)}deg;--len:${t.toFixed(3)}`}function qi(e,t,n,o,a="primary"){return"bands"===o?Oe(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))`}(Oe(a),t,n):Oe(function(e){const t=e.config.color;return"string"==typeof t&&""!==t.trim()?t:void 0}(e)??Ao[t%Ao.length])}function Yi(e){const t=new Set(e.filter(e=>e.available).map(e=>e.unit));return 1===t.size?[...t][0]:""}function Gi(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 i=a.reduce((e,{v:t})=>e+t,0),r="total"===t?i:i/a.length,s=Math.max(...a.map(({e:e})=>e.decimals));return{value:r,text:Fo(r,s,n),unit:Yi(e)}}function Xi(e){return Ee(e.config)?0:Z}function Ji(e,t){const{show:n}=t.config;return!e.length||!n.name&&!n.value&&e.every(e=>void 0===e.share)?Z:R`
+ .icon=${Ya(e)} + >`:W} + ${t.name?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`
${e.map(e=>R`
- ${n.name?R`${e.entity.name}`:Z} - ${n.value?R`${e.entity.display}`:Z} - ${void 0===e.share?Z:R`${e.share}`} + ${n.name?R`${e.entity.name}`:W} + ${n.value?R`${e.entity.display}`:W} + ${void 0===e.share?W:R`${e.share}`}
`)} -
`}function Ki(e,t){return Ho(Fo(e,0,t),"%")}function Qi(e,t){return ot(e.hass,"parts_aria",{style:ot(e.hass,t),names:e.items.map(e=>e.name).join(", ")})}function er(e,t,n){return R``}function tr(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 nr(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 or(e,t){const n=e[t];if(void 0!==n&&"boolean"!=typeof n)throw new Error(`"${t}" must be true or false`)}function ar(e,t){const n=e[t];if(void 0!==n&&"string"!=typeof n)throw new Error(`"${t}" must be text`)}tt("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 ir=i` + `}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 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` .parts { display: flex; flex-direction: column; @@ -1100,26 +1414,26 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de font-weight: 500; white-space: nowrap; } -`;tt("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 rr=["series","bands"];function sr(e,t,n,o){const{show:a}=n.config,i=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,i=Math.ceil(o/a-1e-9);return i>100?{count:100,per:o/100}:{count:i,per:a}}(e.min,e.max,o.perIcon),r=e.available?function(e,t,n,o){const a=Math.min(n.count,Math.max(0,(e-t)/n.per)),i=o?a:Math.floor(a+1e-9);return Array.from({length:n.count},(e,t)=>Math.min(1,Math.max(0,i-t)))}(e.value,e.min,i,o.partial):i.count?Array(i.count).fill(0):[],s=qi(e,t,n.items.length,o.colorBy),l=e.config.icon??o.unitIcon??(e.stateObj?void 0:"mdi:checkbox-blank-circle"),c=i.count>20?20:28,d=e.available?R`${e.valueText}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}/ ${Ho(Fo(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}/ ${Ro(Io(e.max,Number.isInteger(e.max)?0:e.decimals,n.hass),e.unit)}`:e.display;return R`
- ${a.name?R`${e.name}`:Z} + ${a.name?R`${e.name}`:W} - ${a.value?R`${d}`:Z} -
`}const lr=[{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 cr={layout:"chart",group:"totals",height:180,styles:i` + ${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` .pictograph { display: flex; flex-direction: column; @@ -1205,12 +1519,12 @@ const Qe=me(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:()=>I` - ${[0,1,2,3,4].map(e=>I` - `)}`,validate(e){ar(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(nr(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');tr(e,"color_by",rr),or(e,"partial")},editor:function(e){const t=Aa("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:ya(e.hass,"color_by",rr)}]},{name:"partial",selector:{boolean:{}}}]);return Ea(e,"chart",t,lr)},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)=>sr(n,o,e,t))} -
`}};tt("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 dr=["donut","pie"],hr=["total","none"],ur=["series","bands","shades"],pr=48;function mr(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 i=0;return"donut"===t.shape&&(o>1&&(i+=Wi(t.gap,a)),t.rounded&&(i+=Wi(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,i):void 0)}const fr=[{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 gr={layout:"chart",group:"totals",height:200,styles:i` - ${ir} + `,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} .style-pie .track { stroke: var(--cd-track); } @@ -1227,17 +1541,17 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de :host([static]) .style-pie .separator { transition: none; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){tr(e,"shape",dr),tr(e,"center",hr),tr(e,"color_by",ur),nr(e,"gap",0,10),or(e,"rounded"),or(e,"gradient"),or(e,"show_percent"),ar(e,"shade_color"),ar(e,"center_label")},editor:function(e){const t=Aa("pie",e.hass,()=>[{type:"grid",name:"",schema:[{name:"shape",selector:ya(e.hass,"shape",dr)},{name:"color_by",selector:ya(e.hass,"color_by",ur)},{name:"center",selector:ya(e.hass,"center",hr)},{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 Ea(e,"chart",t,fr)},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=Ri(o),i=o.map((e,n)=>qi(e,n,o.length,t.colorBy,t.shadeColor)),r=mr(a,t),s="donut"===t.shape,l=s?41.5:24,c=s?13:pr,d=s?35/pr:0,h=r.filter(Boolean).length,u=t.gradient?I`${o.map((e,t)=>r[t]?function(e,t,n){return I` + pathLength="100" stroke-dasharray="22 78" stroke-dashoffset="-76" transform="rotate(-90 32 20)" />`,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` - `}(`cd-pie-${t}`,i[t],d):Z)}`:Z,p=o.map((e,n)=>{const o=r[n];if(!o)return Z;const a=t.gradient?`url(#cd-pie-${n})`:i[n];return I``}(`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` @@ -1249,18 +1563,18 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de pathLength="360" stroke-width=${c} stroke-linecap=${s&&t.rounded?"round":"butt"} - style="${Zi(o)};stroke:${a}" + style="${Fs(o)};stroke:${a}" > - `}),m=!s&&t.gap>0&&h>1?r.map(e=>e?I``:Z):Z,f=0===h?I``:Z,g=Ii(a),y=o.map((n,o)=>({index:o,entity:n,color:i[o],share:t.percent&&a[o]>0?Ki(g[o],e.hass):void 0})),_=Gi(o,"total",e.hass,a),v=s&&"total"===t.center&&_?er(_.text,"",t.centerLabel||Yi(o)):Z;return R`
+ `}),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`
- - ${u}${f}${p}${m} + + ${u}${f}${m}${p} - ${v} + ${_}
- ${Ji(y,e)} -
`}};let yr=0;const _r=new WeakMap;const vr=60,br=60,$r=e=>String(Math.round(1e3*e)/1e3+0);function xr(e){const[t,n]=ji(e,[0,1]),o=Ni(vr,br,46,t,n),a=e<360;return{layout:"tiles",group:"gauges",height:a?135:155,minColumnWidth:128,styles:i` + ${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` /* A definite width: the dial is a size container and can't size the tile itself. */ .radial { display: flex; @@ -1360,44 +1674,44 @@ const Qe=me(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:i}){const r=function(e){let t=_r.get(e);return t||(t="cd-g-"+ ++yr,_r.set(e,t)),t}(t.config),s=t.span[1]-t.span[0]<=0,l=`stroke-dasharray: ${$r(100*(t.span[1]-t.span[0]))} 100; stroke-dashoffset: ${$r(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(#${r}) ${c}`:`url(#${r})`,u=n.icon?6:0,p=(m=t.valueText,Math.min(22,Math.floor(124/Math.max(1,m.length))));var m;let f=Z;if(n.target&&null!==t.targetPosition&&t.available){const n=ji(e,[t.targetPosition,t.targetPosition])[0],[o,a]=Ti(vr,br,39.5,n),[i,r]=Ti(vr,br,52.5,n);f=I``}return R`
+ `,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``:Z} + .icon=${Ya(t)} + >`:W}
- ${n.name?R`${t.name}`:Z} -
`}}}const wr=xr(360),kr=xr(270);tt("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 Mr=["average","total","none"],Ar=["series","bands"];function Er(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 Sr=[{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 Cr={layout:"chart",group:"totals",height:200,styles:i` - ${ir} + ${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} .style-rings .centre-box { position: absolute; inset: 0 0 auto 0; @@ -1415,7 +1729,7 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de transform-box: view-box; transform-origin: 50px 50px; } - `,thumbnail:()=>I` + `,thumbnail:()=>V` `,validate(e){nr(e,"start_angle",-360,360),nr(e,"end_angle",-360,360);const{start:t,end:n}=Er(e);if(n<=t||n-t>360)throw new Error('"end_angle" must be after "start_angle", and at most 360 degrees after it');tr(e,"center",Mr),tr(e,"color_by",Ar),or(e,"rounded_ends")},editor:function(e){const t=Aa("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:ya(e.hass,"center",Mr)},{name:"color_by",selector:ya(e.hass,"color_by",Ar)}]},{name:"rounded_ends",selector:{boolean:{}}}]);return Ea(e,"chart",t,Sr)},render:function(e){const t=Er(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),i=n.map((e,o)=>qi(e,o,n.length,t.colorBy)),r=t.rounded?"round":"butt",s=Zi({start:t.start,end:t.end}),l=n.map((e,n)=>{const l=e.available?I``:Z;return I``,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` + stroke-linecap=${i} style=${s}> ${l} - `}),c="none"===t.center?void 0:Gi(n,t.center,e.hass),d=c?er(c.text,c.unit,ot(e.hass,`parts_${t.center}`)):Z,h=n.map((e,t)=>({index:t,entity:e,color:i[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: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`
- + ${l}
${d}
- ${Ji(h,e)} -
`}};function Or(e){const[t,n]=e;return Array.from({length:10},(e,o)=>{const a=(o+.5)/10;return a>t&&a<=n})}const Lr={layout:"rows",group:"bars",height:50,minColumnWidth:180,styles:i` + ${Is(h,e)} +
`}},xl={layout:"chart",group:"distribution",height:240,styles:Wr,thumbnail:()=>V` + + + + + + + + `,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` /* 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 { @@ -1556,24 +1878,24 @@ const Qe=me(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?Or(e.span):Or([0,0]),a=t.target&&e.available&&null!==e.targetPosition?Math.min(9,Math.max(0,Math.floor(10*e.targetPosition))):-1,i=!e.available;return R`
+ `,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`
${t.icon?R``:Z} + .icon=${Ya(e)} + >`:W}
- ${t.name||i?R`
- ${t.name?R`${e.name}`:Z} - ${i?R`${e.display}`:Z} -
`:Z} + ${t.name||r?R`
+ ${t.name?R`${e.name}`:W} + ${r?R`${e.display}`:W} +
`:W}
${o.map((e,t)=>R``)}
- ${t.value?R`${e.available?e.display:""}`:Z} -
`}},zr={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:i` + ${t.value?R`${e.available?e.display:""}`:W} +
`}},kl={layout:"tiles",group:"bars",height:230,tilesPerRow:4,minColumnWidth:80,styles:r` .style-tank .entity { display: flex; flex-direction: column; @@ -1640,15 +1962,15 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="icon" .hass=${n} .stateObj=${e.stateObj} - .icon=${Va(e)} - >`:Z} - ${t.value||!e.available?R`${e.display}`:Z} + .icon=${Ya(e)} + >`:W} + ${t.value||!e.available?R`${e.display}`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- ${t.name?R`${e.name}`:Z} - `},Tr={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:i` + ${t.name?R`${e.name}`:W} + `},Ml={layout:"rows",group:"bars",height:44,minColumnWidth:140,styles:r` .style-thin .entity { display: flex; align-items: center; @@ -1719,19 +2041,19 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de class="icon" .hass=${n} .stateObj=${e.stateObj} - .icon=${Va(e)} - >`:Z} + .icon=${Ya(e)} + >`:W}
${t.name||o?R`
- ${t.name?R`${e.name}`:Z} - ${o?R`${e.display}`:Z} -
`:Z} + ${t.name?R`${e.name}`:W} + ${o?R`${e.display}`:W} +
`:W}
- ${t.target&&null!==e.targetPosition&&e.available?R`
`:Z} + ${t.target&&null!==e.targetPosition&&e.available?R`
`:W}
- `}};tt("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 Nr=["one","each"],jr=["series","bands"];function Pr(e){if(1===e.length){const t=e[0];return t.available?[Ii([t.position,1-t.position])[0]]:[0]}return Ii(Ri(e),100)}function Ur(e,t){const{col:n,row:o}=function(e){return{col:e%10,row:9-Math.floor(e/10)}}(e);return I``}function Fr(e,t,n,o){let a=0;const i=e.map((e,o)=>{const i=a;if(a+=e,!e)return Z;const r=n[o];return I` - ${Array.from({length:e},(e,n)=>Ur(i+n,t[o]))} - `}),r=Array.from({length:100-a},(e,t)=>Ur(a+t,void 0));return R` - ${i} - ${r} - `}const Hr=[{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 Dr={layout:"chart",group:"totals",height:210,styles:i` - ${ir} + >`}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` + ${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} .waffle-box { flex: none; width: 180px; @@ -1832,31 +2154,31 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de color: var(--secondary-text-color); font-size: 0.75em; } - `,thumbnail:()=>I` - ${Array.from({length:25},(e,t)=>{const n=t%5,o=4-Math.floor(t/5);return I``})}`,validate(e){tr(e,"waffles",Nr),tr(e,"color_by",jr),or(e,"headline"),or(e,"show_percent")},editor:function(e){const t=Aa("waffle",e.hass,()=>[{type:"grid",name:"",schema:[{name:"waffles",selector:ya(e.hass,"waffles",Nr)},{name:"color_by",selector:ya(e.hass,"color_by",jr)}]},{name:"headline",selector:{boolean:{}}},{name:"show_percent",selector:{boolean:{}}}]);return Ea(e,"chart",t,Hr)},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 i=qi(o,a,e.items.length,t.colorBy),[r]=Pr([o]),s=Ki(r,e.hass),l=n.value&&t.percent&&o.available&&o.display!==s;return R`
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`
- ${Fr([r],[i],[o],o.label)} - ${n.name?R`${o.name}`:Z} + ${zl([i],[r],[o],o.label)} + ${n.name?R`${o.name}`:W} ${o.available&&t.percent?s:o.display} - ${l?R`${o.display}`:Z} + ${l?R`${o.display}`:W}
`})} -
`}(e,t):function(e,t){const n=e.items,o=n.map((e,o)=>qi(e,o,n.length,t.colorBy)),a=Pr(n),i=e=>a[e]>0||1===n.length&&n[e].available,r=n.map((r,s)=>({index:s,entity:r,color:o[s],share:!t.percent||!i(s)||t.headline&&1===n.length?void 0:Ki(a[s],e.hass)})),s=n[0],l=t.headline&&s&&i(0)?R`
- ${Ki(a[0],e.hass)} +
`}(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)} ${s.name} -
`:Z;return R`
-
${Fr(a,o,n,Qi(e,"editor_style_waffle"))}
-
${l}${Ji(r,e)}
-
`}(e,t)}},Br=["bars","gauges","history","totals","compare","distribution"];function Vr(e){return"chart"===e.layout}const Rr={bar:Ga,thin:Tr,segmented:Lr,tank:zr,ring:wr,needle:Vi,arc:kr,line:xi,column:_i,area:Fa,combined:Li,pie:gr,rings:Cr,pictograph:cr,waffle:Dr},Ir="important",Wr=" !"+Ir,Zr=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,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(Wr);o.includes("-")||t?n.setProperty(o,t?e.slice(0,-11):e,t?Ir:""):n[o]=e}}return W}}),qr=Object.keys(Ne),Yr=Object.keys(Ce),Gr="custom",Xr={text:{}},Jr={entity:{domain:["sensor","input_number","number"]}}; +
`: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"]}}; /** * @license * Copyright 2018 Google LLC * SPDX-License-Identifier: BSD-3-Clause - */function Kr(e){return"string"==typeof e&&""!==e.trim()&&!Number.isFinite(Number(e))}const Qr={icon:{}};function es(e){return void 0===e?"none":qr.find(t=>Ne[t]&&Dt(Ne[t],e))??"custom"}function ts(e){return Yr.includes(e)}class ns 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 ot(this.hass,e)}_getSelectors(){const e=`${nt(this.hass)}|${this.unit??""}`;if(this._selectors?.key===e)return this._selectors;const t=qr.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:Gr,label:this._t("editor_preset_custom")}]),color:n([...Yr,Gr].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===Gr||!qr.includes(t))return;if(t===es(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);Kr(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!==Gr?(a.delete(e),this._customOpen=a,n!==o&&this._updateBand(e,"color",n)):ts(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:Kr(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=!ts(e.color)||this._customOpen.has(t),a=Kr(e.from)?e.from:void 0,i=void 0!==a||this._entityOpen.has(t);return R`
- + */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`
+ this._fromChanged(t,e)} > this._colorChanged(t,e)} > this._labelChanged(t,e)} > @@ -1890,15 +2212,15 @@ const Qe=me(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=${Qr} + .selector=${ql} .value=${e.icon??""} @value-changed=${e=>this._iconChanged(t,e)} > this._toggleFromMode(t)} > this._removeBand(t)} > - ${i?R`this._fromEntityChanged(t,e)} - >`:Z} + >`:W} ${o?R`this._customColorChanged(t,e)} - >`:Z} -
`}render(){const e=this._getSelectors(),t=es(this.bands);return R``:W} +
`}render(){const e=this._getSelectors(),t=Wl(this.bands);return R` ${(this.bands??[]).map((t,n)=>this._renderBand(t,n,e))} - `}}ns.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},ns.styles=i` + `}}Yl.properties={hass:{attribute:!1},bands:{attribute:!1},unit:{attribute:!1},_customOpen:{state:!0},_entityOpen:{state:!0}},Yl.styles=r` :host { display: block; } @@ -2004,7 +2326,7 @@ const Qe=me(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",ns);const os={number:{mode:"box",step:"any"}},as={entity:{domain:["sensor","input_number","number"]}};function is(e){return null==e||""===e?"default":"number"==typeof e||""!==e.trim()&&Number.isFinite(Number(e))?"number":"entity"}class rs extends le{willUpdate(e){e.has("value")&&"default"!==is(this.value)&&(this._mode=void 0)}_modeSelectorFor(){const e=(e,t)=>ot(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??is(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=>ot(this.hass,e),t=this._currentMode;return R`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``:"entity"===t?R``:Z}`}}rs.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},rs.styles=i` + >`:W}`}}Kl.properties={hass:{attribute:!1},label:{attribute:!1},value:{attribute:!1},defaultLabel:{attribute:!1},_mode:{state:!0}},Kl.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",rs);const ss=["min","max","target"];function ls(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 cs extends le{constructor(){super(),this._computeLabel=e=>this._t(`editor_${e.name}`),this._expanded=new Set}_t(e,t){return ot(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&&(at(o)||"string"!=typeof o))return e;const a=[...e.entities??[]],i=a[t];if(void 0===i)return e;const r="string"==typeof i?{entity:i}:{...i};at(o)?delete r[n]:r[n]=o;const s=Object.keys(r);return a[t]=1===s.length&&"entity"===s[0]&&"string"==typeof r.entity?r.entity:r,{...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,i=Vt(a,t,n.detail?.value??{});if(i===a)return;const r=[...this.entities??[]],s=Object.keys(i);r[e]=1===s.length&&"entity"===s[0]?i.entity:i,this._emit({type:"",entities:r})}_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 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._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=Ot.get(e);return t||(t=[{name:"attribute",selector:{attribute:{entity_id:e}}}],Ot.set(e,t)),t}(e.entity),$t)} - ${this._form("entity-text",t,e,Ct,bt)} - ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):Z} + ${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)} + ${this.chartFields?this._form("entity-chart",t,e,this.chartFields.schema,this.chartFields.fields):W} this._set(t,"icon",e))} > - ${ss.map(o=>R`R`this._set(t,"severity",e))} > - ${this._form("entity-actions",t,e,St,At)} -
`}_renderRow(e,t,n){const o=function(e){return"string"==typeof e?e:e.entity}(e),a="string"==typeof e?{entity:e}:e,i=this._rowLabel(o),r=this._expanded.has(t);return R`
- ${i.text} + ${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`
+ ${r.text}
{e.stopPropagation(),this._set(t,"entity",e.detail?.value),this.requestUpdate()}} > @@ -2100,12 +2422,12 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de this._toggle(t)} >
- ${r?this._renderOverrides(a,t):Z} + ${i?this._renderOverrides(a,t):W}
`}render(){const e=this.entities??[];return R`${e.map((t,n)=>this._renderRow(t,n,e.length))} `}}cs.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},cs.styles=i` + >`}}tc.properties={hass:{attribute:!1},entities:{attribute:!1},unit:{attribute:!1},chartFields:{attribute:!1},_expanded:{state:!0}},tc.styles=r` :host { display: block; } @@ -2166,29 +2488,29 @@ const Qe=me(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",cs);class ds extends le{constructor(){super(...arguments),this._label=e=>ot(this.hass,e),this._computeLabel=e=>ot(this.hass,`editor_${e.name}`),this._computeRuleLabel=e=>ot(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(Vt(this._config,e,n))}_form(e,t,n){return R`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`this._formChanged(n,e)} >`}_panel(e,t){return R`
${t}
-
`}_subChanged(e,t){t.stopPropagation(),this._config&&this._commit(st(this._config,e,t.detail?.value))}_chartFields(){const e=Rr[this._config?.style??"bar"];return e&&Vr(e)&&this._config?e.entityFields?.(this.hass,this._config):void 0}renderEntities(){return R``}_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`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 i of Nt[e]){const t=`${e}_${i}`,r=Array.isArray(n[t])?n[t]:[];Dt(o[t]??[],r)||(a=st(a,[e,i],r.length?r: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],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._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(yt)}`)}renderColours(){return R`${this._toggles(vt)}this._subChanged(["severity"],e)} - >`}_firstEntityUnit(){const e=this._config?.entities?.[0];return"string"==typeof e?ls(this.hass,e):e?.unit??ls(this.hass,e?.entity,e?.attribute)}_selectStyle(e){this._config&&this._commit(st(this._config,["style"],"bar"===e?void 0:e))}renderStyle(){const e=this._config?.style??"bar";return R`${Br.map(t=>{const n=Re.filter(e=>Rr[e].group===t);return n.length?R`
${this._label(`editor_group_${t}`)}
+ >
`}_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}`)}
- ${n.map(t=>{const n=Rr[t],o=Vr(n)?R`${this._label(`editor_style_${t}`)} `})} -
`:Z})}`}renderChart(){const e=Rr[this._config?.style??"bar"];return e&&Vr(e)&&e.editor&&this._config?this._panel("chart",e.editor({hass:this.hass,config:this._config,commit:e=>this._commit(e)})):Z}renderDisplay(){return R`${this._toggles(gt)} - ${this._form("display",function(e){const t=nt(e);return Ft?.language!==t&&(Ft={language:t,schema:Tt(e)}),Ft.schema}(this.hass),_t)}`}_toggles(e){const t=Bt(this._config,e);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}this._toggle(e,n,t.target.checked)} >
`})} -
`}_toggle(e,t,n){if(!this._config)return;const o=Bt(this._config,e);this._commit(Vt(this._config,e,{...o,[t]:n}))}renderActions(){return this._form("actions",St,vt)}render(){return this._config?R`
${this._form("title",Et,ft)}
+
`}_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)}
${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())}`:Z}}ds.properties={hass:{attribute:!1},_config:{state:!0}},ds.styles=i` + ${this._panel("actions",this.renderActions())}`:W}}nc.properties={hass:{attribute:!1},_config:{state:!0}},nc.styles=r` :host { display: block; } @@ -2311,7 +2633,7 @@ const Qe=me(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",ds);const hs=i` + `,customElements.get("chartdeck-card-editor")||customElements.define("chartdeck-card-editor",nc);const oc=r` :host { display: block; --cd-duration: 900ms; @@ -2414,29 +2736,29 @@ const Qe=me(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.4.0 ","color:#fff;background:#3b8bff;font-weight:700","color:#3b8bff");const us={rows:16,tiles:8};function ps(e){const t=us[e.layout];return Math.max(1,Math.floor((368+t)/((e.minColumnWidth??0)+t)))}class ms 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(!Re.includes(t))throw new Error(`Unknown style "${String(t)}". Use one of: ${Re.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)=>Ke(t,e,n)),a={name:!0,value:!0,icon:!0,target:!0,legend:!1,...e.show},i=Ye(e.columns,"columns",1),r=Ye(e.limit,"limit",1),s=Pe(e.include,"include"),l=Pe(e.exclude,"exclude");s&&Ke("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:Ge(e.severity)??Ie};return void 0!==e.title&&(c.title=e.title),void 0!==i&&(c.columns=i),void 0!==r&&(c.limit=r),s&&(c.include=s),l&&(c.exclude=l),c}(e),n=Rr[t.style];if(Vr(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),Vr(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 Rr[this._styleName]}_legend(e){const t=this._config;if(!t?.show.legend||0===this._count())return[];const n=e?.decimals??1;return function(e,t,n){const o=n?` ${n}`:"",a=[];return e.forEach((n,i)=>{const r=e[i+1],s=Oe(n.color);n.label?a.push({color:s,text:n.label}):Number.isFinite(n.from)?r?a.push({color:s,text:`${t(n.from)}–${t(r.from)}${o}`}):a.push({color:s,text:`${t(n.from)}${o}+`}):r&&a.push({color:s,text:`< ${t(r.from)}${o}`})}),a}(Za(t.legendBands,this.hass),e=>Fo(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(Vr(t))return n+t.height;const o=this._count(),a=void 0!==e.columns?Math.min(e.columns,ps(t)):"rows"===t.layout?1:Math.min(o,t.tilesPerRow??3,ps(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(Vr(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=Ya(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?Rr[this._config.style]:void 0;e&&Vr(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?Rr[this._config.style]:void 0;e&&Vr(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,Se(n,()=>this._items[Number(n.dataset.index)].config))}_renderEntity(e,t,n,o){const a=this._config,i=Ee(e.config),r=function(e){const t=Oe(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:${r.start};--c-end:${r.end}`;return R`
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`
${n.render(e,{show:a.show,hass:o})} -
`}render(){const e=this._config;if(!e)return Z;const t=this.hass,n=this._style;let o,a,i;const r=t?function(e,t){const n=Ya(e,t).map((t,n)=>Ke(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?Ya(e,t):[],0!==e.entities.length||e.include)if(t)if(0===r.length)this._items=[],o=R`
${ot(t,e.include?"no_matches":"none_available")}
`;else{a=r[0];const s=new Map,l=new Set;r.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)}(r,e.sort).slice(0,e.limit);const c=this._items.filter(e=>e.available).map(e=>e.display.length);if(i=c.length?Math.max(...c):void 0,Vr(n))o=R`
+
`}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,i=function(e,t){if(void 0===t)return"rows"===e.layout?"grid-template-columns: repeat(1, minmax(0, 1fr))":"";const n=(t-1)*us[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`
`;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`
${Me(this._items,e=>s.get(e),(e,o)=>this._renderEntity(e,o,n,t))} -
`}}else this._items=[],o=Z;else this._items=[],o=R`
${ot(t,"no_entities")}
`;const s=this._legend(a);return R` +
`}}else this._items=[],o=W;else this._items=[],o=R`
${at(t,"no_entities")}
`;const s=this._legend(a);return R`
${o}
@@ -2444,5 +2766,5 @@ const Qe=me(class extends fe{constructor(e){if(super(e),e.type!==ue&&e.type!==de ${s.map(e=>R`${e.text}`)} -
`:Z} - `}}ms.properties={hass:{attribute:!1},preview:{type:Boolean},_config:{state:!0}},ms.styles=[hs,...Object.values(Rr).map(e=>e.styles)],customElements.get("chartdeck-card")||customElements.define("chartdeck-card",ms);const fs=window.customCards??=[];fs.some(e=>"chartdeck-card"===e.type)||fs.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{ms as ChartdeckCard}; + `: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}; diff --git a/src/types.ts b/src/types.ts index bb7e564..6c7cbbe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,25 @@ 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'; + | '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',