mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-04 01:10:33 -06:00
Improve sampling in trend feature and sensor card (#27190)
This commit is contained in:
parent
faf6cb6333
commit
29bc894dbd
@ -1,21 +1,22 @@
|
||||
import type { LineSeriesOption } from "echarts";
|
||||
|
||||
export function downSampleLineData(
|
||||
data: LineSeriesOption["data"],
|
||||
chartWidth: number,
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
data: T[] | undefined,
|
||||
maxDetails: number,
|
||||
minX?: number,
|
||||
maxX?: number
|
||||
) {
|
||||
if (!data || data.length < 10) {
|
||||
return data;
|
||||
): T[] {
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const width = chartWidth * window.devicePixelRatio;
|
||||
if (data.length <= width) {
|
||||
if (data.length <= maxDetails) {
|
||||
return data;
|
||||
}
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.floor((max - min) / width);
|
||||
const step = Math.ceil((max - min) / Math.floor(maxDetails));
|
||||
const frames = new Map<
|
||||
number,
|
||||
{
|
||||
@ -47,7 +48,7 @@ export function downSampleLineData(
|
||||
}
|
||||
|
||||
// Convert frames back to points
|
||||
const result: typeof data = [];
|
||||
const result: T[] = [];
|
||||
for (const [_i, frame] of frames) {
|
||||
// Use min/max points to preserve visual accuracy
|
||||
// The order of the data must be preserved so max may be before min
|
||||
|
||||
@ -805,7 +805,7 @@ export class HaChartBase extends LitElement {
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
this.clientWidth,
|
||||
this.clientWidth * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
|
||||
@ -43,6 +43,8 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
@state() private _coordinates?: [number, number][];
|
||||
|
||||
@state() private _yAxisOrigin?: number;
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
static getStubConfig(): TrendGraphCardFeatureConfig {
|
||||
@ -105,7 +107,10 @@ class HuiHistoryChartCardFeature
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<hui-graph-base .coordinates=${this._coordinates}></hui-graph-base>
|
||||
<hui-graph-base
|
||||
.coordinates=${this._coordinates}
|
||||
.yAxisOrigin=${this._yAxisOrigin}
|
||||
></hui-graph-base>
|
||||
`;
|
||||
}
|
||||
|
||||
@ -123,14 +128,15 @@ class HuiHistoryChartCardFeature
|
||||
return subscribeHistoryStatesTimeWindow(
|
||||
this.hass!,
|
||||
(historyStates) => {
|
||||
this._coordinates =
|
||||
const { points, yAxisOrigin } =
|
||||
coordinatesMinimalResponseCompressedState(
|
||||
historyStates[this.context!.entity_id!],
|
||||
hourToShow,
|
||||
500,
|
||||
2,
|
||||
undefined
|
||||
) || [];
|
||||
this.clientWidth,
|
||||
this.clientHeight,
|
||||
this.clientWidth / 5 // sample to 1 point per 5 pixels
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
},
|
||||
hourToShow,
|
||||
[this.context!.entity_id!]
|
||||
|
||||
@ -1,134 +1,85 @@
|
||||
import { strokeWidth } from "../../../../data/graph";
|
||||
import { downSampleLineData } from "../../../../components/chart/down-sample";
|
||||
import type { EntityHistoryState } from "../../../../data/history";
|
||||
|
||||
const average = (items: any[]): number =>
|
||||
items.reduce((sum, entry) => sum + parseFloat(entry.state), 0) / items.length;
|
||||
|
||||
const lastValue = (items: any[]): number =>
|
||||
parseFloat(items[items.length - 1].state) || 0;
|
||||
|
||||
const calcPoints = (
|
||||
history: any,
|
||||
hours: number,
|
||||
history: [number, number][],
|
||||
width: number,
|
||||
detail: number,
|
||||
min: number,
|
||||
max: number
|
||||
): [number, number][] => {
|
||||
const coords = [] as [number, number][];
|
||||
const height = 80;
|
||||
let yRatio = (max - min) / height;
|
||||
yRatio = yRatio !== 0 ? yRatio : height;
|
||||
let xRatio = width / (hours - (detail === 1 ? 1 : 0));
|
||||
xRatio = isFinite(xRatio) ? xRatio : width;
|
||||
|
||||
let first = history.filter(Boolean)[0];
|
||||
if (detail > 1) {
|
||||
first = first.filter(Boolean)[0];
|
||||
}
|
||||
let last = [average(first), lastValue(first)];
|
||||
|
||||
const getY = (value: number): number =>
|
||||
height + strokeWidth / 2 - (value - min) / yRatio;
|
||||
|
||||
const getCoords = (item: any[], i: number, offset = 0, depth = 1) => {
|
||||
if (depth > 1 && item) {
|
||||
return item.forEach((subItem, index) =>
|
||||
getCoords(subItem, i, index, depth - 1)
|
||||
);
|
||||
height: number,
|
||||
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
|
||||
) => {
|
||||
let yAxisOrigin = height;
|
||||
let minY = limits?.minY ?? history[0][1];
|
||||
let maxY = limits?.maxY ?? history[0][1];
|
||||
const minX = limits?.minX ?? history[0][0];
|
||||
const maxX = limits?.maxX ?? history[history.length - 1][0];
|
||||
history.forEach(([_, stateValue]) => {
|
||||
if (stateValue < minY) {
|
||||
minY = stateValue;
|
||||
} else if (stateValue > maxY) {
|
||||
maxY = stateValue;
|
||||
}
|
||||
|
||||
const x = xRatio * (i + offset / 6);
|
||||
|
||||
if (item) {
|
||||
last = [average(item), lastValue(item)];
|
||||
}
|
||||
const y = getY(item ? last[0] : last[1]);
|
||||
return coords.push([x, y]);
|
||||
};
|
||||
|
||||
for (let i = 0; i < history.length; i += 1) {
|
||||
getCoords(history[i], i, 0, detail);
|
||||
});
|
||||
const rangeY = maxY - minY || minY * 0.1;
|
||||
if (maxY < 0) {
|
||||
// all values are negative
|
||||
// add margin
|
||||
maxY += rangeY * 0.1;
|
||||
maxY = Math.min(0, maxY);
|
||||
yAxisOrigin = 0;
|
||||
} else if (minY < 0) {
|
||||
// some values are negative
|
||||
yAxisOrigin = (maxY / (maxY - minY || 1)) * height;
|
||||
} else {
|
||||
// all values are positive
|
||||
// add margin
|
||||
minY -= rangeY * 0.1;
|
||||
minY = Math.max(0, minY);
|
||||
}
|
||||
|
||||
coords.push([width, getY(last[1])]);
|
||||
return coords;
|
||||
const yDenom = maxY - minY || 1;
|
||||
const xDenom = maxX - minX || 1;
|
||||
const points: [number, number][] = history.map((point) => {
|
||||
const x = ((point[0] - minX) / xDenom) * width;
|
||||
const y = height - ((point[1] - minY) / yDenom) * height;
|
||||
return [x, y];
|
||||
});
|
||||
points.push([width, points[points.length - 1][1]]);
|
||||
return { points, yAxisOrigin };
|
||||
};
|
||||
|
||||
export const coordinates = (
|
||||
history: any,
|
||||
hours: number,
|
||||
history: [number, number][],
|
||||
width: number,
|
||||
detail: number,
|
||||
limits?: { min?: number; max?: number }
|
||||
): [number, number][] | undefined => {
|
||||
history.forEach((item) => {
|
||||
item.state = Number(item.state);
|
||||
});
|
||||
history = history.filter((item) => !Number.isNaN(item.state));
|
||||
height: number,
|
||||
maxDetails: number,
|
||||
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
|
||||
) => {
|
||||
history = history.filter((item) => !Number.isNaN(item[1]));
|
||||
|
||||
const min =
|
||||
limits?.min !== undefined
|
||||
? limits.min
|
||||
: Math.min(...history.map((item) => item.state));
|
||||
const max =
|
||||
limits?.max !== undefined
|
||||
? limits.max
|
||||
: Math.max(...history.map((item) => item.state));
|
||||
const now = new Date().getTime();
|
||||
|
||||
const reduce = (res, item, point) => {
|
||||
const age = now - new Date(item.last_changed).getTime();
|
||||
|
||||
let key = Math.abs(age / (1000 * 3600) - hours);
|
||||
if (point) {
|
||||
key = (key - Math.floor(key)) * 60;
|
||||
key = Number((Math.round(key / 10) * 10).toString()[0]);
|
||||
} else {
|
||||
key = Math.floor(key);
|
||||
}
|
||||
if (!res[key]) {
|
||||
res[key] = [];
|
||||
}
|
||||
res[key].push(item);
|
||||
return res;
|
||||
};
|
||||
|
||||
history = history.reduce((res, item) => reduce(res, item, false), []);
|
||||
if (detail > 1) {
|
||||
history = history.map((entry) =>
|
||||
entry.reduce((res, item) => reduce(res, item, true), [])
|
||||
);
|
||||
}
|
||||
|
||||
if (!history.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return calcPoints(history, hours, width, detail, min, max);
|
||||
const sampledData: [number, number][] = downSampleLineData(
|
||||
history,
|
||||
maxDetails,
|
||||
limits?.minX,
|
||||
limits?.maxX
|
||||
);
|
||||
return calcPoints(sampledData, width, height, limits);
|
||||
};
|
||||
|
||||
interface NumericEntityHistoryState {
|
||||
state: number;
|
||||
last_changed: number;
|
||||
}
|
||||
|
||||
export const coordinatesMinimalResponseCompressedState = (
|
||||
history: EntityHistoryState[],
|
||||
hours: number,
|
||||
history: EntityHistoryState[] | undefined,
|
||||
width: number,
|
||||
detail: number,
|
||||
limits?: { min?: number; max?: number }
|
||||
): [number, number][] | undefined => {
|
||||
if (!history) {
|
||||
return undefined;
|
||||
height: number,
|
||||
maxDetails: number,
|
||||
limits?: { minX?: number; maxX?: number; minY?: number; maxY?: number }
|
||||
) => {
|
||||
if (!history?.length) {
|
||||
return { points: [], yAxisOrigin: 0 };
|
||||
}
|
||||
const numericHistory: NumericEntityHistoryState[] = history.map((item) => ({
|
||||
state: Number(item.s),
|
||||
const mappedHistory: [number, number][] = history.map((item) => [
|
||||
// With minimal response and compressed state, we don't have last_changed,
|
||||
// so we use last_updated since its always the same as last_changed since
|
||||
// we already filtered out states that are the same.
|
||||
last_changed: item.lu * 1000,
|
||||
}));
|
||||
return coordinates(numericHistory, hours, width, detail, limits);
|
||||
item.lu * 1000,
|
||||
Number(item.s),
|
||||
]);
|
||||
return coordinates(mappedHistory, width, height, maxDetails, limits);
|
||||
};
|
||||
|
||||
@ -6,20 +6,26 @@ import { getPath } from "../common/graph/get-path";
|
||||
|
||||
@customElement("hui-graph-base")
|
||||
export class HuiGraphBase extends LitElement {
|
||||
@property() public coordinates?: any;
|
||||
@property({ attribute: false }) public coordinates?: number[][];
|
||||
|
||||
@property({ attribute: "y-axis-origin", type: Number })
|
||||
public yAxisOrigin?: number;
|
||||
|
||||
@state() private _path?: string;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const width = this.clientWidth || 500;
|
||||
const height = this.clientHeight || width / 5;
|
||||
const yAxisOrigin = this.yAxisOrigin ?? height;
|
||||
return html`
|
||||
${this._path
|
||||
? svg`<svg width="100%" height="100%" viewBox="0 0 500 100" preserveAspectRatio="none">
|
||||
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
|
||||
<g>
|
||||
<mask id="fill">
|
||||
<path
|
||||
class='fill'
|
||||
fill='white'
|
||||
d="${this._path} L 500, 100 L 0, 100 z"
|
||||
d="${this._path} L ${width}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
|
||||
/>
|
||||
</mask>
|
||||
<rect height="100%" width="100%" id="fill-rect" fill="var(--accent-color)" mask="url(#fill)"></rect>
|
||||
@ -38,7 +44,7 @@ export class HuiGraphBase extends LitElement {
|
||||
<rect height="100%" width="100%" id="rect" fill="var(--accent-color)" mask="url(#line)"></rect>
|
||||
</g>
|
||||
</svg>`
|
||||
: svg`<svg width="100%" height="100%" viewBox="0 0 500 100"></svg>`}
|
||||
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@ -153,14 +153,20 @@ export class HuiGraphHeaderFooter
|
||||
// Message came in before we had a chance to unload
|
||||
return;
|
||||
}
|
||||
this._coordinates =
|
||||
coordinatesMinimalResponseCompressedState(
|
||||
combinedHistory[this._config.entity],
|
||||
this._config.hours_to_show!,
|
||||
500,
|
||||
this._config.detail!,
|
||||
this._config.limits
|
||||
) || [];
|
||||
const width = this.clientWidth || this.offsetWidth;
|
||||
// sample to 1 point per hour or 1 point per 5 pixels
|
||||
const maxDetails =
|
||||
this._config.detail! > 1
|
||||
? Math.max(width / 5, this._config.hours_to_show!)
|
||||
: this._config.hours_to_show!;
|
||||
const { points } = coordinatesMinimalResponseCompressedState(
|
||||
combinedHistory[this._config.entity],
|
||||
width,
|
||||
width / 5,
|
||||
maxDetails,
|
||||
{ minY: this._config.limits?.min, maxY: this._config.limits?.max }
|
||||
);
|
||||
this._coordinates = points;
|
||||
},
|
||||
this._config.hours_to_show!,
|
||||
[this._config.entity]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user