Compare commits

..

6 Commits

Author SHA1 Message Date
Petar Petrov
337f0e9f34 Position chart tooltip beside cursor instead of over data point 2026-05-07 13:13:53 +03:00
Aidan Timson
0e1aa400d7 Skeleton for graphs (loading animation) (#51882) 2026-05-07 10:20:35 +01:00
Petar Petrov
00e57454ed Add volume up/down to media player playback tile feature (#51898) 2026-05-07 09:52:15 +01:00
Paul Bottein
0e6b342b3f Fix race condition loading home dashboard favorites (#51901) 2026-05-07 09:47:07 +01:00
ildar170975
7ad8c27aa3 Statistics graph card: allow color customization (#51824)
* add a possibility to customize color

* add a possibility to customize color

* add GraphEntityConfig

* add basic color support

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-07 11:19:50 +03:00
ildar170975
f01c202bbd History graph card: allow color customization for "line" graphs (#51802)
* add "color" option

* add GraphEntityConfig type

* add "color" option

* add "color" option

* add "color" option

* typescript-eslint/no-shadow

* linter

* add graphEntitiesConfigStruct

* import graphEntitiesConfigStruct

* typo in import

* leftout

* Create order-properties-graph.ts

* use common orderPropertiesGraphCard()

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Add missing Struct type import

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-07 07:17:06 +00:00
26 changed files with 663 additions and 273 deletions

View File

@@ -1,54 +0,0 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};

View File

@@ -0,0 +1,40 @@
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
* with the displayed content.
*/
export const sideTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};

View File

@@ -11,6 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -60,6 +61,11 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property() public unit?: string;
@property() public identifier?: string;
@@ -405,8 +411,7 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -435,9 +440,11 @@ export class StateHistoryChartLine extends LitElement {
this._chartTime = new Date();
const endTime = this.endTime;
const names = this.names || {};
const colors = this.colors || {};
entityStates.forEach((states, dataIdx) => {
const domain = states.domain;
const name = names[states.entity_id] || states.name;
const color = colors[states.entity_id];
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
@@ -468,11 +475,11 @@ export class StateHistoryChartLine extends LitElement {
const addDataSet = (
id: string,
nameY: string,
color?: string,
clr?: string,
fill = false
) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
if (!clr) {
clr = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
@@ -481,7 +488,7 @@ export class StateHistoryChartLine extends LitElement {
type: "line",
cursor: "default",
name: nameY,
color,
color: clr,
symbol: "circle",
symbolSize: 1,
step: "end",
@@ -492,7 +499,7 @@ export class StateHistoryChartLine extends LitElement {
},
areaStyle: fill
? {
color: color + "7F",
color: clr + "7F",
}
: undefined,
tooltip: {
@@ -740,7 +747,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(states.entity_id, name);
addDataSet(states.entity_id, name, color);
let lastValue: number;
let lastDate: Date;

View File

@@ -14,6 +14,7 @@ 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 { sideTooltipPosition } from "./chart-tooltip-position";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
@@ -256,8 +257,7 @@ export class StateHistoryChartTimeline extends LitElement {
},
tooltip: {
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},

View File

@@ -52,6 +52,11 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property({ type: Boolean, reflect: true }) public virtualize = false;
@property({ attribute: false }) public endTime?: Date;
@@ -181,6 +186,7 @@ export class StateHistoryCharts extends LitElement {
.endTime=${this._computedEndTime}
.paddingYAxis=${this._maxYWidth}
.names=${this.names}
.colors=${this.colors}
.chartIndex=${index}
.clickForMoreInfo=${this.clickForMoreInfo}
.logarithmicScale=${this.logarithmicScale}

View File

@@ -37,6 +37,7 @@ import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { fillDataGapsAndRoundCaps } from "./round-caps";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
@@ -68,6 +69,11 @@ export class StatisticsChart extends LitElement {
@property({ attribute: false }) public names?: Record<string, string>;
@property({ attribute: false }) public colors?: Record<
string,
string | undefined
>;
@property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@@ -393,8 +399,7 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -485,6 +490,7 @@ export class StatisticsChart extends LitElement {
}
const names = this.names || {};
const colors = this.colors || {};
statisticsData.forEach(([statistic_id, stats]) => {
const meta = statisticsMetaData?.[statistic_id];
let name = names[statistic_id];
@@ -529,11 +535,14 @@ export class StatisticsChart extends LitElement {
prevEndTime = end;
};
const color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
let color = colors[statistic_id];
if (color === undefined) {
color = getGraphColorByIndex(
colorIndex,
this._computedStyle || getComputedStyle(this)
);
colorIndex++;
}
const statTypes: this["statTypes"] = [];

View File

@@ -1,8 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -41,12 +39,6 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**

View File

@@ -62,20 +62,26 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
<div part="start" class="start">
<slot name="start"></slot>
</div>
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
<div part="end" class="end">
<slot name="end"></slot>
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
`;
}
@@ -142,10 +148,6 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
:host(:not(:has([slot="start"]))) .start,
:host(:not(:has([slot="end"]))) .end {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,12 +1,10 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -14,11 +12,9 @@ import type { HaListItemRegistrationDetail } from "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect — works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -72,14 +68,6 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -87,14 +75,11 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
}
public focus(options?: FocusOptions) {
@@ -130,14 +115,18 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
this._activeItemIndex >= next.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -146,32 +135,6 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -188,12 +151,27 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;

View File

@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
</nav>`;
}

View File

@@ -11,15 +11,9 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}

View File

@@ -48,6 +48,8 @@ class PanelHome extends LitElement {
@state() private _extraActionItems?: ExtraActionItem[];
private _loadConfigPromise?: Promise<void>;
private get _showBanner(): boolean {
// Don't show if already dismissed
if (this._config.welcome_banner_dismissed) {
@@ -121,6 +123,12 @@ class PanelHome extends LitElement {
private async _setup() {
this._updateExtraActionItems();
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
this._setLovelace();
}
private async _loadConfig() {
try {
const [_, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
@@ -132,7 +140,6 @@ class PanelHome extends LitElement {
console.error("Failed to load favorites:", err);
this._config = {};
}
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
@@ -313,6 +320,9 @@ class PanelHome extends LitElement {
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const strategyConfig: LovelaceDashboardStrategyConfig = {
strategy: {
type: "home",

View File

@@ -8,6 +8,8 @@ import {
mdiSkipNext,
mdiSkipPrevious,
mdiStop,
mdiVolumeMinus,
mdiVolumePlus,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -52,6 +54,8 @@ const MEDIA_PLAYER_PLAYBACK_CONTROLS_FEATURES: Record<
media_stop: [MediaPlayerEntityFeature.STOP],
media_previous_track: [MediaPlayerEntityFeature.PREVIOUS_TRACK],
media_next_track: [MediaPlayerEntityFeature.NEXT_TRACK],
volume_down: [MediaPlayerEntityFeature.VOLUME_STEP],
volume_up: [MediaPlayerEntityFeature.VOLUME_STEP],
};
export const supportsMediaPlayerPlaybackControl = (
@@ -266,6 +270,16 @@ class HuiMediaPlayerPlaybackCardFeature
buttons.push({ icon: mdiSkipNext, action: "media_next_track" });
}
break;
case "volume_down":
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
buttons.push({ icon: mdiVolumeMinus, action: "volume_down" });
}
break;
case "volume_up":
if (supportsFeature(stateObj, MediaPlayerEntityFeature.VOLUME_STEP)) {
buttons.push({ icon: mdiVolumePlus, action: "volume_up" });
}
break;
}
}

View File

@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../common/number/format_number";
import "../../../components/ha-spinner";
import {
limitedHistoryFromStateObj,
subscribeHistoryStatesTimeWindow,
@@ -49,6 +48,8 @@ class HuiHistoryChartCardFeature
@state() private _yAxisOrigin?: number;
@state() private _loading = true;
@state() private _error?: { code: string; message: string };
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
@@ -90,9 +91,29 @@ class HuiHistoryChartCardFeature
}
protected firstUpdated() {
this._setLoadingCoordinates();
this._subscribeHistory();
}
private _setLoadingCoordinates() {
const entityId = this.context?.entity_id;
if (!entityId || !this.hass) {
return;
}
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return;
}
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
limitedHistoryFromStateObj(stateObj),
this.clientWidth,
this.clientHeight,
10
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
}
protected render() {
if (
!this._config ||
@@ -109,14 +130,7 @@ class HuiHistoryChartCardFeature
</div>
`;
}
if (!this._coordinates) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
if (!this._coordinates.length) {
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
@@ -125,6 +139,7 @@ class HuiHistoryChartCardFeature
}
return html`
<hui-graph-base
?loading=${this._loading}
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
></hui-graph-base>
@@ -197,6 +212,7 @@ class HuiHistoryChartCardFeature
);
this._coordinates = points;
this._yAxisOrigin = yAxisOrigin;
this._loading = false;
},
hourToShow,
[this.context!.entity_id!]
@@ -218,13 +234,6 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.container.loading {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);

View File

@@ -63,6 +63,8 @@ export const MEDIA_PLAYER_PLAYBACK_CONTROLS = [
"media_stop",
"media_previous_track",
"media_next_track",
"volume_down",
"volume_up",
] as const;
export type MediaPlayerPlaybackControl =

View File

@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { theme2hex } from "../../../common/color/convert-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/chart/state-history-charts";
@@ -21,9 +22,8 @@ import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import type { HomeAssistant } from "../../../types";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HistoryGraphCardConfig } from "./types";
import type { GraphEntityConfig, HistoryGraphCardConfig } from "./types";
export const DEFAULT_HOURS_TO_SHOW = 24;
@@ -51,9 +51,11 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
private _names: Record<string, string> = {};
private _colors: Record<string, string | undefined> = {};
private _entityIds: string[] = [];
private _entities: EntityConfig[] = [];
private _entities: GraphEntityConfig[] = [];
private _historyLinkId = `history-${Math.random().toString(36).substring(2, 9)}`;
@@ -95,6 +97,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
this._config = config;
this._computeNames();
this._computeColors();
}
private _computeNames() {
@@ -110,6 +113,19 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
});
}
private _computeColors() {
if (!this._config) {
return;
}
this._colors = {};
this._entities.forEach((entity) => {
// if color = undefined, it is automatically defined inside a chart component
this._colors[entity.entity] = entity.color
? theme2hex(entity.color)
: undefined;
});
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("hass")) {
@@ -371,6 +387,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.fitYData=${this._config.fit_y_data || false}
.colors=${this._colors}
.height=${hasFixedHeight ? "100%" : undefined}
.narrow=${narrow}
.expandLegend=${this._config.expand_legend}

View File

@@ -4,6 +4,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { theme2hex } from "../../../common/color/convert-color";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
@@ -27,10 +28,9 @@ import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { processConfigEntities } from "../common/process-config-entities";
import type { EntityConfig } from "../entity-rows/types";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { getSuggestedMax } from "./energy/common/energy-chart-options";
import type { StatisticsGraphCardConfig } from "./types";
import type { GraphEntityConfig, StatisticsGraphCardConfig } from "./types";
export const DEFAULT_DAYS_TO_SHOW = 30;
@@ -72,7 +72,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
@state() private _unit?: string;
private _entities: EntityConfig[] = [];
private _entities: GraphEntityConfig[] = [];
private _entityIds: string[] = [];
@@ -80,6 +80,8 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
private _names: Record<string, string> = {};
private _colors: Record<string, string | undefined> = {};
private _interval?: number;
private _statTypes?: StatisticType[];
@@ -178,6 +180,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
}
this._config = config;
this._computeNames();
this._computeColors();
}
private _computeNames() {
@@ -199,6 +202,19 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
});
}
private _computeColors() {
if (!this._config) {
return;
}
this._colors = {};
this._entities.forEach((entity) => {
// if color = undefined, it is automatically defined inside a chart component
this._colors[entity.entity] = entity.color
? theme2hex(entity.color)
: undefined;
});
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return (
hasConfigOrEntitiesChanged(this, changedProps) ||
@@ -353,6 +369,7 @@ export class HuiStatisticsGraphCard extends LitElement implements LovelaceCard {
.unit=${this._unit}
.minYAxis=${this._config.min_y_axis}
.maxYAxis=${this._config.max_y_axis}
.colors=${this._colors}
.startTime=${this._energyStart}
.endTime=${this._energyEnd && this._energyStart
? getSuggestedMax(

View File

@@ -458,8 +458,14 @@ export interface MediaControlCardConfig extends LovelaceCardConfig {
theme?: string;
}
export interface GraphEntityConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
color?: string;
}
export interface HistoryGraphCardConfig extends LovelaceCardConfig {
entities: (EntityConfig | string)[];
entities: (GraphEntityConfig | string)[];
hours_to_show?: number;
title?: string;
show_names?: boolean;
@@ -472,7 +478,7 @@ export interface HistoryGraphCardConfig extends LovelaceCardConfig {
}
export interface StatisticsGraphCardConfig extends EnergyCardBaseConfig {
entities: (EntityConfig | string)[];
entities: (GraphEntityConfig | string)[];
unit?: string;
days_to_show?: number;
period?: "auto" | StatisticPeriod;

View File

@@ -1,6 +1,9 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, svg } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { MediaQueriesListener } from "../../../common/dom/media_query";
import { listenMediaQuery } from "../../../common/dom/media_query";
import { parseAnimationDuration } from "../../../common/util/parse-animation-duration";
import { strokeWidth } from "../../../data/graph";
import { getPath } from "../common/graph/get-path";
@@ -11,58 +14,197 @@ export class HuiGraphBase extends LitElement {
@property({ attribute: "y-axis-origin", type: Number })
public yAxisOrigin?: number;
@state() private _path?: string;
@property({ type: Boolean, reflect: true }) public loading = false;
private _uniqueId = `graph-${Math.random().toString(36).substring(2, 9)}`;
@state()
private _displayCoordinates?: number[][];
@state()
private _reducedMotion = false;
private _unsubMediaQuery?: MediaQueriesListener;
private _animationFrame?: number;
protected render(): TemplateResult {
const width = this.clientWidth || 500;
const height = this.clientHeight || width / 5;
const yAxisOrigin = this.yAxisOrigin ?? height;
const lastX = this.coordinates?.length
? this.coordinates[this.coordinates.length - 1][0]
const path =
(this._displayCoordinates && getPath(this._displayCoordinates)) ??
(this.loading ? `M 0,${height / 2} L ${width},${height / 2}` : undefined);
const lastX = this._displayCoordinates?.length
? this._displayCoordinates[this._displayCoordinates.length - 1][0]
: width;
if (!path) {
return html`
${svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
`;
}
const showShimmer = this.loading && !this._reducedMotion;
const shimmerId = `${this._uniqueId}-shimmer`;
const fillRect = showShimmer ? `url(#${shimmerId})` : "var(--accent-color)";
return html`
${this._path
? svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<g>
<mask id="${this._uniqueId}-fill">
<path
class='fill'
fill='white'
d="${this._path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
/>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-fill)"></rect>
<mask id="${this._uniqueId}-line">
<path
vector-effect="non-scaling-stroke"
class='line'
fill="none"
stroke="white"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
d=${this._path}
></path>
</mask>
<rect height="100%" width="100%" fill="var(--accent-color)" mask="url(#${this._uniqueId}-line)"></rect>
</g>
</svg>`
: svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}"></svg>`}
${svg`<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
${
showShimmer
? svg`<defs>
<linearGradient id=${shimmerId} x1="-50%" x2="-30%" y1="0" y2="0">
<stop offset="0%" stop-color="var(--accent-color)" />
<stop offset="50%" stop-color="white" />
<stop offset="100%" stop-color="var(--accent-color)" />
<animate attributeName="x1" values="-50%;120%" dur="1.8s" repeatCount="indefinite" />
<animate attributeName="x2" values="-30%;140%" dur="1.8s" repeatCount="indefinite" />
</linearGradient>
</defs>`
: nothing
}
<g>
<mask id="${this._uniqueId}-fill">
<path
class="fill"
fill="white"
d="${path} L ${lastX}, ${yAxisOrigin} L 0, ${yAxisOrigin} z"
/>
</mask>
<rect height="100%" width="100%" fill=${fillRect} mask="url(#${this._uniqueId}-fill)"></rect>
<mask id="${this._uniqueId}-line">
<path
vector-effect="non-scaling-stroke"
class="line"
fill="none"
stroke="white"
stroke-width="${strokeWidth}"
stroke-linecap="round"
stroke-linejoin="round"
d=${path}
></path>
</mask>
<rect height="100%" width="100%" fill=${fillRect} mask="url(#${this._uniqueId}-line)"></rect>
</g>
</svg>`}
`;
}
public connectedCallback() {
super.connectedCallback();
this._unsubMediaQuery = listenMediaQuery(
"(prefers-reduced-motion: reduce)",
(matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
}
}
);
}
public willUpdate(changedProps: PropertyValues<this>) {
if (!this.coordinates) {
return;
}
if (changedProps.has("coordinates")) {
this._path = getPath(this.coordinates);
this._setCoordinates(this.coordinates);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQuery?.();
this._unsubMediaQuery = undefined;
this._cancelAnimation();
}
private _setCoordinates(coordinates: number[][]) {
this._cancelAnimation();
const displayCoordinates = this._displayCoordinates;
if (!displayCoordinates || coordinates.length < 2) {
this._displayCoordinates = coordinates;
return;
}
const duration = parseAnimationDuration(
getComputedStyle(this).getPropertyValue("--ha-animation-duration-slow")
);
if (duration <= 1) {
this._displayCoordinates = coordinates;
return;
}
const fromCoordinates = coordinates.map((coord) => [
coord[0],
this._interpolateY(displayCoordinates, coord[0]),
]);
const start = performance.now();
const animate = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const easedProgress = 1 - (1 - progress) ** 3;
this._displayCoordinates = coordinates.map((coord, index) => [
coord[0],
fromCoordinates[index][1] +
(coord[1] - fromCoordinates[index][1]) * easedProgress,
]);
if (progress < 1) {
this._animationFrame = requestAnimationFrame(animate);
} else {
this._animationFrame = undefined;
}
};
this._animationFrame = requestAnimationFrame(animate);
}
private _interpolateY(coordinates: number[][], x: number): number {
if (!coordinates.length) {
return 0;
}
if (x <= coordinates[0][0]) {
return coordinates[0][1];
}
for (let i = 1; i < coordinates.length; i++) {
const current = coordinates[i];
if (x > current[0]) {
continue;
}
const previous = coordinates[i - 1];
const xDelta = current[0] - previous[0];
if (!xDelta) {
return current[1];
}
const progress = (x - previous[0]) / xDelta;
return previous[1] + (current[1] - previous[1]) * progress;
}
return coordinates[coordinates.length - 1][1];
}
private _cancelAnimation() {
if (this._animationFrame === undefined) {
return;
}
cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
static styles = css`
:host {
display: flex;

View File

@@ -12,26 +12,35 @@ import {
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HistoryGraphCardConfig } from "../../cards/types";
import "../../components/hui-entity-editor";
import "../hui-sub-element-editor";
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { EntityConfig } from "../../entity-rows/types";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { isNumericFromAttributes } from "../../../../common/number/format_number";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-history-graph-card";
import type {
GraphEntityConfig,
HistoryGraphCardConfig,
} from "../../cards/types";
import "../../components/hui-entity-editor";
import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { processEditorEntities } from "../process-editor-entities";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entitiesConfigStruct } from "../structs/entities-struct";
import { DEFAULT_HOURS_TO_SHOW } from "../../cards/hui-history-graph-card";
import { graphEntitiesConfigStruct } from "../structs/entities-struct";
import type { EditDetailElementEvent, SubElementEditorConfig } from "../types";
import { orderPropertiesGraphCard } from "./order-properties/order-properties-graph";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
entities: array(entitiesConfigStruct),
entities: array(graphEntitiesConfigStruct),
title: optional(string()),
hours_to_show: optional(number()),
refresh_interval: optional(number()), // deprecated
@@ -43,19 +52,6 @@ const cardConfigStruct = assign(
})
);
const SUB_FORM = {
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
{
name: "name",
selector: { entity_name: {} },
context: {
entity: "entity",
},
},
] as const,
};
@customElement("hui-history-graph-card-editor")
export class HuiHistoryGraphCardEditor
extends LitElement
@@ -67,12 +63,9 @@ export class HuiHistoryGraphCardEditor
@state() private _subElementEditorConfig?: SubElementEditorConfig;
@state() private _configEntities?: EntityConfig[];
public setConfig(config: HistoryGraphCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
}
private _schema = memoizeOne(
@@ -123,17 +116,46 @@ export class HuiHistoryGraphCardEditor
] as const
);
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
schema: [
{ name: "entity", selector: { entity: {} }, required: true },
{
name: "name",
selector: { entity_name: {} },
context: {
entity: "entity",
},
},
{
name: "color",
disabled: this._shouldDisableColorOption(entityId),
selector: { ui_color: {} },
},
] as const,
computeLabel: (item: HaFormSchema) => {
switch (item.name) {
case "color":
return localize(`ui.panel.lovelace.editor.card.generic.${item.name}`);
default:
return undefined;
}
},
}));
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
if (this._subElementEditorConfig) {
const entityId = (
this._subElementEditorConfig.elementConfig! as { entity: string }
).entity;
return html`
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
.form=${SUB_FORM}
.form=${this._subForm(this.hass.localize, entityId)}
@go-back=${this._goBack}
@config-changed=${this._handleSubEntityChanged}
>
@@ -146,6 +168,9 @@ export class HuiHistoryGraphCardEditor
this._config!.max_y_axis !== undefined
);
const configEntities = this._config.entities
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
: [];
return html`
<ha-form
.hass=${this.hass}
@@ -156,7 +181,7 @@ export class HuiHistoryGraphCardEditor
></ha-form>
<hui-entity-editor
.hass=${this.hass}
.entities=${this._configEntities}
.entities=${configEntities}
can-edit
@entities-changed=${this._entitiesChanged}
@edit-detail-element=${this._editDetailElement}
@@ -175,36 +200,75 @@ export class HuiHistoryGraphCardEditor
private _handleSubEntityChanged(ev: CustomEvent): void {
ev.stopPropagation();
const index = this._subElementEditorConfig!.index!;
// get updated entity config
let newEntityConfig = ev.detail.config as GraphEntityConfig;
const entityId = newEntityConfig.entity;
if (this._shouldDisableColorOption(entityId)) {
// remove unused "color" option
newEntityConfig = this._deleteColorOption(newEntityConfig);
}
const newEntities = this._configEntities!.concat();
const newConfig = ev.detail.config as EntityConfig;
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: newConfig,
};
newEntities[index] = newConfig;
// update card config with updated entity config
const index = this._subElementEditorConfig!.index!;
const newEntities = [...this._config!.entities];
newEntities[index] = newEntityConfig;
let config = this._config!;
config = { ...config, entities: newEntities };
config = this._orderProperties(config);
this._config = config;
this._configEntities = processEditorEntities(config.entities);
// update sub-element editor config
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: {
...(this._config!.entities[index] as GraphEntityConfig),
},
};
fireEvent(this, "config-changed", { config });
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = this._orderProperties(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private _entitiesChanged(ev: CustomEvent): void {
let config = this._config!;
config = { ...config, entities: ev.detail.entities };
this._configEntities = processEditorEntities(config.entities);
config = this._orderProperties(config);
fireEvent(this, "config-changed", { config });
}
// a rough assumption about a numerical entity
// which may use state-history-chart-line
// where "color" option may be used
private _shouldDisableColorOption = (entityId: string) => {
const domain = computeDomain(entityId);
const isNumberDomain =
domain === "counter" || domain === "number" || domain === "input_number";
const stateObj = this.hass!.states[entityId];
const attributes = stateObj?.attributes;
return !isNumericFromAttributes(attributes) && !isNumberDomain;
};
// remove "color" option when needed
private _deleteColorOption(config: GraphEntityConfig): GraphEntityConfig {
const { color, ...rest } = config;
return rest as GraphEntityConfig;
}
// normalize a generated yaml code by placing lines in a consistent order
private _orderProperties(
config: HistoryGraphCardConfig
): HistoryGraphCardConfig {
return orderPropertiesGraphCard(
config,
cardConfigStruct
) as HistoryGraphCardConfig;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {

View File

@@ -33,12 +33,13 @@ import {
statisticsMetaHasType,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_DAYS_TO_SHOW } from "../../cards/hui-statistics-graph-card";
import type { StatisticsGraphCardConfig } from "../../cards/types";
import { processConfigEntities } from "../../common/process-config-entities";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entitiesConfigStruct } from "../structs/entities-struct";
import { DEFAULT_DAYS_TO_SHOW } from "../../cards/hui-statistics-graph-card";
import { graphEntitiesConfigStruct } from "../structs/entities-struct";
import { orderPropertiesGraphCard } from "./order-properties/order-properties-graph";
const statTypeStruct = union([
literal("state"),
@@ -52,7 +53,7 @@ const statTypeStruct = union([
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
entities: array(entitiesConfigStruct),
entities: array(graphEntitiesConfigStruct),
title: optional(string()),
days_to_show: optional(number()),
period: optional(
@@ -394,7 +395,8 @@ export class HuiStatisticsGraphCardEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = this._orderProperties(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private async _entitiesChanged(ev: CustomEvent): Promise<void> {
@@ -408,7 +410,7 @@ export class HuiStatisticsGraphCardEditor
return matchEntity ?? newEnt;
});
const config = { ...this._config!, entities: newEntities };
let config = { ...this._config!, entities: newEntities };
if (
newEntityIds?.some((statistic_id) => isExternalStatistic(statistic_id)) &&
config.period === "5minute"
@@ -437,11 +439,22 @@ export class HuiStatisticsGraphCardEditor
) {
delete config.unit;
}
config = this._orderProperties(config);
fireEvent(this, "config-changed", {
config,
});
}
// normalize a generated yaml code by placing lines in a consistent order
private _orderProperties(
config: StatisticsGraphCardConfig
): StatisticsGraphCardConfig {
return orderPropertiesGraphCard(
config,
cardConfigStruct
) as StatisticsGraphCardConfig;
}
private _computeHelperCallback = (schema) => {
switch (schema.name) {
case "collection_key":

View File

@@ -0,0 +1,29 @@
import type { Struct } from "superstruct";
import { orderProperties } from "../../../../../common/util/order-properties";
import type { GraphEntityConfig } from "../../../cards/types";
// normalize a generated yaml code by placing lines in a consistent order
export const orderPropertiesGraphCard = (
config: Record<string, any>,
cardConfigStruct: Struct<any, any>
): Record<string, any> => {
const fieldOrderCard = Object.keys(cardConfigStruct.schema);
const fieldOrderEntity = [
// ideally should be taken from a schema
"entity",
"name",
"color",
];
// normalize card's options
let orderedConfig = { ...orderProperties(config, fieldOrderCard) };
// normalize entities' options
const entitiesOrderedCfg = config.entities.map(
(entry: GraphEntityConfig | string) =>
typeof entry !== "string"
? orderProperties(entry, fieldOrderEntity)
: entry
);
// merge normalized config
orderedConfig = { ...orderedConfig, ...{ entities: entitiesOrderedCfg } };
return orderedConfig;
};

View File

@@ -24,3 +24,12 @@ export const entitiesConfigStruct = union([
}),
string(),
]);
export const graphEntitiesConfigStruct = union([
object({
entity: string(),
name: optional(entityNameStruct),
color: optional(string()),
}),
string(),
]);

View File

@@ -6,7 +6,6 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-alert";
import "../../../components/ha-spinner";
import type { HistoryStates } from "../../../data/history";
import {
limitedHistoryFromStateObj,
@@ -69,6 +68,8 @@ export class HuiGraphHeaderFooter
@state() private _coordinates?: [number, number][];
@state() private _loading = true;
@state() private _error?: { code: string; message: string };
private _history?: HistoryStates;
@@ -118,15 +119,7 @@ export class HuiGraphHeaderFooter
`;
}
if (!this._coordinates) {
return html`
<div class="container">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
if (!this._coordinates.length) {
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
@@ -135,7 +128,10 @@ export class HuiGraphHeaderFooter
}
return html`
<hui-graph-base .coordinates=${this._coordinates}></hui-graph-base>
<hui-graph-base
?loading=${this._loading}
.coordinates=${this._coordinates}
></hui-graph-base>
`;
}
@@ -166,6 +162,7 @@ export class HuiGraphHeaderFooter
) {
return;
}
this._setLoadingCoordinates();
this._subscribed = subscribeHistoryStatesTimeWindow(
this.hass!,
(combinedHistory) => {
@@ -193,6 +190,28 @@ export class HuiGraphHeaderFooter
this._setRedrawTimer();
}
private _setLoadingCoordinates() {
if (!this._config || !this.hass) {
return;
}
const stateObj = this.hass.states[this._config.entity];
if (!stateObj) {
return;
}
const width = this.clientWidth || this.offsetWidth;
const { points } = coordinatesMinimalResponseCompressedState(
limitedHistoryFromStateObj(stateObj),
width,
width / 5,
10,
{
minY: this._config.limits?.min,
maxY: this._config.limits?.max,
}
);
this._coordinates = points;
}
private _computeCoordinates() {
if (!this._history || !this._config) {
return;
@@ -225,6 +244,7 @@ export class HuiGraphHeaderFooter
useMean
);
this._coordinates = points;
this._loading = false;
}
private _redrawGraph() {
@@ -302,10 +322,6 @@ export class HuiGraphHeaderFooter
display: block;
cursor: pointer;
}
ha-spinner {
position: absolute;
top: calc(50% - 14px);
}
.container {
display: flex;
justify-content: center;

View File

@@ -256,6 +256,8 @@
"media_stop": "Stop",
"media_next_track": "Next track",
"media_previous_track": "Previous track",
"volume_up": "Volume up",
"volume_down": "Volume down",
"media_volume_up": "Volume up",
"media_volume_down": "Volume down",
"media_volume_mute": "Volume mute",

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
TOOLTIP_GAP_PX,
TOOLTIP_TOP_OFFSET_PX,
sideTooltipPosition,
} from "../../../src/components/chart/chart-tooltip-position";
const callPosition = (
cursorX: number,
options: {
viewSize?: [number, number];
contentSize?: [number, number];
rtl?: boolean;
} = {}
) => {
const dom = document.createElement("div");
if (options.rtl) {
dom.setAttribute("dir", "rtl");
document.body.appendChild(dom);
}
const result = sideTooltipPosition([cursorX, 0], [], dom, null, {
viewSize: options.viewSize ?? [800, 400],
contentSize: options.contentSize ?? [200, 120],
}) as [number, number];
if (options.rtl) {
document.body.removeChild(dom);
}
return result;
};
describe("sideTooltipPosition", () => {
it("places tooltip to the right of the cursor by default", () => {
const [x, y] = callPosition(100);
expect(x).toBe(100 + TOOLTIP_GAP_PX);
expect(y).toBe(TOOLTIP_TOP_OFFSET_PX);
});
it("flips to the left when right side overflows the chart", () => {
const [x] = callPosition(700, {
viewSize: [800, 400],
contentSize: [200, 120],
});
expect(x).toBe(700 - TOOLTIP_GAP_PX - 200);
});
it("clamps to chart bounds when neither side fits", () => {
const [x] = callPosition(50, {
viewSize: [120, 400],
contentSize: [200, 120],
});
expect(x).toBe(0);
});
it("clamps Y when chart is shorter than the tooltip", () => {
const [, y] = callPosition(100, {
viewSize: [800, 100],
contentSize: [200, 120],
});
expect(y).toBe(0);
});
it("prefers the left of the cursor in RTL mode", () => {
const [x] = callPosition(400, { rtl: true });
expect(x).toBe(400 - TOOLTIP_GAP_PX - 200);
});
});