Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
650 changes: 371 additions & 279 deletions dist/chartdeck-card.js

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions src/chart/columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { ResolvedEntity } from '../resolve';
import { parsePeriod } from '../history/period';
import type { Period } from '../history/period';
import type { GroupUnit } from '../history/group';
import type { Point } from '../history/types';
import type { CardConfig } from '../types';
import { valueAt } from './stack';

/**
* Column maths: which grouping a column chart uses when none is set, how readings line up into
* slots (one per group, or per reading), and where each series' column sits in its slot.
*/

/** Grouping choices a column chart offers: `auto` picks one for the span, `none` draws each reading. */
export const COLUMN_GROUPS = ['auto', 'none', 'hour', 'day', 'week', 'month'] as const;
export const COLUMN_AGGREGATES = ['auto', 'mean', 'sum', 'min', 'max', 'first', 'last', 'change'] as const;

/** A column chart with no period shows the last 7 days: with automatic grouping, a column a day. */
export const COLUMN_PERIOD = { last: 7, unit: 'day' } as const;

const PERIOD_DAYS = { hour: 1 / 24, day: 1, week: 7, month: 30 } as const;

/**
* The group a span reads best in: hours for up to two days, days up to about two months, weeks
* up to half a year, then months. Calendar spans: today by hour, a week or month by day, a year
* by month.
*/
export function autoGroupUnit(period: Period): GroupUnit {
if (period.calendar) return ({ day: 'hour', week: 'day', month: 'day', year: 'month' } as const)[period.calendar];
const days = period.last * PERIOD_DAYS[period.unit];
if (days <= 2) return 'hour';
if (days <= 62) return 'day';
if (days <= 190) return 'week';
return 'month';
}

/** A meter that counts up (energy, water, gas): its columns should show how much it went up. */
export function isMeter(e: ResolvedEntity): boolean {
if (e.config.attribute) return false;
const stateClass = e.stateObj?.attributes?.state_class;
return stateClass === 'total_increasing' || stateClass === 'total';
}

/**
* The card config the shared history frame should read: the column chart's own defaults filled
* in. No period means the last 7 days; `group_by` auto (or unset) picks a unit for the span, and
* `none` draws one column per reading; `aggregate` auto (or unset) is the change of meters when
* every entity is one (energy per day), else the average. Range columns always use the average.
*/
export function columnRaw(raw: CardConfig, items: readonly ResolvedEntity[]): CardConfig {
const out: Record<string, unknown> = { ...raw, period: raw.period ?? { ...COLUMN_PERIOD } };
if (raw.group_by === 'none') {
delete out.group_by;
} else if (raw.group_by === undefined || raw.group_by === 'auto') {
out.group_by = autoGroupUnit(parsePeriod(out.period as CardConfig['period']));
}
if (raw.aggregate === undefined || raw.aggregate === 'auto') {
out.aggregate = raw.range !== true && items.length > 0 && items.every(isMeter) ? 'change' : 'mean';
}
return out as CardConfig;
}

/** Every series' readings at one shared set of slots. */
export interface ColumnTable {
/** Slot starts, in order. */
times: number[];
/** Where each slot ends (the next slot's start, a group's end, or the range's end). */
ends: number[];
/** Per series, its value in each slot: its reading then, or the one it held. */
values: (number | null)[][];
/** Per series, each slot's lowest and highest (statistics, or a grouped range); null if unknown. */
lows: (number | null)[][];
highs: (number | null)[][];
}

/**
* Line series up into slots. Grouped points already share their times; readings that don't are
* read at every time any series has one (a value holds until it changes). A slot ends at its
* group's end, else at the next slot, else at `end`; one with no width (the reading carried to
* the right edge) is dropped.
*/
export function columnTable(series: readonly (readonly Point[])[], end: number): ColumnTable {
const all = [...new Set(series.flatMap((points) => points.map((p) => p.t)))].sort((a, b) => a - b);
const exact = series.map((points) => new Map(points.map((p) => [p.t, p])));
const groupEnd = new Map<number, number>();
for (const points of series) for (const p of points) if (p.end !== undefined) groupEnd.set(p.t, p.end);
const table: ColumnTable = {
times: [],
ends: [],
values: series.map(() => []),
lows: series.map(() => []),
highs: series.map(() => []),
};
all.forEach((t, i) => {
const slotEnd = Math.min(groupEnd.get(t) ?? all[i + 1] ?? end, end);
if (!(slotEnd > t)) return;
table.times.push(t);
table.ends.push(slotEnd);
series.forEach((points, k) => {
const p = exact[k].get(t);
table.values[k].push(p ? p.v : valueAt(points, t));
table.lows[k].push(p?.min ?? null);
table.highs[k].push(p?.max ?? null);
});
});
return table;
}

