Compare commits

...

1 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
5 changed files with 112 additions and 6 deletions

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 {
@@ -410,8 +411,7 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},

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

@@ -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> = {
@@ -398,8 +399,7 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},

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);
});
});