mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-19 11:48:08 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a2f44e6a | ||
|
|
b45f70998b | ||
|
|
b9b0326d9c | ||
|
|
bed1f67b3b |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user