/** Share of a slot left empty, split either side, so neighbouring slots don't touch. */
export const SLOT_GAP = 0.2;
/** Share of a series' column width left between columns side by side in one slot. */
export const SERIES_GAP = 0.12;

/**
* Where column `k` of `n` side by side sits in a slot from `x0` to `x1` (plot units): its left
* edge and width. One column (or a stack) takes the slot's middle 80 %.
*/
export function columnBox(x0: number, x1: number, k: number, n: number): [left: number, width: number] {
const w = x1 - x0;
const inner = w * (1 - SLOT_GAP);
const each = inner / n;
const pad = n > 1 ? each * SERIES_GAP : 0;
return [x0 + (w * SLOT_GAP) / 2 + k * each + pad / 2, each - pad];
}

/**
* Value-axis ends to fix so columns stand on zero: readings all at or above zero start the axis
* at zero, all at or below end it there. Ends the user set always win.
*/
export function zeroBased(data: [number, number], raw: CardConfig): CardConfig {
const out: Record<string, unknown> = { ...raw };
if (raw.y_min === undefined && data[0] >= 0) out.y_min = 0;
if (raw.y_max === undefined && data[1] <= 0) out.y_max = 0;
return out as CardConfig;
}

/** Lowest and highest of some numbers (nulls skipped), with zero in unless `zero` is false. */
export function spread(values: Iterable<number | null>, zero = true): [number, number] | undefined {
let lo = zero ? 0 : Infinity;
let hi = zero ? 0 : -Infinity;
for (const v of values) {
if (v === null || !Number.isFinite(v)) continue;
lo = Math.min(lo, v);
hi = Math.max(hi, v);
}
return lo === Infinity ? undefined : [lo, hi];
}

/** The time labels to keep so about `fit` of `count` show: every first, every second, … */
export function labelEvery(count: number, fit: number): number {
return Math.max(1, Math.ceil(count / Math.max(1, fit)));
}
16 changes: 12 additions & 4 deletions src/chart/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,14 @@ export function validateHistoryOptions(raw: CardConfig): void {
}
}

/** What a chart asks of its history beyond the readings. */
export interface FrameOptions {
/** Keep each group's lowest and highest (`min`, `max` on its point), for range charts. */
range?: boolean;
}

