diff --git a/src/components/chart/chart-tooltip-position.ts b/src/components/chart/chart-tooltip-position.ts new file mode 100644 index 0000000000..5657ba4989 --- /dev/null +++ b/src/components/chart/chart-tooltip-position.ts @@ -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]; +}; diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 8e9f38438f..349a1b8d44 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -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 { @@ -413,8 +414,7 @@ export class StateHistoryChartLine extends LitElement { tooltip: { trigger: "axis", renderMode: "html", - position: "bottom", - align: "center", + position: sideTooltipPosition, confine: true, formatter: this._renderTooltip, }, diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 3a4329ff4f..c0a12fdb87 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -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"; @@ -263,8 +264,7 @@ export class StateHistoryChartTimeline extends LitElement { }, tooltip: { renderMode: "html", - position: "bottom", - align: "center", + position: sideTooltipPosition, confine: true, formatter: this._renderTooltip, }, diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index dc407b18ba..e892d57581 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -39,6 +39,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 = { @@ -459,8 +460,7 @@ export class StatisticsChart extends LitElement { tooltip: { trigger: "axis", renderMode: "html", - position: "bottom", - align: "center", + position: sideTooltipPosition, confine: true, formatter: this._renderTooltip, }, diff --git a/test/components/chart/chart-tooltip-position.test.ts b/test/components/chart/chart-tooltip-position.test.ts new file mode 100644 index 0000000000..7f18bc6f15 --- /dev/null +++ b/test/components/chart/chart-tooltip-position.test.ts @@ -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); + }); +});