Compare commits

...
Author SHA1 Message Date
Petar Petrov 38a2f44e6a Pass entity states to the timeline data transform instead of hass 2026-09-18 17:53:54 +03:00
Petar Petrov b45f70998b Narrow the timeline data transform's hass parameter to the states it reads 2026-09-18 17:45:19 +03:00
Petar Petrov b9b0326d9c Downsample the history timeline to the chart's pixel width
The timeline emitted one rectangle per state change with no downsampling, so an
entity with tens of thousands of recorded changes produced tens of thousands of
rectangles and blocked the main thread for seconds inside ECharts' layout.

Bound the output by the chart's device-pixel width, the way ha-chart-base
already does for line series: segments at least one frame wide keep their exact
bounds, and runs of narrower ones resolve per frame to the state covering most
of that frame, merging with neighbours that resolve to the same state. Charts
whose state changes are all wider than a frame, which is the common case, keep
exactly the rectangles they had before.

The host also becomes a block box so it reports its own width; as an inline box
its clientWidth was always 0.
2026-09-18 17:36:26 +03:00
Petar Petrov bed1f67b3b Extract the history timeline chart data transform
Move the body of state-history-chart-timeline's _generateData into
state-history-chart-timeline-data.ts, mirroring the line chart's
state-history-chart-line-data.ts. Environment inputs are passed in, so the
transform is a pure function that can be tested and benchmarked on its own.