/** Load (or reuse) the card's history and pair it with its entities. */
export function historyFrame(ctx: ChartContext, now = Date.now()): HistoryFrame {
export function historyFrame(ctx: ChartContext, now = Date.now(), options: FrameOptions = {}): HistoryFrame {
const period = parsePeriod(ctx.raw.period as CardConfig['period']);
const source: Source = (ctx.raw.source as Source | undefined) ?? 'history';
const zone = zoneFor(ctx.hass);
Expand All @@ -102,7 +108,9 @@ export function historyFrame(ctx: ChartContext, now = Date.now()): HistoryFrame
const requests = ctx.items.map((e) => ({ entity: e.entityId, attribute: e.config.attribute }));
const store = storeFor(ctx.host, ctx.requestUpdate);
const statistics =
group && source === 'statistics' ? statisticsQueryFor(group, zone, ctx.hass.config?.time_zone) : undefined;
group && source === 'statistics'
? statisticsQueryFor(group, zone, ctx.hass.config?.time_zone, options.range === true)
: undefined;
// Statistics groups load from one row early: the row before a group gives its `first`.
const load = statistics ? { start: range.start - PERIOD_MS[statistics.period], end: range.end } : range;
const loaded = store.get(ctx.hass, requests, load, source, { live: span.live, statistics });
Expand All @@ -117,8 +125,8 @@ export function historyFrame(ctx: ChartContext, now = Date.now()): HistoryFrame
if (group) {
points =
source === 'statistics'
? groupStatistics(points, bounds, group.aggregate, until)
: groupHistory(points, bounds, group.aggregate, until);
? groupStatistics(points, bounds, group.aggregate, until, options.range === true)
: groupHistory(points, bounds, group.aggregate, until, options.range === true);
} else if (source === 'history' && last && last.v !== null && last.t < until) {
// A sensor holds its last reading until it changes, so carry it on to the right edge.
points = [...points, { t: until, v: last.v }];
Expand Down
4 changes: 3 additions & 1 deletion src/chart/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface PlotAxes {
unit: TimeUnit;
range: Range;
scales: PlotScales;
/** The text under each time label, when a chart names its own (a column's day); else the time's. */
label?: (t: number) => string;
}

/** Work out the axes for readings spanning `data` over `range`, sized to the card. */
Expand Down Expand Up @@ -126,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`<span class="axis-label ${edge}" style="left:${pct(left)}"
>${timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}</span
>${axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}</span
>`;
});
}
Expand Down
45 changes: 40 additions & 5 deletions src/history/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,13 @@ export function increments(points: Point[]): (number | null)[] {
*
* A group with no value held at any time in it is a gap. For sum, a group with no readings is.
*/
export function groupHistory(points: Point[], bounds: number[], aggregate: Aggregate, until: number): Point[] {
export function groupHistory(
points: Point[],
bounds: number[],
aggregate: Aggregate,
until: number,
range = false,
): Point[] {
const ups = aggregate === 'change' ? increments(points) : [];
const out: Point[] = [];
// Index of the first point at or after the group's start.
Expand Down Expand Up @@ -112,6 +118,11 @@ export function groupHistory(points: Point[], bounds: number[], aggregate: Aggre
}[];
if (known.length === 0) continue;
const values = known.map((h) => h.v);
if (range) {
// The group's spread, for range charts: every value that held in it.
group.min = Math.min(...values);
group.max = Math.max(...values);
}
switch (aggregate) {
case 'mean': {
const time = known.reduce((a, h) => a + h.dt, 0);
Expand Down Expand Up @@ -153,7 +164,13 @@ export function groupHistory(points: Point[], bounds: number[], aggregate: Aggre
* reading at the group's start), or the group's first row when there is none. A group that starts
* after `until` (now) is a gap whatever the server sent.
*/
export function groupStatistics(rows: Point[], bounds: number[], aggregate: Aggregate, until = Infinity): Point[] {
export function groupStatistics(
rows: Point[],
bounds: number[],
aggregate: Aggregate,
until = Infinity,
range = false,
): Point[] {
const out: Point[] = [];
let j = 0;
for (let i = 0; i + 1 < bounds.length; i++) {
Expand All @@ -162,8 +179,16 @@ export function groupStatistics(rows: Point[], bounds: number[], aggregate: Aggr
while (j < rows.length && rows[j].t < start) j++;
let k = j;
const values: number[] = [];
const lows: number[] = [];
const highs: number[] = [];
// A row moved to the range's start is the previous period's: only `first` wants it.
for (; k < rows.length && rows[k].t < end; k++) if (rows[k].v !== null && !rows[k].carried) values.push(rows[k].v!);
for (; k < rows.length && rows[k].t < end; k++) {
const row = rows[k];
if (row.v === null || row.carried) continue;
values.push(row.v);
lows.push(row.min ?? row.v);
highs.push(row.max ?? row.v);
}
let v: number | null = null;
if (values.length && start < until) {
if (aggregate === 'mean') v = values.reduce((a, x) => a + x, 0) / values.length;
Expand All @@ -180,7 +205,13 @@ export function groupStatistics(rows: Point[], bounds: number[], aggregate: Aggr
v = (prior ?? carried)?.v ?? values[0];
}
}
out.push({ t: start, end, v });
const group: Point = { t: start, end, v };
// The group's spread, for range charts: lowest of the rows' lows to highest of their highs.
if (range && v !== null) {
group.min = Math.min(...lows);
group.max = Math.max(...highs);
}
out.push(group);
}
return out;
}
Expand Down Expand Up @@ -216,8 +247,12 @@ export function statisticsQueryFor(
grouping: Grouping,
zone: Zone,
serverTimeZone: string | undefined,
range = false,
): StatisticsQuery {
const sameCalendar = zone.timeZone === serverTimeZone && (grouping.unit !== 'week' || zone.firstDay === 1);
const period: StatisticsPeriod = grouping.unit === 'hour' || !sameCalendar ? 'hour' : grouping.unit;
return { period, ...COLUMNS[grouping.aggregate] };
const { types, value } = COLUMNS[grouping.aggregate];
// A range chart also wants each row's lowest and highest.
const wanted: StatisticField[] = range ? [...new Set<StatisticField>([...types, 'min', 'max'])] : types;
return { period, types: wanted, value };
}
Loading
Loading