Repository navigation
Conversation
| setIsHovered(false); | ||
| if (tooltipPinnedCoords === null) { | ||
| setShowTooltip(false); |
There was a problem hiding this comment.
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.
|
Hello @oschrenk thanks for your contribution !
little question here, what does it do if you set |
There was a problem hiding this comment.
🟡 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
TimeSeriesChartPaneltest suite does not verify thatcrossis 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
crossadds a horizontal line whenever the panel is hovered, but the runtime intentionally dropsaxisPointerfor 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.
| axisPointer?: { | ||
| type?: "cross" | ||
| } |
There was a problem hiding this comment.
I would prefer to do this as a follow up. I first want to get the spec and behaviour correct, before also changing SDK.
There was a problem hiding this comment.
if you are willing to make a followup PR, fine for me.
|
Nexucis
left a comment
There was a problem hiding this comment.
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 ?
|
@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>
4cfd9de to
02a7bb7
Compare
Rebased. Added test and improved perf |
|
yeah ok it does not bring anything particularly. Thanks for the different tests and check you have made ! |
Nexucis
left a comment
There was a problem hiding this comment.
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
crosspointer, the horizontal line uses the ECharts defaultcrossStyle(#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; |
There was a problem hiding this comment.
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.
| #tooltip: { | ||
| enablePinning?: bool | ||
| axisPointer?: { | ||
| type?: "cross" |
There was a problem hiding this comment.
nit: axisPointer: {} is currently valid and does nothing. Should type be required here, and in TooltipAxisPointerOptions as well?
There was a problem hiding this comment.
Made type required. Optionality is covered via axisPointer.
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>
bcf930a to
fe1aa14
Compare
|
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>
fe1aa14 to
61da1f6
Compare
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>
61da1f6 to
7cfda0f
Compare



Closes perses/perses#4434.
Adds an optional
axisPointersetting 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.
typeis required; leaving outaxisPointerkeeps the line off.Choices:
connectforwards 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.setOption, and a drag-to-zoom that leaves the panel keeps its selection.crossonly. While ECharts also allowsline,shadowandnone, onlycrossis accepted for now. Not sure if there is value in adding the other options, but it leaves room for it.crossdoes not apply there.Testing
timeserieschart/v0.13.0for host compatibilitysetOptioncalls, and a drag-to-zoom that overshoots the panel edge still zoomstypeScreenshots
From the first version, before the DOM overlay.
Checklist
[<catalog_entry>] <commit message>naming convention.