No behaviour change.
2026-09-18 17:13:38 +03:00
5 changed files with 565 additions and 107 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
].flat();
// Always rounds down, so no chart ends up with fewer frames than it asked for.
function snapFrameSize(step: number): number {
export function snapFrameSize(step: number): number {
if (step >= DAY) {
return Math.floor(step / DAY) * DAY;
}
+1 -1
View File
@@ -57,7 +57,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
const DEFAULT_CHART_WIDTH = 500;
export const DEFAULT_CHART_WIDTH = 500;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
@@ -0,0 +1,258 @@
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
} from "echarts/types/dist/shared";
import type { HassEntities } from "home-assistant-js-websocket";
import { hex2rgb } from "../../common/color/convert-color";
import { luminosity } from "../../common/color/rgb";
import type { TimelineEntity } from "../../data/history";
import { snapFrameSize } from "./down-sample";
import { computeTimelineColor } from "./timeline-color";
export interface StateHistoryChartTimelineDataParams {
states: HassEntities;
data: TimelineEntity[];
startTime: Date;
endTime: Date;
names?: Record<string, string>;
showNames: boolean;
computedStyles: CSSStyleDeclaration;
renderItem: CustomSeriesRenderItem;
/** Chart width in device pixels; bounds how many rectangles are emitted. */
chartWidth: number;
}
export interface TimelineSegment {
state: string;
locState: string | null;
start: number;
end: number;
}
/** Resolves each frame of a run to the state covering most of that frame. */
function collapseRun(
segments: TimelineSegment[],
from: number,
to: number,
frameMs: number,
push: (segment: TimelineSegment) => void
) {
const runEnd = segments[to - 1].end;
const frameStates = new Map<
string,
{ duration: number; locState: string | null }
>();
let frameStart = segments[from].start;
let index = from;
while (frameStart < runEnd) {
const boundary = (Math.floor(frameStart / frameMs) + 1) * frameMs;
// a frame size that rounds back onto frameStart would never advance
const next = boundary > frameStart ? boundary : frameStart + frameMs;
const frameEnd = next < runEnd ? next : runEnd;
frameStates.clear();
let bestState: string | null = null;
let bestLocState: string | null = null;
let bestDuration = 0;
// Segments are narrower than a frame, so each is visited at most twice:
// index stops at the one spilling into the next frame.
let cursor = index;
while (cursor < to && segments[cursor].start < frameEnd) {
const segment = segments[cursor];
cursor++;
const overlapStart =
segment.start > frameStart ? segment.start : frameStart;
const overlapEnd = segment.end < frameEnd ? segment.end : frameEnd;
if (overlapEnd <= overlapStart) {
continue;
}
let entry = frameStates.get(segment.state);
if (entry) {
entry.duration += overlapEnd - overlapStart;
} else {
entry = {
duration: overlapEnd - overlapStart,
locState: segment.locState,
};
frameStates.set(segment.state, entry);
}
if (entry.duration > bestDuration) {
bestDuration = entry.duration;
bestState = segment.state;
bestLocState = entry.locState;
}
}
while (index < to && segments[index].end <= frameEnd) {
index++;
}
if (bestState !== null) {
push({
state: bestState,
locState: bestLocState,
start: frameStart,
end: frameEnd,
});
}
frameStart = frameEnd;
}
}
/**
* Bounds the rectangle count by the chart's pixel width. Segments at least one
* frame wide are kept as they are; narrower ones are resolved per frame, and
* neighbours resolving to the same state merge into one rectangle.
*/
export function downSampleTimelineSegments(
segments: TimelineSegment[],
frameMs: number
): TimelineSegment[] {
if (!(frameMs > 0)) {
return segments;
}
const result: TimelineSegment[] = [];
const push = (segment: TimelineSegment) => {
const last = result[result.length - 1];
if (last && last.state === segment.state && last.end === segment.start) {
last.end = segment.end;
return;
}
result.push(segment);
};
let index = 0;
while (index < segments.length) {
if (segments[index].end - segments[index].start >= frameMs) {
push({ ...segments[index] });
index++;
continue;
}
const from = index;
index++;
// A gap of its own frame or more stays a gap; a narrower one is invisible
// and is absorbed, so that a row of gap-separated slivers stays bounded.
while (
index < segments.length &&
segments[index].end - segments[index].start < frameMs &&
segments[index].start - segments[index - 1].end < frameMs
) {
index++;
}
collapseRun(segments, from, index, frameMs, push);
}
return result;
}
/**
* Transforms processed history (`TimelineEntity[]`) into ECharts custom series
* for `state-history-chart-timeline`. Pure data processing: all environment
* inputs (theme style, entity states, chart width, the render callback) are injected so
* the transform is deterministic and benchmarkable.
*/
export function generateStateHistoryChartTimelineData(
params: StateHistoryChartTimelineDataParams
): CustomSeriesOption[] {
const { states, computedStyles, startTime, endTime, renderItem } = params;
const stateHistory = params.data ?? [];
const startTimeMs = startTime.getTime();
const endTimeMs = endTime.getTime();
// Snapped, and placed on absolute time, so a chart following "now" keeps
// resolving the same frames instead of reshaping on every refresh.
const rawFrameMs = Math.ceil(
(endTimeMs - startTimeMs) / Math.floor(params.chartWidth)
);
const frameMs =
Number.isFinite(rawFrameMs) && rawFrameMs > 0
? snapFrameSize(rawFrameMs)
: 0;
const datasets: CustomSeriesOption[] = [];
const names = params.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTimeMs;
const entityDisplay: string = params.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const segments: TimelineSegment[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = entityState.last_changed;
if (!newState) {
newState = null;
}
if (timeStamp > endTimeMs) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
} else if (newState !== prevState) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: timeStamp,
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = timeStamp;
}
});
if (prevState !== null) {
segments.push({
state: prevState,
locState,
start: prevLastChanged,
end: endTimeMs,
});
}
const stateObj = states[stateInfo.entity_id];
const dataRow = downSampleTimelineSegments(segments, frameMs).map(
(segment) => {
const color = computeTimelineColor(
segment.state,
computedStyles,
stateObj
);
return {
value: [
stateInfo.entity_id,
new Date(segment.start),
new Date(segment.end),
segment.locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
};
}
);
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem,
progressive: 0,
});
});
return datasets;
}
@@ -11,16 +11,14 @@ import millisecondsToDuration from "../../common/datetime/milliseconds_to_durati
import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { generateStateHistoryChartTimelineData } from "./state-history-chart-timeline-data";
const ROW_HEIGHT = 30;
// Taller rows when the name is drawn under the bar instead of in a column.
@@ -315,109 +313,20 @@ export class StateHistoryChartTimeline extends LitElement {
}
private _generateData() {
const computedStyles = getComputedStyle(this);
let stateHistory = this.data;
if (!stateHistory) {
stateHistory = [];
}
this._chartTime = new Date();
const startTime = this.startTime;
const endTime = this.endTime;
const datasets: CustomSeriesOption[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
let newLastChanged: Date;
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
if (!newState) {
newState = null;
}
if (timeStamp > endTime) {
// Drop datapoints that are after the requested endTime. This could happen if
// endTime is 'now' and client time is not in sync with server time.
return;
}
if (prevState === null) {
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = new Date(entityState.last_changed);
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
locState = entityState.state_localize;
prevLastChanged = newLastChanged;
}
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
value: [
stateInfo.entity_id,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
progressive: 0,
});
this._chartData = generateStateHistoryChartTimelineData({
states: this.hass.states,
data: this.data,
startTime: this.startTime,
endTime: this.endTime,
names: this.names,
showNames: this.showNames,
computedStyles: getComputedStyle(this),
renderItem: this._renderItem,
// 0 while inside a hidden container, e.g. a section with a visibility condition
chartWidth:
(this.clientWidth || DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
});
this._chartData = datasets;
}
private _handleChartClick(
@@ -434,6 +343,9 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -0,0 +1,288 @@
import { describe, expect, it } from "vitest";
import type { TimelineSegment } from "../../../src/components/chart/state-history-chart-timeline-data";
import {
downSampleTimelineSegments,
generateStateHistoryChartTimelineData,
} from "../../../src/components/chart/state-history-chart-timeline-data";
import { createMockComputedStyle } from "../../fixtures/computed-style";
import { createMockHass } from "../../fixtures/hass";
import type { TimelineEntity } from "../../../src/data/history";
const segment = (
state: string,
start: number,
end: number
): TimelineSegment => ({ state, locState: state, start, end });
/** Alternating on/off segments covering [start, end) with the given duty. */
const flapping = (
start: number,
end: number,
onMs: number,
offMs: number
): TimelineSegment[] => {
const segments: TimelineSegment[] = [];
let time = start;
while (time < end) {
segments.push(segment("on", time, Math.min(time + onMs, end)));
time += onMs;
if (time >= end) break;
segments.push(segment("off", time, Math.min(time + offMs, end)));
time += offMs;
}
return segments;
};
const spans = (segments: TimelineSegment[]) =>
segments.map((s) => [s.state, s.start, s.end]);
const assertContiguous = (result: TimelineSegment[]) => {
result.forEach((s, i) => {
expect(s.end).toBeGreaterThan(s.start);
if (i > 0) {
expect(s.start).toBe(result[i - 1].end);
}
});
};
describe("downSampleTimelineSegments", () => {
it("leaves segments of at least one frame untouched", () => {
const segments = [
segment("off", 0, 450),
segment("on", 450, 930),
segment("off", 930, 2000),
];
const before = spans(segments);
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual(before);
expect(spans(segments)).toEqual(before);
});
it("keeps a single sub-frame segment straddling a frame boundary intact", () => {
const segments = [segment("on", 90, 150)];
expect(spans(downSampleTimelineSegments(segments, 100))).toEqual([
["on", 90, 150],
]);
});
it("collapses sub-frame runs to the dominant state per frame", () => {
const segments = [
...flapping(0, 500, 15, 5),
...flapping(500, 1000, 5, 15),
];
expect(segments.length).toBe(100);
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([
["on", 0, 500],
["off", 500, 1000],
]);
assertContiguous(result);
});
it("bounds the output by the number of frames and keeps the full span", () => {
const segments = flapping(0, 100_000, 3, 7);
expect(segments.length).toBeGreaterThan(19_000);
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([["off", 0, 100_000]]);
assertContiguous(result);
});
it("keeps exact bounds of a full-width segment between sub-frame runs", () => {
const segments = [
...flapping(0, 450, 15, 5),
segment("unavailable", 450, 1337),
...flapping(1337, 1800, 5, 15),
];
const result = downSampleTimelineSegments(segments, 100);
expect(result).toContainEqual(
expect.objectContaining({
state: "unavailable",
start: 450,
end: 1337,
})
);
assertContiguous(result);
});
it("merges a chosen state into a following full-width segment of that state", () => {
const segments = [
...flapping(0, 400, 15, 5),
segment("on", 400, 2000),
segment("off", 2000, 3000),
];
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([
["on", 0, 2000],
["off", 2000, 3000],
]);
});
it("does not modify the segments it is given", () => {
const segments = [segment("on", 0, 100), segment("on", 100, 200)];
const before = spans(segments);
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([["on", 0, 200]]);
expect(spans(segments)).toEqual(before);
});
it("treats a segment exactly one frame wide as full width", () => {
const segments = [
segment("on", 0, 40),
segment("boiler", 40, 140),
segment("on", 140, 180),
];
expect(spans(downSampleTimelineSegments(segments, 100))).toEqual([
["on", 0, 40],
["boiler", 40, 140],
["on", 140, 180],
]);
});
it("emits nothing for a frame no segment covers", () => {
// zero-duration segments: two state changes sharing a timestamp
const segments = [
segment("a", 0, 1),
segment("z", 99, 99),
segment("y", 198, 198),
segment("b", 297, 298),
];
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([
["a", 0, 100],
["b", 200, 298],
]);
expect(result.every((r) => r.state !== null)).toBe(true);
});
it("preserves a gap between two runs", () => {
const segments = [...flapping(0, 300, 15, 5), ...flapping(500, 800, 15, 5)];
const result = downSampleTimelineSegments(segments, 100);
expect(spans(result)).toEqual([
["on", 0, 300],
["on", 500, 800],
]);
});
});
describe("generateStateHistoryChartTimelineData", () => {
const baseParams = {
states: createMockHass().states,
computedStyles: createMockComputedStyle(),
showNames: true,
renderItem: () => null,
} as const;
/** Alternating states whose dominant flips halfway through the range. */
const flappingEntity = (entityId: string, changes: number) => {
const data: TimelineEntity["data"] = [];
let time = 0;
for (let i = 0; i < changes; i++) {
const on = i % 2 === 0;
const state = on ? "on" : "off";
data.push({ state, state_localize: state, last_changed: time });
const dominant = i < changes / 2 ? on : !on;
time += dominant ? 700 : 300;
}
return { entity_id: entityId, name: entityId, data, end: time };
};
it("bounds the rectangle count by the chart width", () => {
const { end, ...entity } = flappingEntity("binary_sensor.flapping", 60_000);
const result = generateStateHistoryChartTimelineData({
...baseParams,
data: [entity],
startTime: new Date(0),
endTime: new Date(end),
chartWidth: 1000,
});
const data = result[0].data as { value: [string, Date, Date, string] }[];
expect(data.map((d) => [d.value[3], +d.value[1], +d.value[2]])).toEqual([
["on", 0, 15_000_000],
["off", 15_000_000, end],
]);
});
it("bounds rows whose states are separated by sub-frame gaps", () => {
// An empty state resets the state machine, leaving a gap before the next
// one, so these segments are not contiguous.
const changes = 40_000;
const data: TimelineEntity["data"] = [];
for (let i = 0; i < changes; i++) {
const state = i % 2 === 0 ? "" : "on";
data.push({ state, state_localize: state, last_changed: i * 1000 });
}
const result = generateStateHistoryChartTimelineData({
...baseParams,
data: [{ entity_id: "binary_sensor.blips", name: "Blips", data }],
startTime: new Date(0),
endTime: new Date(changes * 1000),
chartWidth: 1000,
});
const rects = result[0].data as { value: [string, Date, Date, string] }[];
expect(rects.map((d) => [d.value[3], +d.value[1], +d.value[2]])).toEqual([
["on", 1000, changes * 1000],
]);
});
// deliberately off the frame grid, so collapsing would move the bounds
const slowData: TimelineEntity["data"] = [0, 1, 2, 3, 4].map((i) => ({
state: i % 2 === 0 ? "on" : "off",
state_localize: i % 2 === 0 ? "On" : "Off",
last_changed: i * 1000 + 137,
}));
it("keeps every rectangle when the chart width rounds down to zero", () => {
const result = generateStateHistoryChartTimelineData({
...baseParams,
data: [{ entity_id: "binary_sensor.slow", name: "Slow", data: slowData }],
startTime: new Date(0),
endTime: new Date(5137),
chartWidth: 0.5,
});
expect((result[0].data as unknown[]).length).toBe(5);
});
it("emits one rectangle per state change when they are wide enough", () => {
const result = generateStateHistoryChartTimelineData({
...baseParams,
data: [{ entity_id: "binary_sensor.slow", name: "Slow", data: slowData }],
startTime: new Date(0),
endTime: new Date(5137),
chartWidth: 1000,
});
expect(
(result[0].data as { value: [string, Date, Date, string] }[]).map((d) => [
d.value[3],
d.value[1],
d.value[2],
])
).toEqual([
["On", new Date(137), new Date(1137)],
["Off", new Date(1137), new Date(2137)],
["On", new Date(2137), new Date(3137)],
["Off", new Date(3137), new Date(4137)],
["On", new Date(4137), new Date(5137)],
]);
});
});