Skip to content

[FEATURE] Add horizontal crosshair option to TimeSeriesChart plugin - #809

Open
oschrenk wants to merge 5 commits into
perses:mainfrom
oschrenk:feat/timeserieschart-cross-axispointer
Open

oschrenk wants to merge 5 commits into
perses:mainfrom
oschrenk:feat/timeserieschart-cross-axispointer

Conversation

@oschrenk

@oschrenk oschrenk commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes perses/perses#4434.

Adds an optional axisPointer setting to the TimeSeriesChart tooltip spec. Specifically the single option to add a horizontal line.

With type: "cross", hovering a panel now shows a horizontal line at the cursor's y position in addition to the default vertical line. Useful for visually comparing series levels within a panel.

It adds a spec field (no UI editing as of now) mirroring the ECharts naming and nesting, plus Go SDK support. type is required; leaving out axisPointer keeps the line off.

kind: TimeSeriesChart
spec:
  tooltip:
    axisPointer:
      type: "cross"

Choices:

  • Single panel only. Currently this setting is enabled per chart and the horizontal line is kept active only on the hovered chart (no syncing across charts). Syncing would be misleading: ECharts connect forwards the y pointer by value (axisTrigger.ts#L407-L411), so other panels would show a line at the same y value mapped into their own scale, which means nothing across different units.
  • DOM overlay. The horizontal line is a DOM element moved on mouse move, not an ECharts axis pointer. Hovering does not call setOption, and a drag-to-zoom that leaves the panel keeps its selection.
  • cross only. While ECharts also allows line, shadow and none, only cross is accepted for now. Not sure if there is value in adding the other options, but it leaves room for it.
  • No axis value labels. The overlay draws only the line, since the custom tooltip from Perses already shows the values.
  • Stacked bar excluded. cross does not apply there.

Testing

  • Manually tested the first version live against a Perses v0.54.0 server with the plugin dev server, by rebasing the patch onto timeserieschart/v0.13.0 for host compatibility
  • Checked the DOM overlay in headless Chrome with a throwaway harness: the line follows the cursor, hides outside the grid and on synced charts, hovering makes 0 setOption calls, and a drag-to-zoom that overshoots the panel edge still zooms
  • Added unit tests for the overlay and the Go SDK, and schema fixtures for an invalid and a missing type

Screenshots

From the first version, before the DOM overlay.

Screenshot 2026-09-10 at 10 35 09

Checklist

  • Pull request has a descriptive title and context useful to a reviewer.
  • Pull request title follows the [<catalog_entry>] <commit message> naming convention.
  • All commits have DCO signoffs.

@oschrenk
oschrenk requested review from a team and AntoineThebaud as code owners September 10, 2026 17:30
@oschrenk
oschrenk requested review from shahrokni and removed request for a team September 10, 2026 17:30
Comment on lines 495 to 497
setIsHovered(false);
if (tooltipPinnedCoords === null) {
setShowTooltip(false);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the description. I distinguish hovered and showing tooltips because, tooltips might be pinned, but i might not hover anymore.

Please advice if that distinction make sense to you.

@Nexucis

Nexucis commented Sep 18, 2026

Copy link
Copy Markdown
Member

Hello @oschrenk thanks for your contribution !

cross only. While ECharts also allows line, shadow and none, only cross is accepted for now. Not sure if there is value in adding the other options, but it leaves room for it.'

little question here, what does it do if you set line as a value and same for shadow ? Just to know if it brings anything interesting

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The TypeScript contract remains inconsistent and the Go SDK cannot serialize the new public option.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an optional horizontal crosshair to TimeSeriesChart tooltips for hovered, non-stacked charts.

Changes:

  • Adds the tooltip.axisPointer.type: "cross" contract.
  • Activates the crosshair only while hovering.
  • Adds schema fixtures and documentation.
File summaries
File Description
timeserieschart/src/TimeSeriesChartPanel.tsx Passes axis-pointer settings for eligible charts.
timeserieschart/src/TimeSeriesChartBase.tsx Configures hover-dependent ECharts crosshairs.
timeserieschart/src/time-series-chart-model.ts Adds TypeScript axis-pointer types.
timeserieschart/schemas/time-series.cue Defines the new schema field.
timeserieschart/schemas/tests/valid/time-series-tooltip-cross.json Adds a valid crosshair fixture.
timeserieschart/schemas/tests/invalid/time-series-tooltip-axis-pointer-invalid-type.json Rejects unsupported pointer types.
docs/timeserieschart/model.md Documents crosshair configuration.
Review details

Suppressed comments (2)

timeserieschart/src/TimeSeriesChartPanel.tsx:481

  • The runtime branching introduced here is not covered by the schema fixtures: the existing TimeSeriesChartPanel test suite does not verify that cross is passed for ordinary charts and suppressed for stacked bars, and the hover-only ECharts option behavior is also untested. Please add focused component coverage for these paths so regressions in the per-panel/stacked-bar guarantees are detected.
  const axisPointer = isStackedBar ? undefined : tooltip?.axisPointer;

docs/timeserieschart/model.md:35

  • This documentation says cross adds a horizontal line whenever the panel is hovered, but the runtime intentionally drops axisPointer for stacked bar charts (TimeSeriesChartPanel.tsx:467-481). Please document that stacked bar charts do not support this option, matching the behavior described in the PR.
# "cross" adds a horizontal line at the cursor's y position while hovering the panel.
# The horizontal line only shows on the hovered panel.
type: <enum = "cross"> # Optional
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +32 to +34
axisPointer?: {
type?: "cross"
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to do this as a follow up. I first want to get the spec and behaviour correct, before also changing SDK.

@Nexucis Nexucis Oct 8, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you are willing to make a followup PR, fine for me.

@oschrenk oschrenk Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go SDK: added in 7cfda0f.

@oschrenk

oschrenk commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor Author

little question here, what does it do if you set line as a value and same for shadow ? Just to know if it brings anything interesting

  • tooltip.axisPointer.line -> to my understanding the same as axisPointer.line, so basically the same as default that exists controlled by a separate setting
  • tooltip.axisPointer.shadow -> draws a shadowy box behind the currently select vertical line. See echarts example. I personally don't find that interesting and haven't explored if the custom tooltip/styling from perses has impact. Happy to explore if asked for.

@Nexucis Nexucis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry for the long time to coming back to you.

I think type should have the two possible values (so cross and vertical with vertical as default).

What do you think ?

@Nexucis

Nexucis commented Oct 26, 2026

Copy link
Copy Markdown
Member

@oschrenk any chance you would like to finish this PR ?

Add an optional tooltip.axisPointer spec field, mirroring the ECharts
naming and nesting. With type "cross", hovering a panel shows a
horizontal line at the cursor's y position.

Closes perses/perses#4434

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
@oschrenk
oschrenk force-pushed the feat/timeserieschart-cross-axispointer branch from 4cfd9de to 02a7bb7 Compare October 28, 2026 02:37
@oschrenk

Copy link
Copy Markdown
Contributor Author

sorry for the long time to coming back to you.

I think type should have the two possible values (so cross and vertical with vertical as default).

What do you think ?

Not sure actually, what you mean to be honest.

I tested all ECharts tooltip.axisPointer.type values. Only cross adds something:

  • line looks the same as unset. The vertical line already comes from the global axisPointer.
  • shadow (means only something on different chart types, a category axis.) and none remove the line on the hovered chart only.
  • ECharts has no vertical value.
cross line unset

What do you mean with vertical?

Do you mean it as a Perses name for the unset default? If yes, I can add it as an alias with no effect.

@oschrenk

Copy link
Copy Markdown
Contributor Author

@oschrenk any chance you would like to finish this PR ?

Rebased. Added test and improved perf

@Nexucis

Nexucis commented Oct 28, 2026

Copy link
Copy Markdown
Member

yeah ok it does not bring anything particularly. Thanks for the different tests and check you have made !

@Nexucis Nexucis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the rebase and the test!

I ran it locally: tests, type-check, format, lint (no new warnings) and the schema tests all pass. The main point is about performance on hover (see inline comment). A few smaller things:

  • With the ECharts cross pointer, the horizontal line uses the ECharts default crossStyle (#999) instead of the theme's axis pointer color. Close enough visually, but the overlay suggestion below also fixes that.
  • axisPointer: {} is valid but does nothing (see inline comment).
  • Go SDK: fine as a follow-up. Could you open an issue to track it?
Full patch for the overlay suggestion (including the updated test)
diff --git a/timeserieschart/src/TimeSeriesChartBase.test.tsx b/timeserieschart/src/TimeSeriesChartBase.test.tsx
index f92e111a..3cdfc3dc 100644
--- a/timeserieschart/src/TimeSeriesChartBase.test.tsx
+++ b/timeserieschart/src/TimeSeriesChartBase.test.tsx
@@ -189,21 +189,27 @@ describe('TimeSeriesChartBase performance', () => {
 });
 
 describe('TimeSeriesChartBase axis pointer', () => {
-  it('shows the cross axis pointer only while the chart is hovered', () => {
+  it('moves the cross line without updating chart options', () => {
     const chart = mockChart();
+    Object.assign(chart, {
+      containPixel: (): boolean => true,
+      convertToPixel: (_finder: unknown, value: number): number => (value === 0 ? 40 : 440),
+    });
     const { container } = render(chartElement({ axisPointer: { type: 'cross' } }));
     const wrapper = container.firstElementChild;
-    if (!(wrapper instanceof HTMLElement)) throw new Error('Chart wrapper is missing');
-    expect(chart.setOption.mock.calls.at(-1)?.[0].tooltip.axisPointer).toBeUndefined();
+    const canvas = document.createElement('canvas');
+    wrapper?.prepend(canvas);
+    const line = container.querySelector<HTMLElement>('[aria-hidden="true"]');
+    if (!(wrapper instanceof HTMLElement) || line === null) throw new Error('Chart elements are missing');
+    const setOptionCalls = chart.setOption.mock.calls.length;
 
     fireEvent.mouseEnter(wrapper);
-    expect(chart.setOption.mock.calls.at(-1)?.[0].tooltip.axisPointer).toEqual({
-      type: 'cross',
-      label: { show: false },
-    });
+    fireEvent.mouseMove(canvas, { clientX: 100, clientY: 120 });
+    expect(line.style.visibility).toBe('visible');
 
     fireEvent.mouseLeave(wrapper);
-    expect(chart.setOption.mock.calls.at(-1)?.[0].tooltip.axisPointer).toBeUndefined();
+    expect(line.style.visibility).toBe('hidden');
+    expect(chart.setOption).toHaveBeenCalledTimes(setOptionCalls);
   });
 });
 
diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx
index b5b2f05d..72926990 100644
--- a/timeserieschart/src/TimeSeriesChartBase.tsx
+++ b/timeserieschart/src/TimeSeriesChartBase.tsx
@@ -166,7 +166,7 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
   const isPinningEnabled = tooltipConfig.enablePinning && enablePinning;
   const chartRef = useRef<EChartsInstance>();
   const [showTooltip, setShowTooltip] = useState<boolean>(true);
-  const [isHovered, setIsHovered] = useState<boolean>(false);
+  const horizontalLineRef = useRef<HTMLDivElement>(null);
   const [tooltipPinnedCoords, setTooltipPinnedCoords] = useState<CursorCoordinates | null>(null);
   const [pinnedCrosshair, setPinnedCrosshair] = useState<LineSeriesOption | null>(null);
   const [isDragging, setIsDragging] = useState(false);
@@ -344,8 +344,6 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
     [data],
   );
 
-  const hoveredAxisPointerType = isHovered ? axisPointer?.type : undefined;
-
   const option: EChartsCoreOption = useMemo(() => {
     // The "chart" `noDataVariant` is only used when the `timeSeries` is an
     // empty array because a `null` value will throw an error.
@@ -363,7 +361,6 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
       showContent: isStackedBar,
       trigger: isStackedBar ? 'item' : 'axis',
       appendToBody: isStackedBar,
-      ...(hoveredAxisPointerType ? { axisPointer: { type: hoveredAxisPointerType, label: { show: false } } } : {}),
     };
 
     const option: EChartsCoreOption = {
@@ -429,9 +426,26 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
     enablePinning,
     pinnedCrosshair,
     getTimezoneAwareAxisFormatter,
-    hoveredAxisPointerType,
   ]);
 
+  // The horizontal crosshair is a DOM overlay moved through a ref: any ECharts setOption call,
+  // even in merge mode, reprocesses every series. It is also never forwarded to synced charts.
+  const updateHorizontalLine = (x: number, y: number): void => {
+    const line = horizontalLineRef.current;
+    const chart = chartRef.current;
+    if (line === null || chart === undefined) return;
+    if (!chart.containPixel('grid', [x, y])) {
+      line.style.visibility = 'hidden';
+      return;
+    }
+    const left = chart.convertToPixel({ xAxisIndex: 0 }, timeScale.startMs);
+    const right = chart.convertToPixel({ xAxisIndex: 0 }, timeScale.endMs);
+    line.style.left = `${left}px`;
+    line.style.width = `${right - left}px`;
+    line.style.transform = `translateY(${y}px)`;
+    line.style.visibility = 'visible';
+  };
+
   // Only changes from another chart (or new series) can clear the local pin.
   // A local click may update its coordinates without updating the shared pin.
   const [previousPinState, setPreviousPinState] = useState({ lastTooltipPinnedCoords, seriesMapping });
@@ -463,7 +477,7 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
   return (
     <Box
       ref={containerRef}
-      style={{ height }}
+      style={{ height, position: 'relative' }}
       // onContextMenu={(e) => {
       //   // TODO: confirm tooltip pinning works correctly on Windows, should e.preventDefault() be added here
       //   e.preventDefault(); // Prevent the default behaviour when right clicked
@@ -604,6 +618,9 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
           return;
         }
         const { clientX } = e;
+        if (axisPointer?.type === 'cross') {
+          updateHorizontalLine(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
+        }
         // ECharts does not reliably emit mouseout when the cursor moves off an exemplar marker
         // onto the plain plot area, which would keep the exemplar tooltip stuck on screen and
         // hide the regular TimeChartTooltip. Clear the hovered exemplar as soon as the cursor
@@ -637,7 +654,9 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
         setShowTooltip(true);
       }}
       onMouseLeave={() => {
-        setIsHovered(false);
+        if (horizontalLineRef.current !== null) {
+          horizontalLineRef.current.style.visibility = 'hidden';
+        }
         if (tooltipPinnedCoords === null) {
           setShowTooltip(false);
         }
@@ -649,7 +668,6 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
         }
       }}
       onMouseEnter={() => {
-        setIsHovered(true);
         setShowTooltip(true);
         if (chartRef.current !== undefined) {
           enableDataZoom(chartRef.current);
@@ -731,6 +749,20 @@ export const TimeSeriesChartBase = forwardRef<ChartInstance, TimeChartProps>(fun
         _instance={chartRef}
         syncGroup={enableSyncGrouping ? syncGroup : undefined}
       />
+      {axisPointer?.type === 'cross' && (
+        <Box
+          ref={horizontalLineRef}
+          aria-hidden
+          sx={(theme) => ({
+            position: 'absolute',
+            top: 0,
+            height: 0,
+            borderTop: `1px dashed ${theme.palette.grey[500]}`,
+            pointerEvents: 'none',
+            visibility: 'hidden',
+          })}
+        />
+      )}
     </Box>
   );
 });

[data],
);

const hoveredAxisPointerType = isHovered ? axisPointer?.type : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each time the mouse enters or leaves a panel with cross, this changes option, and the EChart wrapper then calls setOption(option, true), which rebuilds the whole chart. Switching to an imperative merge-mode setOption({ tooltip }) wouldn't help much: in ECharts 5.5.0, any setOption marks every series data task dirty (Scheduler.restoreData → SeriesModel.restoreData → dataTask.dirty()).

What about drawing the horizontal line as a DOM overlay moved through a ref instead? No option change, no isHovered state, and nothing is forwarded to synced charts:

const horizontalLineRef = useRef<HTMLDivElement>(null);

const updateHorizontalLine = (x: number, y: number): void => {
  const line = horizontalLineRef.current;
  const chart = chartRef.current;
  if (line === null || chart === undefined) return;
  if (!chart.containPixel('grid', [x, y])) {
    line.style.visibility = 'hidden';
    return;
  }
  const left = chart.convertToPixel({ xAxisIndex: 0 }, timeScale.startMs);
  const right = chart.convertToPixel({ xAxisIndex: 0 }, timeScale.endMs);
  line.style.left = `${left}px`;
  line.style.width = `${right - left}px`;
  line.style.transform = `translateY(${y}px)`;
  line.style.visibility = 'visible';
};

// onMouseMove, after the canvas check:
if (axisPointer?.type === 'cross') {
  updateHorizontalLine(e.nativeEvent.offsetX, e.nativeEvent.offsetY);
}
// onMouseLeave: horizontalLineRef.current.style.visibility = 'hidden';

// Container gets `position: 'relative'`, then after <EChart />:
{axisPointer?.type === 'cross' && (
  <Box
    ref={horizontalLineRef}
    aria-hidden
    sx={(theme) => ({
      position: 'absolute',
      top: 0,
      height: 0,
      borderTop: `1px dashed ${theme.palette.grey[500]}`,
      pointerEvents: 'none',
      visibility: 'hidden',
    })}
  />
)}

The full patch is in the review summary; it also updates the test to assert that setOption is not called while hovering. I tried it locally (type-check, tests and lint pass) but did not test it in a browser.

@oschrenk oschrenk Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 81475fe.

Comment thread timeserieschart/schemas/time-series.cue Outdated
#tooltip: {
enablePinning?: bool
axisPointer?: {
type?: "cross"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: axisPointer: {} is currently valid and does nothing. Should type be required here, and in TooltipAxisPointerOptions as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made type required. Optionality is covered via axisPointer.

oschrenk added a commit to oschrenk/plugins that referenced this pull request Oct 29, 2026
Any ECharts setOption call reprocesses every series, even in merge mode.
Toggling the cross pointer on hover rebuilt the chart on every enter and
leave, and leaving the panel during a drag-to-zoom dropped the zoom.

Draw the horizontal line as an absolutely positioned element that a ref
moves on mouse move. Hover no longer changes the chart options, synced
charts are not affected, and the line uses the theme colour.

Suggested by Nexucis in the review of perses#809.

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
@oschrenk
oschrenk force-pushed the feat/timeserieschart-cross-axispointer branch 2 times, most recently from bcf930a to fe1aa14 Compare October 30, 2026 02:38
@oschrenk

oschrenk commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Go SDK: added in 7cfda0f.

Hovering a chart without a configured axis pointer no longer rebuilds
the ECharts options. Document that stacked bar charts ignore the option
and test that the cross axis pointer follows the hover state.

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
Any ECharts setOption call reprocesses every series, even in merge mode.
Toggling the cross pointer on hover rebuilt the chart on every enter and
leave, and leaving the panel during a drag-to-zoom dropped the zoom.

Draw the horizontal line as an absolutely positioned element that a ref
moves on mouse move. Hover no longer changes the chart options, synced
charts are not affected, and the line uses the theme colour.

Suggested by Nexucis in the review of perses#809.

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
@oschrenk
oschrenk force-pushed the feat/timeserieschart-cross-axispointer branch from fe1aa14 to 61da1f6 Compare October 30, 2026 02:45
An empty axisPointer object was valid and had no effect. Leaving out
axisPointer still keeps the crosshair off.

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
Dashboard-as-Code users can now set tooltip.axisPointer.type to "cross"
through WithTooltip.

Signed-off-by: Oliver Schrenk <oliver.schrenk@gmail.com>
@oschrenk
oschrenk force-pushed the feat/timeserieschart-cross-axispointer branch from 61da1f6 to 7cfda0f Compare October 30, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TimeSeriesChart: option for a horizontal crosshair on hover?

3 participants