mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-12 07:54:25 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9ad5ed150 | ||
|
|
27a968d777 | ||
|
|
3be0294867 | ||
|
|
d96dbfaae1 | ||
|
|
ff3b608653 | ||
|
|
96d15065a9 | ||
|
|
ca456105e5 | ||
|
|
9f12b54a14 | ||
|
|
cf25121dda | ||
|
|
71f53ae3e2 | ||
|
|
8843596e42 | ||
|
|
d06ec13af6 | ||
|
|
1c428c1831 | ||
|
|
c20a90cd81 | ||
|
|
f64a602bc8 | ||
|
|
3354b5ffec | ||
|
|
fce263e6ab | ||
|
|
6317dad51b | ||
|
|
c350d46d44 | ||
|
|
e5a43b48c2 | ||
|
|
73a88453db | ||
|
|
fba2451e61 | ||
|
|
c33a452976 | ||
|
|
ec0533f8c5 | ||
|
|
b1b9c41bc1 | ||
|
|
685e7d51d2 | ||
|
|
315afd8ac2 | ||
|
|
bc9d0b5227 | ||
|
|
99c85c3407 | ||
|
|
e04e639100 | ||
|
|
ca7b91af35 | ||
|
|
ffd26ffc1a | ||
|
|
bd4ff71d29 |
No files matched your search
@@ -0,0 +1,19 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -11,7 +13,9 @@ import type {
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -108,6 +112,64 @@ interface Example {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
return html`${EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type {
|
||||
DataZoomComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
@@ -90,14 +93,29 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const CLICK_DRAG_TOLERANCE = 4;
|
||||
// The selection is shown by the mask, not by the brush
|
||||
const BRUSH_STYLE = {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
borderColor: "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
|
||||
export type ChartSelectMode = "none" | "point" | "range";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
"highlight",
|
||||
] as const;
|
||||
const MASK_OPACITY = 0.7;
|
||||
|
||||
export interface ChartTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
|
||||
// "point" selects on click, "range" also selects on drag instead of zooming
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
// Everything outside this time range fades
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
// Rounds a selection to the periods of the data, or drops it
|
||||
@property({ attribute: false }) public snapSelection?: (
|
||||
range: ChartTimeRange
|
||||
) => ChartTimeRange | undefined;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@@ -201,6 +231,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _pointerDownX?: number;
|
||||
|
||||
private _selectingRange?: ChartTimeRange;
|
||||
|
||||
private _maskColor = "";
|
||||
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@@ -454,6 +490,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("selectMode")) {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
@@ -499,6 +538,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
} else if (changedProps.has("highlight")) {
|
||||
this._updateMask();
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -888,6 +929,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
this._maskColor =
|
||||
style.getPropertyValue("--card-background-color").trim() ||
|
||||
style.getPropertyValue("--primary-background-color").trim();
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
@@ -970,6 +1014,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (this.selectMode !== "none") {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
}
|
||||
@@ -1021,7 +1068,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
// A chart can switch type (e.g. pie and bar) without being rebuilt
|
||||
private _updateDragToZoom() {
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
const active =
|
||||
!this._touchInput &&
|
||||
this._hasZoomableXAxis() &&
|
||||
this.selectMode !== "range";
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1033,6 +1083,126 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _snapRange(range: ChartTimeRange) {
|
||||
return this.snapSelection ? this.snapSelection(range) : range;
|
||||
}
|
||||
|
||||
private _getMaskArea(): MarkAreaComponentOption | undefined {
|
||||
const range = this._selectingRange ?? this.highlight;
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
silent: true,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: this._maskColor,
|
||||
opacity: MASK_OPACITY,
|
||||
},
|
||||
data: [
|
||||
[{ xAxis: -Infinity }, { xAxis: range.start }],
|
||||
[{ xAxis: range.end }, { xAxis: Infinity }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first series carries the mask, merged in without resending data
|
||||
private _updateMask() {
|
||||
this.chart?.setOption({
|
||||
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSelectPointerDown = (e: ECElementEvent) => {
|
||||
this._pointerDownX = e.offsetX;
|
||||
};
|
||||
|
||||
private _handleSelectClick = (e: ECElementEvent) => {
|
||||
const point = [e.offsetX, e.offsetY];
|
||||
// A drag that ends on the plot is not a click
|
||||
const dragged =
|
||||
this._pointerDownX !== undefined &&
|
||||
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
|
||||
if (dragged || !this.chart?.containPixel("grid", point)) {
|
||||
return;
|
||||
}
|
||||
const [x] = this.chart.convertFromPixel("grid", point) as number[];
|
||||
const selection = this._snapRange({ start: x, end: x });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrush = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
if (range) {
|
||||
this._selectingRange = this._snapRange({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
});
|
||||
this._updateMask();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrushEnd = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
this.chart?.dispatchAction({ type: "brush", areas: [] });
|
||||
this._selectingRange = undefined;
|
||||
this._updateMask();
|
||||
const selection =
|
||||
range && this._snapRange({ start: range[0], end: range[1] });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _updateSelectMode() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const zr = this.chart.getZr();
|
||||
zr.off("mousedown", this._handleSelectPointerDown);
|
||||
zr.off("click", this._handleSelectClick);
|
||||
this.chart.off("brush", this._handleBrush);
|
||||
this.chart.off("brushEnd", this._handleBrushEnd);
|
||||
if (this.selectMode !== "none") {
|
||||
zr.on("mousedown", this._handleSelectPointerDown);
|
||||
zr.on("click", this._handleSelectClick);
|
||||
}
|
||||
const selectRange = this.selectMode === "range";
|
||||
if (selectRange) {
|
||||
this.chart.on("brush", this._handleBrush);
|
||||
this.chart.on("brushEnd", this._handleBrushEnd);
|
||||
this.chart.setOption({
|
||||
brush: {
|
||||
xAxisIndex: 0,
|
||||
// Only measures the range, the mask fades what is outside
|
||||
seriesIndex: [],
|
||||
transformable: false,
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const brushCursor = {
|
||||
type: "takeGlobalCursor",
|
||||
key: "brush",
|
||||
brushOption: {
|
||||
brushType: selectRange ? "lineX" : false,
|
||||
brushMode: "single",
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
};
|
||||
// Brush and drag to zoom share the global cursor, the last one taken wins
|
||||
if (selectRange) {
|
||||
this._updateDragToZoom();
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
} else {
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
this._dragToZoom = false;
|
||||
this._updateDragToZoom();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
XAXisOption | undefined;
|
||||
@@ -1475,7 +1645,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const maskArea = this._getMaskArea();
|
||||
const series = ensureArray(this.data).map((s, index) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
@@ -1513,6 +1684,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
// ECharts merges series by id and would keep a mask left out here
|
||||
if (s.type === "line" || s.type === "bar") {
|
||||
result = {
|
||||
...result,
|
||||
markArea:
|
||||
index === 0 && maskArea
|
||||
? maskArea
|
||||
: ((s as LineSeriesOption).markArea ?? { data: [] }),
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
@@ -1762,7 +1943,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
this._dragToZoom &&
|
||||
(this._dragToZoom || this.selectMode === "range") &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
@@ -2337,6 +2518,7 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"chart-select": ChartTimeRange;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
@@ -49,7 +49,13 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import type {
|
||||
ChartSelectMode,
|
||||
ChartTimeRange,
|
||||
CustomLegendOption,
|
||||
HaChartBase,
|
||||
ZoomSliderMode,
|
||||
} from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
@query("ha-chart-base") private _chartBase?: HaChartBase;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: CustomLegendOption["data"];
|
||||
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.data=${this._chartData}
|
||||
.highlight=${this.highlight}
|
||||
.snapSelection=${this._snapSelection}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.height}
|
||||
.selectMode=${this.selectMode}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
style=${styleMap({ height: this.height })}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public resetZoom() {
|
||||
this._chartBase?.zoom(0, 100);
|
||||
}
|
||||
|
||||
// A click selects the period that contains it, a drag every period it
|
||||
// touches
|
||||
private _snapSelection = (
|
||||
range: ChartTimeRange
|
||||
): ChartTimeRange | undefined => {
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
Object.values(this.statisticsData ?? {}).forEach((values) =>
|
||||
values.forEach((value) => {
|
||||
const touched =
|
||||
range.start === range.end
|
||||
? value.start <= range.start && range.start < value.end
|
||||
: value.start < range.end && value.end > range.start;
|
||||
if (touched) {
|
||||
start = Math.min(start ?? value.start, value.start);
|
||||
end = Math.max(end ?? value.end, value.end);
|
||||
}
|
||||
})
|
||||
);
|
||||
return start === undefined || end === undefined
|
||||
? undefined
|
||||
: { start, end };
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
|
||||
@@ -107,6 +107,9 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
|
||||
|
||||
// Added after the default presets
|
||||
@property({ attribute: false }) public extraRanges?: DateRangePickerRanges;
|
||||
|
||||
@state() private _ranges?: DateRangePickerRanges;
|
||||
|
||||
@property({ attribute: "time-picker", type: Boolean })
|
||||
@@ -166,7 +169,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
if (
|
||||
changedProps.has("_i18n") ||
|
||||
changedProps.has("_hassConfig") ||
|
||||
changedProps.has("extendedPresets")
|
||||
changedProps.has("extendedPresets") ||
|
||||
changedProps.has("extraRanges")
|
||||
) {
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
@@ -178,7 +182,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
this._ranges = ranges;
|
||||
this._ranges = { ...ranges, ...this.extraRanges };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -349,7 +349,7 @@ export const statisticsMetaHasType = (
|
||||
};
|
||||
|
||||
export const adjustStatisticsSum = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_id: string,
|
||||
start_time: number,
|
||||
adjustment: number,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,670 +0,0 @@
|
||||
import { formatISO9075 } from "date-fns";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { formatDateTime } from "../../../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { formatNumberWithUnit } from "../../../../common/number/format_number";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-selector/ha-selector-datetime";
|
||||
import "../../../../components/ha-selector/ha-selector-number";
|
||||
import "../../../../components/item/ha-list-item-button";
|
||||
import "../../../../components/list/ha-list-base";
|
||||
import type { StatisticValue } from "../../../../data/recorder";
|
||||
import {
|
||||
adjustStatisticsSum,
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
} from "../../../../data/recorder";
|
||||
import type {
|
||||
DateTimeSelector,
|
||||
NumberSelector,
|
||||
} from "../../../../data/selector";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import type { DialogStatisticsAdjustSumParams } from "./show-dialog-statistics-adjust-sum";
|
||||
|
||||
interface CombinedStat {
|
||||
hour: StatisticValue | null;
|
||||
fiveMin: StatisticValue[];
|
||||
}
|
||||
|
||||
interface FetchedStats {
|
||||
hour: StatisticValue[];
|
||||
fiveMin: StatisticValue[];
|
||||
}
|
||||
|
||||
interface AdjustState {
|
||||
amount: number | undefined;
|
||||
}
|
||||
|
||||
@customElement("dialog-statistics-adjust-sum")
|
||||
export class DialogStatisticsFixUnsupportedUnitMetadata extends DirtyStateProviderMixin<AdjustState>()(
|
||||
LitElement
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: DialogStatisticsAdjustSumParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _busy = false;
|
||||
|
||||
@state() private _moment?: string;
|
||||
|
||||
@state() private _stats5min?: StatisticValue[];
|
||||
|
||||
@state() private _statsHour?: StatisticValue[];
|
||||
|
||||
@state() private _chosenStat?: StatisticValue;
|
||||
|
||||
@state() private _amount?: number;
|
||||
|
||||
private _showingOutliers = false;
|
||||
|
||||
// Adjustments made while the dialog is open, keyed by period. The recorder
|
||||
// processes adjustments asynchronously, so a fetch right after adjusting can
|
||||
// still return the old values.
|
||||
private _adjustedChanges = new Map<string, number>();
|
||||
|
||||
// Incremented for every fetch, so results of outdated fetches are ignored
|
||||
private _fetchId = 0;
|
||||
|
||||
private _dateTimeSelector: DateTimeSelector = {
|
||||
datetime: {},
|
||||
};
|
||||
|
||||
private _precision = 2;
|
||||
|
||||
private _amountSelector = memoizeOne(
|
||||
(
|
||||
unit_of_measurement: string | undefined,
|
||||
precision: number
|
||||
): NumberSelector => ({
|
||||
number: {
|
||||
step: 10 ** -precision,
|
||||
unit_of_measurement,
|
||||
mode: "box",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
public showDialog(params: DialogStatisticsAdjustSumParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
|
||||
// Here we create a date with minutes set to %5
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() - (now.getMinutes() % 5), 0);
|
||||
this._moment = formatISO9075(now);
|
||||
this._showingOutliers = false;
|
||||
this._adjustedChanges.clear();
|
||||
this._refreshStats();
|
||||
|
||||
const entry = this.hass.entities[params.statistic.statistic_id];
|
||||
this._precision = Math.max(entry?.display_precision ?? 0, 2);
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._moment = undefined;
|
||||
this._stats5min = undefined;
|
||||
this._statsHour = undefined;
|
||||
this._amount = undefined;
|
||||
this._chosenStat = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
|
||||
this._busy = false;
|
||||
this._showingOutliers = false;
|
||||
this._adjustedChanges.clear();
|
||||
this._fetchId++;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
let content: TemplateResult;
|
||||
let footer: TemplateResult;
|
||||
|
||||
if (!this._chosenStat) {
|
||||
content = this._renderPickStatistic();
|
||||
footer = html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this._showOutliers}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.outliers"
|
||||
)}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
`;
|
||||
} else {
|
||||
content = this._renderAdjustStat();
|
||||
footer = html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
.disabled=${this._busy}
|
||||
@click=${this._clearChosenStatistic}
|
||||
appearance="plain"
|
||||
>
|
||||
${this.hass.localize("ui.common.back")}</ha-button
|
||||
>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
.disabled=${this._busy || !this.isDirtyState}
|
||||
@click=${this._fixIssue}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.adjust"
|
||||
)}</ha-button
|
||||
>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.title"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${content}
|
||||
<ha-dialog-footer slot="footer">${footer}</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (changedProps.size !== 1 || !changedProps.has("hass")) {
|
||||
return true;
|
||||
}
|
||||
// We only respond to hass changes if the translations or locale changed
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
return (
|
||||
!oldHass ||
|
||||
oldHass.localize !== this.hass.localize ||
|
||||
oldHass.locale !== this.hass.locale
|
||||
);
|
||||
}
|
||||
|
||||
private _renderPickStatistic() {
|
||||
let stats: TemplateResult;
|
||||
|
||||
if (!this._stats5min || !this._statsHour) {
|
||||
stats = html`<ha-spinner></ha-spinner>`;
|
||||
} else if (this._statsHour.length < 1 && this._stats5min.length < 1) {
|
||||
stats = html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.no_statistics_found"
|
||||
)}
|
||||
</p>`;
|
||||
} else {
|
||||
const data =
|
||||
this._stats5min.length >= 1 ? this._stats5min : this._statsHour;
|
||||
const unit = getDisplayUnit(
|
||||
this.hass.states,
|
||||
this._params!.statistic.statistic_id,
|
||||
this._params!.statistic
|
||||
);
|
||||
const rows: TemplateResult[] = [];
|
||||
for (const stat of data) {
|
||||
const digits = this._fractionDigits(stat.change);
|
||||
const change = formatNumberWithUnit(
|
||||
stat.change!,
|
||||
unit,
|
||||
this.hass.locale,
|
||||
{
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}
|
||||
);
|
||||
rows.push(html`
|
||||
<ha-list-item-button
|
||||
.headline=${change}
|
||||
.supportingText=${formatDateTime(
|
||||
new Date(stat.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
.stat=${stat}
|
||||
@click=${this._setChosenStatistic}
|
||||
>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`);
|
||||
}
|
||||
stats = html`<ha-list-base
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic_values"
|
||||
)}
|
||||
>${rows}</ha-list-base
|
||||
>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="text-content">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.info_text_1"
|
||||
)}
|
||||
</div>
|
||||
<div class="text-content">
|
||||
<b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
|
||||
)}</b
|
||||
>
|
||||
${this._params!.statistic.statistic_id}
|
||||
</div>
|
||||
<ha-selector-datetime
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.pick_a_time"
|
||||
)}
|
||||
.selector=${this._dateTimeSelector}
|
||||
.value=${this._moment}
|
||||
@value-changed=${this._dateTimeSelectorChanged}
|
||||
></ha-selector-datetime>
|
||||
<div class="stat-list">${stats}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _clearChosenStatistic() {
|
||||
this._chosenStat = undefined;
|
||||
this._initDirtyTracking({ type: "deep" }, { amount: undefined });
|
||||
}
|
||||
|
||||
// Small changes get more digits, so they do not round to zero.
|
||||
private _fractionDigits(value: number | null | undefined): number {
|
||||
if (!value || Math.abs(value) >= 10 ** -this._precision) {
|
||||
return this._precision;
|
||||
}
|
||||
return Math.min(Math.ceil(-Math.log10(Math.abs(value))) + 1, 20);
|
||||
}
|
||||
|
||||
private _setChosenStatistic(ev) {
|
||||
const stat = ev.currentTarget.stat;
|
||||
const multiple = 10 ** this._fractionDigits(stat.change);
|
||||
const growth = Math.round(stat.change! * multiple) / multiple;
|
||||
|
||||
this._chosenStat = stat;
|
||||
this._amount = growth;
|
||||
this._initDirtyTracking({ type: "deep" }, { amount: growth });
|
||||
}
|
||||
|
||||
private _dateTimeSelectorChanged(ev) {
|
||||
this._moment = ev.detail.value;
|
||||
this._showingOutliers = false;
|
||||
this._refreshStats();
|
||||
}
|
||||
|
||||
private _showOutliers() {
|
||||
this._showingOutliers = true;
|
||||
this._refreshStats();
|
||||
}
|
||||
|
||||
private async _refreshStats(): Promise<void> {
|
||||
const fetchId = ++this._fetchId;
|
||||
this._stats5min = undefined;
|
||||
this._statsHour = undefined;
|
||||
try {
|
||||
const stats = this._showingOutliers
|
||||
? await this._fetchOutliers()
|
||||
: await this._fetchStats();
|
||||
if (fetchId !== this._fetchId) {
|
||||
return;
|
||||
}
|
||||
this._statsHour = stats.hour;
|
||||
this._stats5min = stats.fiveMin;
|
||||
} catch (err: any) {
|
||||
if (fetchId !== this._fetchId) {
|
||||
return;
|
||||
}
|
||||
this._stats5min = [];
|
||||
this._statsHour = [];
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_loading_statistics",
|
||||
{ message: err.message || err }
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _applyAdjustedChanges(stats: StatisticValue[]): StatisticValue[] {
|
||||
if (!this._adjustedChanges.size) {
|
||||
return stats;
|
||||
}
|
||||
return stats.map((stat) => {
|
||||
const change = this._adjustedChanges.get(`${stat.start}-${stat.end}`);
|
||||
return change === undefined ? stat : { ...stat, change };
|
||||
});
|
||||
}
|
||||
|
||||
private _renderAdjustStat() {
|
||||
const unit = getDisplayUnit(
|
||||
this.hass.states,
|
||||
this._params!.statistic.statistic_id,
|
||||
this._params!.statistic
|
||||
);
|
||||
return html`
|
||||
<div class="text-content">
|
||||
<b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.statistic"
|
||||
)}</b
|
||||
>
|
||||
${this._params!.statistic.statistic_id}
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<span
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.start"
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>${formatDateTime(
|
||||
new Date(this._chosenStat!.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="table-row">
|
||||
<span
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.end"
|
||||
)}</span
|
||||
>
|
||||
<span
|
||||
>${formatDateTime(
|
||||
new Date(this._chosenStat!.end),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<ha-selector-number
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.new_value"
|
||||
)}
|
||||
.selector=${this._amountSelector(
|
||||
unit || undefined,
|
||||
this._fractionDigits(this._chosenStat!.change)
|
||||
)}
|
||||
.value=${this._amount}
|
||||
.disabled=${this._busy}
|
||||
@value-changed=${this._amountChanged}
|
||||
></ha-selector-number>
|
||||
`;
|
||||
}
|
||||
|
||||
private _amountChanged(ev) {
|
||||
this._amount = ev.detail.value;
|
||||
this._updateDirtyState({ amount: this._amount });
|
||||
}
|
||||
|
||||
private async _fetchStats(): Promise<FetchedStats> {
|
||||
const statId = this._params!.statistic.statistic_id;
|
||||
|
||||
// moment is in format YYYY-MM-DD HH:mm:ss because of selector
|
||||
// Here we convert it to an ISO string.
|
||||
const moment = new Date(this._moment!.replace(" ", "T"));
|
||||
|
||||
// Search 2 hours before and 3 hours after chosen time
|
||||
const hourStatStart = new Date(moment.getTime());
|
||||
hourStatStart.setTime(hourStatStart.getTime() - 2 * 3600 * 1000);
|
||||
const hourStatEnd = new Date(moment.getTime());
|
||||
hourStatEnd.setTime(hourStatEnd.getTime() + 3 * 3600 * 1000);
|
||||
|
||||
const statsHourData = await fetchStatistics(
|
||||
this.hass.callWS,
|
||||
hourStatStart,
|
||||
hourStatEnd,
|
||||
[statId],
|
||||
"hour"
|
||||
);
|
||||
const statsHour =
|
||||
statId in statsHourData
|
||||
? this._applyAdjustedChanges(statsHourData[statId].slice(0, 5))
|
||||
: [];
|
||||
|
||||
// Can't have 5 min data if no hourly data
|
||||
if (statsHour.length === 0) {
|
||||
return { hour: [], fiveMin: [] };
|
||||
}
|
||||
|
||||
// Search 10 minutes before and 15 minutes after chosen time
|
||||
const minStatStart = new Date(moment.getTime());
|
||||
minStatStart.setTime(minStatStart.getTime() - 10 * 60 * 1000);
|
||||
const minStatEnd = new Date(moment.getTime());
|
||||
minStatEnd.setTime(minStatEnd.getTime() + 15 * 60 * 1000);
|
||||
|
||||
const stats5MinData = await fetchStatistics(
|
||||
this.hass.callWS,
|
||||
minStatStart,
|
||||
minStatEnd,
|
||||
[statId],
|
||||
"5minute"
|
||||
);
|
||||
|
||||
return {
|
||||
hour: statsHour,
|
||||
fiveMin:
|
||||
statId in stats5MinData
|
||||
? this._applyAdjustedChanges(stats5MinData[statId].slice(0, 5))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async _fetchOutliers(): Promise<FetchedStats> {
|
||||
const statId = this._params!.statistic.statistic_id;
|
||||
|
||||
// Get all the data
|
||||
const start = new Date(0);
|
||||
const end = new Date();
|
||||
|
||||
const statsHourData = await fetchStatistics(
|
||||
this.hass.callWS,
|
||||
start,
|
||||
end,
|
||||
[statId],
|
||||
"hour"
|
||||
);
|
||||
|
||||
const statsHour =
|
||||
statId in statsHourData
|
||||
? this._applyAdjustedChanges(statsHourData[statId])
|
||||
: [];
|
||||
if (statsHour.length === 0) {
|
||||
return { hour: [], fiveMin: [] };
|
||||
}
|
||||
|
||||
const stats5MinData = await fetchStatistics(
|
||||
this.hass.callWS,
|
||||
start,
|
||||
end,
|
||||
[statId],
|
||||
"5minute"
|
||||
);
|
||||
|
||||
const stats5Min =
|
||||
statId in stats5MinData
|
||||
? this._applyAdjustedChanges(stats5MinData[statId])
|
||||
: [];
|
||||
// First datapoint of 5 minute data in the history is always junk since it counts the entire sum
|
||||
// as the change, which we don't want here.
|
||||
stats5Min.shift();
|
||||
|
||||
const combinedStatsData: CombinedStat[] = [];
|
||||
statsHour.forEach((s) => {
|
||||
combinedStatsData.push({ hour: s, fiveMin: [] });
|
||||
});
|
||||
|
||||
const lasthour: CombinedStat = { hour: null, fiveMin: [] };
|
||||
|
||||
let i = 0;
|
||||
stats5Min.forEach((s) => {
|
||||
let matched = false;
|
||||
for (i; i < combinedStatsData.length; i++) {
|
||||
const hour = combinedStatsData[i].hour;
|
||||
if (hour && s.start >= hour.start && s.end <= hour.end) {
|
||||
combinedStatsData[i].fiveMin.push(s);
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
lasthour.fiveMin.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
combinedStatsData.push(lasthour);
|
||||
|
||||
let statsOutliers: StatisticValue[] = [];
|
||||
let min = 0;
|
||||
const numOutliers = 10;
|
||||
|
||||
// Track the top 10 values.
|
||||
const addOutlier = (s) => {
|
||||
const val = Math.abs(s.change ?? 0);
|
||||
if (statsOutliers.length < numOutliers || val > min) {
|
||||
statsOutliers.push(s);
|
||||
statsOutliers = statsOutliers.sort(
|
||||
(a, b) => Math.abs(b.change ?? 0) - Math.abs(a.change ?? 0)
|
||||
);
|
||||
statsOutliers = statsOutliers.slice(0, numOutliers);
|
||||
min = statsOutliers[statsOutliers.length - 1].change ?? 0;
|
||||
}
|
||||
};
|
||||
|
||||
// If an hour has incomplete five minute data, add the hour value
|
||||
// Otherwise, add the 5 minute values and ignore the hour value
|
||||
combinedStatsData.forEach((c) => {
|
||||
if (c.fiveMin.length !== 12 && c.hour) {
|
||||
addOutlier(c.hour);
|
||||
} else {
|
||||
c.fiveMin.forEach((s) => {
|
||||
addOutlier(s);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Outliers are a possible mix of hour/5minute data, but the distinction
|
||||
// is not relevant here, as long as only one array is populated.
|
||||
return { hour: statsOutliers, fiveMin: [] };
|
||||
}
|
||||
|
||||
private async _fixIssue(): Promise<void> {
|
||||
const params = this._params!;
|
||||
const chosenStat = this._chosenStat!;
|
||||
const amount = this._amount!;
|
||||
const unit = getDisplayUnit(
|
||||
this.hass.states,
|
||||
params.statistic.statistic_id,
|
||||
params.statistic
|
||||
);
|
||||
this._busy = true;
|
||||
try {
|
||||
await adjustStatisticsSum(
|
||||
this.hass,
|
||||
params.statistic.statistic_id,
|
||||
chosenStat.start,
|
||||
// The shown value is rounded, so compare with the stored value.
|
||||
amount - chosenStat.change!,
|
||||
unit || null
|
||||
);
|
||||
} catch (err: any) {
|
||||
// Report the error even if the dialog was closed or reopened while
|
||||
// adjusting, but leave the state of a newer invocation alone
|
||||
if (this._params === params) {
|
||||
this._busy = false;
|
||||
}
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.error_sum_adjusted",
|
||||
{ message: err.message || err }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.fix_issue.adjust_sum.sum_adjusted"
|
||||
),
|
||||
});
|
||||
// The dialog was closed or reopened while adjusting
|
||||
if (this._params !== params) {
|
||||
return;
|
||||
}
|
||||
this._adjustedChanges.set(`${chosenStat.start}-${chosenStat.end}`, amount);
|
||||
this._busy = false;
|
||||
this._clearChosenStatistic();
|
||||
this._refreshStats();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
.text-content,
|
||||
ha-selector-datetime,
|
||||
ha-selector-number {
|
||||
margin-bottom: var(--ha-space-5);
|
||||
}
|
||||
ha-list-base {
|
||||
margin: 0 -24px;
|
||||
}
|
||||
ha-list-item-button {
|
||||
--ha-row-item-padding-inline: var(--ha-space-6);
|
||||
}
|
||||
ha-list-item-button ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.table-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--ha-space-5);
|
||||
}
|
||||
.stat-list {
|
||||
margin-top: var(--ha-space-2);
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.stat-list ha-spinner {
|
||||
margin: 0 auto;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-statistics-adjust-sum": DialogStatisticsFixUnsupportedUnitMetadata;
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/chart/statistics-chart";
|
||||
import "../../../../components/date-picker/ha-date-range-picker";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-spinner";
|
||||
import {
|
||||
apiContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../data/context";
|
||||
import type { Statistics, StatisticType } from "../../../../data/recorder";
|
||||
import {
|
||||
clearStatistics,
|
||||
fetchStatistics,
|
||||
StatisticMeanType,
|
||||
} from "../../../../data/recorder";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import type { DialogStatisticsClearRangeParams } from "./show-dialog-statistics-clear-range";
|
||||
|
||||
// Longer ranges use hourly statistics to keep the preview fast.
|
||||
const MAX_5MINUTE_PREVIEW_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type PreviewPeriod = "5minute" | "hour";
|
||||
|
||||
// The recorder also clears the period in which the range starts, so the
|
||||
// preview starts at the beginning of that period.
|
||||
const periodStart = (date: Date, period: PreviewPeriod) => {
|
||||
const periodMs = (period === "hour" ? 60 : 5) * 60 * 1000;
|
||||
return new Date(Math.floor(date.getTime() / periodMs) * periodMs);
|
||||
};
|
||||
|
||||
@customElement("dialog-statistics-clear-range")
|
||||
class DialogStatisticsClearRange extends DialogMixin<DialogStatisticsClearRangeParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state() private _start = new Date();
|
||||
|
||||
@state() private _end = new Date();
|
||||
|
||||
@state() private _statistics?: Statistics;
|
||||
|
||||
@state() private _previewStart?: Date;
|
||||
|
||||
@state() private _busy = false;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
// Incremented for every fetch, so results of outdated fetches are ignored
|
||||
private _fetchId = 0;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._end.setMinutes(
|
||||
this._end.getMinutes() - (this._end.getMinutes() % 5),
|
||||
0,
|
||||
0
|
||||
);
|
||||
this._start = new Date(this._end.getTime() - 24 * 60 * 60 * 1000);
|
||||
this._fetchStatistics();
|
||||
}
|
||||
|
||||
private get _validRange() {
|
||||
return this._start.getTime() < this._end.getTime();
|
||||
}
|
||||
|
||||
private get _statTypes(): StatisticType[] {
|
||||
return this.params!.statistic.mean_type !== StatisticMeanType.NONE
|
||||
? ["mean", "min", "max"]
|
||||
: ["change"];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.params) {
|
||||
return nothing;
|
||||
}
|
||||
const localize = this._i18n.localize;
|
||||
const statisticId = this.params.statistic.statistic_id;
|
||||
const hasData = Boolean(this._statistics?.[statisticId]?.length);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
width="large"
|
||||
.headerTitle=${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.title"
|
||||
)}
|
||||
.preventScrimClose=${this._busy}
|
||||
>
|
||||
<p>
|
||||
${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.info_text",
|
||||
{ name: html`<b>${this.params.name}</b>` }
|
||||
)}
|
||||
</p>
|
||||
<ha-date-range-picker
|
||||
time-picker
|
||||
.startDate=${this._start}
|
||||
.endDate=${this._end}
|
||||
.disabled=${this._busy}
|
||||
@value-changed=${this._rangeChanged}
|
||||
></ha-date-range-picker>
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!this._validRange
|
||||
? html`<ha-alert alert-type="error">
|
||||
${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.invalid_range"
|
||||
)}
|
||||
</ha-alert>`
|
||||
: !this._statistics
|
||||
? html`<div class="preview center">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: !hasData
|
||||
? html`<div class="preview center">
|
||||
${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.no_statistics_found"
|
||||
)}
|
||||
</div>`
|
||||
: html`<div class="preview">
|
||||
<statistics-chart
|
||||
.statisticsData=${this._statistics}
|
||||
.metadata=${{ [statisticId]: this.params.statistic }}
|
||||
.names=${{ [statisticId]: this.params.name }}
|
||||
.statTypes=${this._statTypes}
|
||||
.chartType=${
|
||||
this._statTypes[0] === "change" ? "bar" : "line"
|
||||
}
|
||||
.startTime=${this._previewStart}
|
||||
.endTime=${this._end}
|
||||
hide-legend
|
||||
.clickForMoreInfo=${false}
|
||||
height="240px"
|
||||
></statistics-chart>
|
||||
</div>`
|
||||
}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
.disabled=${this._busy}
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
variant="danger"
|
||||
.loading=${this._busy}
|
||||
.disabled=${this._busy || !this._validRange || !hasData}
|
||||
@click=${this._delete}
|
||||
>
|
||||
${localize("ui.common.delete")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeChanged(
|
||||
ev: HASSDomEvent<{ value: { startDate: Date; endDate: Date } }>
|
||||
) {
|
||||
this._start = ev.detail.value.startDate;
|
||||
this._end = ev.detail.value.endDate;
|
||||
this._fetchStatistics();
|
||||
}
|
||||
|
||||
private async _fetchStatistics() {
|
||||
const fetchId = ++this._fetchId;
|
||||
this._statistics = undefined;
|
||||
this._error = undefined;
|
||||
if (!this._validRange) {
|
||||
return;
|
||||
}
|
||||
const statisticId = this.params!.statistic.statistic_id;
|
||||
const fetchPreview = async (period: PreviewPeriod) => {
|
||||
const start = periodStart(this._start, period);
|
||||
const statistics = await fetchStatistics(
|
||||
this._api.callWS,
|
||||
start,
|
||||
this._end,
|
||||
[statisticId],
|
||||
period,
|
||||
undefined,
|
||||
this._statTypes.includes("change") ? ["change"] : ["mean", "min", "max"]
|
||||
);
|
||||
return { start, statistics };
|
||||
};
|
||||
try {
|
||||
const shortRange =
|
||||
this._end.getTime() - this._start.getTime() <= MAX_5MINUTE_PREVIEW_MS;
|
||||
let preview = await fetchPreview(shortRange ? "5minute" : "hour");
|
||||
// Short term statistics are purged before the hourly statistics.
|
||||
if (shortRange && !preview.statistics[statisticId]?.length) {
|
||||
preview = await fetchPreview("hour");
|
||||
}
|
||||
if (fetchId === this._fetchId) {
|
||||
this._previewStart = preview.start;
|
||||
this._statistics = preview.statistics;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (fetchId === this._fetchId) {
|
||||
this._statistics = {};
|
||||
this._error = err.message || err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _delete() {
|
||||
this._busy = true;
|
||||
this._error = undefined;
|
||||
try {
|
||||
await clearStatistics(this._api, [this.params!.statistic.statistic_id], {
|
||||
start: this._start,
|
||||
end: this._end,
|
||||
});
|
||||
} catch (err: any) {
|
||||
this._busy = false;
|
||||
this._error = err.message || err;
|
||||
return;
|
||||
}
|
||||
showToast(this, {
|
||||
message: this._i18n.localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.deleted"
|
||||
),
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleDialog,
|
||||
css`
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
ha-date-range-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.preview {
|
||||
min-height: 240px;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-statistics-clear-range": DialogStatisticsClearRange;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,30 @@ import type { StatisticsValidationResult } from "../../../../data/recorder";
|
||||
import { showFixStatisticsDialog } from "./show-dialog-statistics-fix";
|
||||
import { showFixStatisticsUnitsChangedDialog } from "./show-dialog-statistics-fix-units-changed";
|
||||
|
||||
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
|
||||
no_state: 0,
|
||||
entity_no_longer_recorded: 1,
|
||||
entity_not_recorded: 1,
|
||||
state_class_removed: 2,
|
||||
units_changed: 3,
|
||||
mean_type_changed: 4,
|
||||
};
|
||||
|
||||
export const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
|
||||
"no_state",
|
||||
"entity_no_longer_recorded",
|
||||
"state_class_removed",
|
||||
"units_changed",
|
||||
"mean_type_changed",
|
||||
];
|
||||
|
||||
export const sortStatisticsIssues = (issues: StatisticsValidationResult[]) =>
|
||||
[...issues].sort(
|
||||
(itemA, itemB) =>
|
||||
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
|
||||
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
|
||||
);
|
||||
|
||||
export const fixStatisticsIssue = async (
|
||||
element: HTMLElement,
|
||||
issue: StatisticsValidationResult
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type {
|
||||
StatisticsMetaData,
|
||||
StatisticsValidationResult,
|
||||
} from "../../../../data/recorder";
|
||||
|
||||
export const loadStatisticDialog = () => import("./dialog-statistic");
|
||||
|
||||
export interface DialogStatisticParams {
|
||||
statistic: StatisticsMetaData;
|
||||
name: string;
|
||||
area?: string;
|
||||
device?: string;
|
||||
issues?: StatisticsValidationResult[];
|
||||
issueFixedCallback?: () => void;
|
||||
}
|
||||
|
||||
export const showStatisticDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: DialogStatisticParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-statistic",
|
||||
dialogImport: loadStatisticDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
|
||||
export const loadAdjustSumDialog = () =>
|
||||
import("./dialog-statistics-adjust-sum");
|
||||
|
||||
export interface DialogStatisticsAdjustSumParams {
|
||||
statistic: StatisticsMetaData;
|
||||
}
|
||||
|
||||
export const showStatisticsAdjustSumDialog = (
|
||||
element: HTMLElement,
|
||||
detailParams: DialogStatisticsAdjustSumParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-statistics-adjust-sum",
|
||||
dialogImport: loadAdjustSumDialog,
|
||||
dialogParams: detailParams,
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
|
||||
export const loadClearRangeDialog = () =>
|
||||
import("./dialog-statistics-clear-range");
|
||||
|
||||
export interface DialogStatisticsClearRangeParams {
|
||||
statistic: StatisticsMetaData;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const showStatisticsClearRangeDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: DialogStatisticsClearRangeParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-statistics-clear-range",
|
||||
dialogImport: loadClearRangeDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
@@ -2,10 +2,8 @@ import {
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
mdiClose,
|
||||
mdiDeleteClock,
|
||||
mdiFormatListChecks,
|
||||
mdiMenuDown,
|
||||
mdiSlopeUphill,
|
||||
mdiTableCog,
|
||||
mdiUnfoldLessHorizontal,
|
||||
mdiUnfoldMoreHorizontal,
|
||||
@@ -28,11 +26,9 @@ import { consume } from "../../../../common/decorators/consume";
|
||||
import { QueryParamsController } from "../../../../common/controllers/query-params-controller";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import {
|
||||
computeDeviceName,
|
||||
@@ -87,26 +83,7 @@ import type {
|
||||
HomeAssistantRegistries,
|
||||
} from "../../../../types";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { fixStatisticsIssue } from "./fix-statistics";
|
||||
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
|
||||
import { showStatisticsClearRangeDialog } from "./show-dialog-statistics-clear-range";
|
||||
|
||||
const FIX_ISSUES_ORDER: Record<StatisticsValidationResult["type"], number> = {
|
||||
no_state: 0,
|
||||
entity_no_longer_recorded: 1,
|
||||
entity_not_recorded: 1,
|
||||
state_class_removed: 2,
|
||||
units_changed: 3,
|
||||
mean_type_changed: 4,
|
||||
};
|
||||
|
||||
const FIXABLE_ISSUES: StatisticsValidationResult["type"][] = [
|
||||
"no_state",
|
||||
"entity_no_longer_recorded",
|
||||
"state_class_removed",
|
||||
"units_changed",
|
||||
"mean_type_changed",
|
||||
];
|
||||
import { showStatisticDialog } from "./show-dialog-statistic";
|
||||
|
||||
type StatisticData = StatisticsMetaData & {
|
||||
issues?: StatisticsValidationResult[];
|
||||
@@ -318,70 +295,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
localize("ui.panel.config.tools.tabs.statistics.no_issue")
|
||||
}`,
|
||||
},
|
||||
fix: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.tools.tabs.statistics.fix_issue.fix"),
|
||||
type: "icon",
|
||||
template: (statistic) =>
|
||||
html`${
|
||||
statistic.issues
|
||||
? html`<ha-button
|
||||
@click=${this._fixIssue}
|
||||
.data=${statistic.issues}
|
||||
appearance="plain"
|
||||
size="s"
|
||||
>
|
||||
${localize(
|
||||
statistic.issues.some((issue) =>
|
||||
FIXABLE_ISSUES.includes(issue.type)
|
||||
)
|
||||
? "ui.panel.config.tools.tabs.statistics.fix_issue.fix"
|
||||
: "ui.panel.config.tools.tabs.statistics.fix_issue.info"
|
||||
)}
|
||||
</ha-button>`
|
||||
: "—"
|
||||
}`,
|
||||
minWidth: "113px",
|
||||
maxWidth: "113px",
|
||||
showNarrow: true,
|
||||
},
|
||||
clear_range: {
|
||||
title: "",
|
||||
label: localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.title"
|
||||
),
|
||||
type: "icon-button",
|
||||
showNarrow: true,
|
||||
template: (statistic) => html`
|
||||
<ha-icon-button
|
||||
.label=${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.clear_range.title"
|
||||
)}
|
||||
.path=${mdiDeleteClock}
|
||||
.statistic=${statistic}
|
||||
@click=${this._showStatisticsClearRangeDialog}
|
||||
></ha-icon-button>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
title: "",
|
||||
label: localize("ui.panel.config.tools.tabs.statistics.adjust_sum"),
|
||||
type: "icon-button",
|
||||
showNarrow: true,
|
||||
template: (statistic) =>
|
||||
statistic.has_sum
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.label=${localize(
|
||||
"ui.panel.config.tools.tabs.statistics.adjust_sum"
|
||||
)}
|
||||
.path=${mdiSlopeUphill}
|
||||
.statistic=${statistic}
|
||||
@click=${this._showStatisticsAdjustSumDialog}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -825,35 +738,28 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
this._dataTable.selectAll((statistic) => statistic.issues);
|
||||
}
|
||||
|
||||
private _showStatisticsAdjustSumDialog(
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElement & { statistic: StatisticData }>
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
showStatisticsAdjustSumDialog(this, {
|
||||
statistic: ev.currentTarget.statistic,
|
||||
});
|
||||
}
|
||||
|
||||
private _showStatisticsClearRangeDialog(
|
||||
ev: HASSDomCurrentTargetEvent<
|
||||
HTMLElement & { statistic: DisplayedStatisticData }
|
||||
>
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const statistic = ev.currentTarget.statistic;
|
||||
showStatisticsClearRangeDialog(this, {
|
||||
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const statistic = this._displayData(
|
||||
this._data,
|
||||
this._i18n.localize,
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
this._formatEntityName
|
||||
).find((stat) => stat.statistic_id === ev.detail.id);
|
||||
if (!statistic) {
|
||||
return;
|
||||
}
|
||||
showStatisticDialog(this, {
|
||||
statistic,
|
||||
name: statistic.displayName,
|
||||
area: statistic.area,
|
||||
device: statistic.device,
|
||||
issues: statistic.issues,
|
||||
issueFixedCallback: () => this._validateStatistics(),
|
||||
});
|
||||
}
|
||||
|
||||
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const id = ev.detail.id;
|
||||
if (id in this._states) {
|
||||
fireEvent(this, "hass-more-info", { entityId: id });
|
||||
}
|
||||
}
|
||||
|
||||
private async _validateStatistics() {
|
||||
try {
|
||||
const [statisticIds, issues] = await Promise.all([
|
||||
@@ -918,21 +824,6 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _fixIssue = async (
|
||||
ev: HASSDomCurrentTargetEvent<
|
||||
HTMLElement & { data: StatisticsValidationResult[] }
|
||||
>
|
||||
) => {
|
||||
const issues = ev.currentTarget.data.sort(
|
||||
(itemA, itemB) =>
|
||||
(FIX_ISSUES_ORDER[itemA.type] ?? 99) -
|
||||
(FIX_ISSUES_ORDER[itemB.type] ?? 99)
|
||||
);
|
||||
const issue = issues[0];
|
||||
await fixStatisticsIssue(this, issue);
|
||||
this._validateStatistics();
|
||||
};
|
||||
|
||||
protected supportedShortcuts(): SupportedShortcuts {
|
||||
return {
|
||||
f: () => this._searchInput.focus(),
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
DataZoomComponent,
|
||||
VisualMapComponent,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
} from "echarts/components";
|
||||
|
||||
// Features like Universal Transition and Label Layout
|
||||
@@ -48,6 +50,8 @@ import type {
|
||||
GridComponentOption,
|
||||
DataZoomComponentOption,
|
||||
VisualMapComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
BrushComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { ComposeOption } from "echarts/core";
|
||||
|
||||
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
|
||||
| GridComponentOption
|
||||
| DataZoomComponentOption
|
||||
| VisualMapComponentOption
|
||||
| MarkAreaComponentOption
|
||||
| BrushComponentOption
|
||||
| SankeySeriesOption
|
||||
| GraphSeriesOption
|
||||
| SunburstSeriesOption
|
||||
@@ -91,6 +97,8 @@ echarts.use([
|
||||
UniversalTransition,
|
||||
CanvasRenderer,
|
||||
ToolboxComponent,
|
||||
MarkAreaComponent,
|
||||
BrushComponent,
|
||||
]);
|
||||
|
||||
export { LinearGradient };
|
||||
|
||||
+24
-20
@@ -4513,28 +4513,32 @@
|
||||
"info_text_1": "The mean type of ''{name}'' ({statistic_id}) changed from ''{metadata_mean_type}'' to ''{state_mean_type}''.",
|
||||
"info_text_2": "Statistics cannot be generated until the old statistics are deleted, or the mean type matches the old statistics data again.",
|
||||
"info_text_3": "Do you want to permanently delete the long term statistics of {statistic_id} from your database?"
|
||||
},
|
||||
"adjust_sum": {
|
||||
"title": "Adjust a statistic",
|
||||
"no_statistics_found": "No statistics found for this period.",
|
||||
"info_text_1": "Sometimes the statistics end up being incorrect for a specific point in time. This can mess up your beautiful graphs! Select a time below to find the bad moment and adjust the data.",
|
||||
"pick_a_time": "Pick a time",
|
||||
"statistic_values": "Statistic values",
|
||||
"statistic": "Statistic:",
|
||||
"start": "Start",
|
||||
"end": "End",
|
||||
"new_value": "New value",
|
||||
"adjust": "Adjust",
|
||||
"outliers": "Outliers",
|
||||
"sum_adjusted": "Statistic sum adjusted",
|
||||
"error_sum_adjusted": "Error adjusting sum: {message}",
|
||||
"error_loading_statistics": "Error loading statistics: {message}"
|
||||
}
|
||||
},
|
||||
"adjust_sum": "Adjust sum",
|
||||
"clear_range": {
|
||||
"title": "Delete statistics in a time range",
|
||||
"info_text": "Delete the statistics of {name} between the start and end time. Use this to remove bad measurements from your graphs. Deleted statistics cannot be restored.",
|
||||
"statistic": {
|
||||
"more_info": "[%key:ui::panel::config::tools::tabs::states::more_info%]",
|
||||
"all_data": "All data",
|
||||
"show_all_data": "Show all data",
|
||||
"data_range": "Recorded data",
|
||||
"correct_mode": "Correction mode",
|
||||
"no_selection": "No period selected",
|
||||
"outliers": "Outliers",
|
||||
"no_outliers": "[%key:ui::common::none%]",
|
||||
"correct": "Correct data",
|
||||
"correct_sum_hint": "Select the bar with the wrong value.",
|
||||
"correct_mean_hint": "Select the period with wrong values, or drag across the graph to select several periods.",
|
||||
"current_value": "Current value",
|
||||
"period": "From {start} to {end}",
|
||||
"mean": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::mean%]",
|
||||
"min": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::min%]",
|
||||
"max": "[%key:ui::panel::lovelace::editor::card::statistic::stat_type_labels::max%]",
|
||||
"new_value": "New value",
|
||||
"adjust": "Adjust",
|
||||
"adjusted": "Statistic adjusted",
|
||||
"delete": "Delete period",
|
||||
"delete_confirm_title": "Delete these statistics?",
|
||||
"delete_confirm_text": "Deleted statistics cannot be restored.",
|
||||
"delete_confirm_text_5minute": "The hourly statistics that overlap this period are deleted too. Deleted statistics cannot be restored.",
|
||||
"invalid_range": "The end time must be after the start time.",
|
||||
"no_statistics_found": "No statistics found for this period.",
|
||||
"deleted": "Statistics deleted"
|
||||
|
||||
Reference in new issue
Block a user