Skip to content
1,012 changes: 667 additions & 345 deletions dist/chartdeck-card.js

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions src/chart/box.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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;
}
19 changes: 12 additions & 7 deletions src/chart/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,27 +108,27 @@ 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`<span class="axis-label ${end}" style="top:${pct(top)}"
>${formatNumber(v, value.decimals, ctx.hass)}</span
>${format ? format(v) : formatNumber(v, value.decimals, ctx.hass)}</span
>`;
});
}

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) => {
const left = scales.x(t) / VIEW_W;
// 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)}"
>${axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}</span
>${format ? format(t) : axes.label ? axes.label(t) : timeLabel(t, axes.unit, range.end - range.start, ctx.hass, offset)}</span
>`;
});
}
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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`<div class="y-axis">${yTicks}</div>` : nothing;
const xLabels = x ? html`<div class="x-axis">${timeLabels(axes, ctx)}</div>` : nothing;
const xLabels = x ? html`<div class="x-axis">${timeLabels(axes, ctx, xLabel)}</div>` : nothing;
const sizer = y
? html`<span class="axis-sizer" aria-hidden="true"
>${axes.value.ticks
.map((v) => formatNumber(v, axes.value.decimals, ctx.hass))
.map((v) => (yLabel ? yLabel(v) : formatNumber(v, axes.value.decimals, ctx.hass)))
.reduce((a, b) => (b.length > a.length ? b : a), '')}</span
>`
: nothing;
Expand Down
141 changes: 141 additions & 0 deletions src/chart/heatmap-grid.ts
Original file line number Diff line number Diff line change
@@ -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<number>(7 * 24).fill(0);
const counts = new Array<number>(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;
Loading
Loading