Fix dashboard trends tooltip portal rendering

This commit is contained in:
rcourtman
2026-04-15 11:39:06 +01:00
parent 38770f30c2
commit 9c907b22bf
6 changed files with 67 additions and 81 deletions
@@ -944,6 +944,13 @@ and `frontend-modern/src/components/shared/interactiveSparklineModel.ts` owns
sparkline downsampling, gap segmentation, axis-tick math, and hover-selection
policy. Future sparkline work should extend those owners instead of pushing
canvas scheduling or chart-shape math back into the shared component shell.
That same sparkline boundary now also owns floating tooltip shell routing:
local hover tooltips must derive viewport anchor coordinates from the shared
runtime/model path and render through
`frontend-modern/src/components/shared/TooltipPortal.tsx`, not as HTML
`foreignObject` shells inside the `preserveAspectRatio="none"` chart SVG where
cross-browser scaling can stretch the tooltip surface or drop its semantic
shell styling.
That same shared sparkline boundary now also owns active-series isolation
metadata. The shell may expose `data-active-series-display` and
`data-rendered-series-count` for proof and inspection, but only the shared
@@ -1,9 +1,9 @@
import { Component, For, Show } from 'solid-js';
import {
formatInteractiveSparklineHoverTime,
type InteractiveSparklineHoverState,
type InteractiveSparklineProps,
} from './interactiveSparklineModel';
import { TooltipPortal } from './TooltipPortal';
import { useInteractiveSparklineState } from './useInteractiveSparklineState';
export type {
@@ -27,11 +27,6 @@ const sparklineYAxisFontSize = (size?: InteractiveSparklineProps['size']) =>
const sparklineXAxisFontSize = (size?: InteractiveSparklineProps['size']) =>
size === 'lg' ? '12' : '10';
const tooltipWidth = (hover: InteractiveSparklineHoverState) => (hover.focusedTooltip ? 112 : 138);
const tooltipHeight = (hover: InteractiveSparklineHoverState) =>
22 + hover.values.length * 16 + (hover.totalValues > hover.values.length ? 14 : 0);
export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props) => {
let chartSurfaceRef: Element | undefined;
let canvasRef: HTMLCanvasElement | undefined;
@@ -187,52 +182,6 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
preserveAspectRatio="none"
aria-hidden="true"
>
<Show when={sparkline.hoveredState()}>
{(hover) => (
<foreignObject
x={hover().tooltipX - tooltipWidth(hover()) / 2}
y={hover().tooltipY - tooltipHeight(hover())}
width={tooltipWidth(hover())}
height={tooltipHeight(hover())}
overflow="visible"
>
<div
data-sparkline-tooltip="true"
class="h-full w-full rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-base-content shadow-lg"
>
<div class="mb-1 text-center font-medium text-base-content">
{formatInteractiveSparklineHoverTime(hover().timestamp)}
</div>
<For each={hover().values}>
{(entry) => (
<div
class={`flex items-center gap-1.5 leading-tight ${
props.highlightNearestSeriesOnHover &&
hover().focusedTooltip &&
hover().highlightedSeriesIndex === entry.seriesIndex
? 'rounded px-1 bg-slate-400/15'
: ''
}`}
>
<svg class="h-2 w-2 shrink-0" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="4" fill={entry.color} />
</svg>
<span class="text-muted">{entry.name}</span>
<span class="ml-auto font-medium text-base-content">
{sparkline.formatValue(entry.value)}
</span>
</div>
)}
</For>
<Show when={hover().totalValues > hover().values.length}>
<div class="mt-0.5 text-[10px] text-muted">
+{hover().totalValues - hover().values.length} more series
</div>
</Show>
</div>
</foreignObject>
)}
</Show>
</svg>
</div>
<div
@@ -286,6 +235,48 @@ export const InteractiveSparkline: Component<InteractiveSparklineProps> = (props
</For>
</svg>
</div>
<Show when={sparkline.hoveredState()}>
{(hover) => (
<TooltipPortal when={true} x={hover().tooltipX} y={hover().tooltipY}>
<div
data-sparkline-tooltip="true"
class={`max-w-[220px] text-[10px] ${
hover().focusedTooltip ? 'min-w-[112px]' : 'min-w-[138px]'
}`}
>
<div class="mb-1 text-center font-medium text-base-content">
{formatInteractiveSparklineHoverTime(hover().timestamp)}
</div>
<For each={hover().values}>
{(entry) => (
<div
class={`flex items-center gap-1.5 leading-tight ${
props.highlightNearestSeriesOnHover &&
hover().focusedTooltip &&
hover().highlightedSeriesIndex === entry.seriesIndex
? 'rounded px-1 bg-slate-400/15'
: ''
}`}
>
<svg class="h-2 w-2 shrink-0" viewBox="0 0 8 8" aria-hidden="true">
<circle cx="4" cy="4" r="4" fill={entry.color} />
</svg>
<span class="text-muted">{entry.name}</span>
<span class="ml-auto font-medium text-base-content">
{sparkline.formatValue(entry.value)}
</span>
</div>
)}
</For>
<Show when={hover().totalValues > hover().values.length}>
<div class="mt-0.5 text-[10px] text-muted">
+{hover().totalValues - hover().values.length} more series
</div>
</Show>
</div>
</TooltipPortal>
)}
</Show>
</div>
);
};
@@ -244,9 +244,8 @@ describe('shared primitive guardrails', () => {
expect(tooltipPortalSource).toContain('text-base-content');
expect(tooltipPortalSource).toContain('border-border');
expect(tooltipPortalSource).not.toContain("'background-color': 'rgb(15, 23, 42)'");
expect(interactiveSparklineSource).toContain('bg-surface');
expect(interactiveSparklineSource).toContain('TooltipPortal');
expect(interactiveSparklineSource).toContain('text-base-content');
expect(interactiveSparklineSource).toContain('border-border');
expect(interactiveSparklineSource).not.toContain("'background-color': 'rgb(15, 23, 42)'");
});
@@ -780,6 +779,7 @@ describe('shared primitive guardrails', () => {
expect(interactiveSparklineSource).toContain('data-active-series-display');
expect(interactiveSparklineSource).toContain('data-active-hover-cursor-x');
expect(interactiveSparklineSource).toContain('data-sparkline-tooltip="true"');
expect(interactiveSparklineSource).toContain('TooltipPortal');
expect(interactiveSparklineSource).toContain('data-sparkline-y-axis="true"');
expect(interactiveSparklineSource).toContain('data-sparkline-x-axis="true"');
expect(interactiveSparklineSource).toContain('axisPositionPercent(tick.y, sparkline.vbH)');
@@ -816,10 +816,10 @@ describe('shared primitive guardrails', () => {
expect(interactiveSparklineModelSource).toContain('buildInteractiveSparklineChartData');
expect(interactiveSparklineModelSource).toContain('computeInteractiveSparklineHoverState');
expect(interactiveSparklineModelSource).toContain('getInteractiveSparklineCursorXForTimestamp');
expect(interactiveSparklineModelSource).toContain('const tooltipX = chartRect.left + mouseX;');
expect(interactiveSparklineModelSource).toContain(
'let tooltipY = (mouseY / chartRect.height) * vbH - 6;',
'const tooltipY = chartRect.top + mouseY - 6;',
);
expect(interactiveSparklineModelSource).not.toContain('let tooltipY = chartRect.top - 6;');
expect(interactiveSparklineModelSource).toContain('downsampleLTTB');
expect(interactiveSparklineModelSource).toContain('findNearestMetricPoint');
});
@@ -9,9 +9,16 @@ import { buildInteractiveSparklineSynchronizedReadout } from '@/components/share
describe('InteractiveSparkline hover behavior', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
cleanup();
});
const mockImmediateRaf = () =>
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
it('keeps the sparkline on shell, runtime, and model owners', () => {
expect(interactiveSparklineSource).toContain('useInteractiveSparklineState');
expect(interactiveSparklineSource).not.toContain('style={{');
@@ -60,6 +67,7 @@ describe('InteractiveSparkline hover behavior', () => {
it('shows a vertical dashed hover line and a tooltip', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
mockImmediateRaf();
const now = Date.now();
const { container } = render(() => (
@@ -311,6 +319,7 @@ describe('InteractiveSparkline hover behavior', () => {
it('limits tooltip rows and shows the "+N more series" affordance', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
mockImmediateRaf();
const now = Date.now();
const makeSeries = (i: number, value: number) => ({
@@ -358,6 +367,7 @@ describe('InteractiveSparkline hover behavior', () => {
it('clamps tooltip position so it stays in the viewport', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
mockImmediateRaf();
const now = Date.now();
const { container } = render(() => (
@@ -414,6 +424,7 @@ describe('InteractiveSparkline hover behavior', () => {
it('anchors the tooltip to the pointer instead of the chart top edge', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
mockImmediateRaf();
const now = Date.now();
const { container } = render(() => (
@@ -596,8 +596,6 @@ export const computeInteractiveSparklineHoverState = ({
sortTooltipByValue,
highlightNearestSeriesOnHover,
lockedSeriesIndex,
tooltipPadding,
tooltipEstimatedWidth,
}: {
chartData: InteractiveSparklineChartData;
chartRect: DOMRect;
@@ -610,8 +608,6 @@ export const computeInteractiveSparklineHoverState = ({
sortTooltipByValue?: boolean;
highlightNearestSeriesOnHover?: boolean;
lockedSeriesIndex: number | null;
tooltipPadding: number;
tooltipEstimatedWidth: number;
}): InteractiveSparklineHoverState | null => {
if (chartData.validSeries.length === 0 || chartData.rangeMs <= 0 || chartRect.width <= 0) {
return null;
@@ -706,23 +702,8 @@ export const computeInteractiveSparklineHoverState = ({
}
const totalValues = focusedTooltip ? tooltipValues.length : values.length;
let tooltipX = chartX;
let tooltipY = (mouseY / chartRect.height) * vbH - 6;
const shownRows = tooltipValues.length;
const tooltipWidth =
(focusedTooltip ? tooltipEstimatedWidth * 0.78 : tooltipEstimatedWidth) *
(vbW / Math.max(chartRect.width, 1));
const tooltipHeight =
(22 + shownRows * 16 + (totalValues > shownRows ? 14 : 0)) *
(vbH / Math.max(chartRect.height, 1));
const minTooltipX = tooltipPadding + tooltipWidth / 2;
const maxTooltipX = Math.max(minTooltipX, vbW - tooltipPadding - tooltipWidth / 2);
tooltipX = clampInteractiveSparklineValue(tooltipX, minTooltipX, maxTooltipX);
const minTooltipY = tooltipHeight + tooltipPadding;
const maxTooltipY = Math.max(minTooltipY, vbH - tooltipPadding);
tooltipY = clampInteractiveSparklineValue(tooltipY, minTooltipY, maxTooltipY);
const tooltipX = chartRect.left + mouseX;
const tooltipY = chartRect.top + mouseY - 6;
return {
x: chartX,
@@ -32,8 +32,6 @@ export function useInteractiveSparklineState(
const vbH = 100;
const vbW = 200;
const xAxisBandPx = 16;
const tooltipPadding = 8;
const tooltipEstimatedWidth = 190;
const maxRows = () => props.maxTooltipRows ?? 6;
const yMode = () => props.yMode ?? 'percent';
const activeSeriesDisplay = () => props.activeSeriesDisplay ?? 'emphasize';
@@ -78,8 +76,6 @@ export function useInteractiveSparklineState(
sortTooltipByValue: props.sortTooltipByValue,
highlightNearestSeriesOnHover: props.highlightNearestSeriesOnHover,
lockedSeriesIndex: props.highlightNearestSeriesOnHover ? lockedSeriesIndex() : null,
tooltipPadding,
tooltipEstimatedWidth,
});
setHoveredState(computed);
if (!props.onHoverSyncChange || !props.hoverSourceKey) {