diff --git a/timeserieschart/src/TimeSeriesChartBase.tsx b/timeserieschart/src/TimeSeriesChartBase.tsx index c4ecf7af5..8b1cb7c93 100644 --- a/timeserieschart/src/TimeSeriesChartBase.tsx +++ b/timeserieschart/src/TimeSeriesChartBase.tsx @@ -28,6 +28,7 @@ import { DEFAULT_TOOLTIP_CONFIG, EChart, enableDataZoom, + ExemplarMetadataTooltip, getClosestTimestamp, getCommonTimeScale, getFormattedAxis, @@ -37,7 +38,7 @@ import { useChartsContext, useTimeZone, } from '@perses-dev/components'; -import type { TimeScale, TimeSeries } from '@perses-dev/spec'; +import type { Exemplar, Labels, TimeScale, TimeSeries } from '@perses-dev/spec'; import type { DatasetComponentOption as DatasetOption, EChartsCoreOption, @@ -46,7 +47,11 @@ import type { YAXisComponentOption, TooltipComponentOption, } from 'echarts'; -import { LineChart as EChartsLineChart, BarChart as EChartsBarChart } from 'echarts/charts'; +import { + LineChart as EChartsLineChart, + BarChart as EChartsBarChart, + ScatterChart as EChartsScatterChart, +} from 'echarts/charts'; import { GridComponent, DatasetComponent, @@ -68,11 +73,14 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { AnnotationTooltip, buildAnnotationSeries } from './annotations/AnnotationTooltip'; import type { TimeSeriesAnnotation } from './utils/annotation'; +import type { ExemplarChartData } from './utils/data-transform'; +import { EXEMPLAR_SERIES_ID_PREFIX, EXEMPLAR_SYMBOL_SIZE, getExemplarSeries } from './utils/data-transform'; import { createTimezoneAwareAxisFormatter } from './utils/timezone-formatter'; registerECharts([ EChartsLineChart, EChartsBarChart, + EChartsScatterChart, GridComponent, DatasetComponent, DataZoomComponent, @@ -85,10 +93,27 @@ registerECharts([ CanvasRenderer, ]); +interface HoveredExemplar { + exemplar: Exemplar; + seriesLabels?: Labels; + /** + * The Y value actually plotted for the marker. Differs from `exemplar.value` when the + * matching series is rendered with the negativeY transform (value negated for display). + */ + plottedValue: number; +} + +/** + * Maximum pixel distance from an exemplar marker center for the cursor to count as still + * hovering it (covers the full diamond bounding box, corners included). + */ +const EXEMPLAR_HOVER_RADIUS = (Math.SQRT2 * EXEMPLAR_SYMBOL_SIZE) / 2; + export interface TimeChartProps { height: number; data: TimeSeries[]; seriesMapping: TimeChartSeriesMapping; + exemplars?: ExemplarChartData[]; annotations?: TimeSeriesAnnotation[]; timeScale?: TimeScale; yAxis?: YAXisComponentOption | YAXisComponentOption[]; @@ -112,6 +137,7 @@ export const TimeSeriesChartBase = forwardRef(fun height, data, seriesMapping, + exemplars, annotations, timeScale: timeScaleProp, yAxis, @@ -139,6 +165,9 @@ export const TimeSeriesChartBase = forwardRef(fun const [startX, setStartX] = useState(0); const [hoveredAnnotation, setHoveredAnnotation] = useState(null); const [pinnedAnnotation, setPinnedAnnotation] = useState(null); + const [hoveredExemplar, setHoveredExemplar] = useState(null); + const [pinnedExemplar, setPinnedExemplar] = useState(null); + const [pinnedExemplarPos, setPinnedExemplarPos] = useState(null); const [pinnedAnnotationPos, setPinnedAnnotationPos] = useState(null); const { timeZone, formatWithUserTimeZone } = useTimeZone(); @@ -212,6 +241,22 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseover: (params: any): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + if (params.data?.exemplar) { + setHoveredExemplar({ + exemplar: params.data.exemplar, + seriesLabels: params.data.seriesLabels, + plottedValue: params.data?.value?.[1] ?? params.data.exemplar.value, + }); + return; + } + } + setHoveredExemplar(null); // Only markPoint (triangles under the X-axis) opens the annotation tooltip. // Hovering markLine or anything else keeps the regular TimeSeries tooltip visible // and clears any stale hovered annotation (mouseout is sometimes missed by ECharts). @@ -226,6 +271,15 @@ export const TimeSeriesChartBase = forwardRef(fun }, // eslint-disable-next-line @typescript-eslint/no-explicit-any mouseout: (params: any): void => { + if ( + params.componentType === 'series' && + params.seriesType === 'scatter' && + typeof params.seriesId === 'string' && + params.seriesId.startsWith(EXEMPLAR_SERIES_ID_PREFIX) + ) { + setHoveredExemplar(null); + return; + } if ( annotations && params.componentType === 'markPoint' && @@ -243,6 +297,7 @@ export const TimeSeriesChartBase = forwardRef(fun // Cursor left the chart canvas — guarantee the annotation tooltip is dismissed. setHoveredAnnotation(null); } + setHoveredExemplar(null); }, }; }, [annotations, onDataZoom]); @@ -250,6 +305,8 @@ export const TimeSeriesChartBase = forwardRef(fun // Generate annotation series for ECharts markArea (range), markLine (point), and markPoint (markers under X-axis) const annotationSeries = useMemo(() => buildAnnotationSeries(annotations), [annotations]); + const exemplarSeries = useMemo(() => exemplars?.map(getExemplarSeries) ?? [], [exemplars]); + const { noDataOption } = chartsTheme; const option: EChartsCoreOption = useMemo(() => { @@ -270,8 +327,8 @@ export const TimeSeriesChartBase = forwardRef(fun const updatedSeriesMapping = enablePinning && pinnedCrosshair !== null - ? [...seriesMapping, pinnedCrosshair, ...annotationSeries] - : [...seriesMapping, ...annotationSeries]; + ? [...seriesMapping, pinnedCrosshair, ...annotationSeries, ...exemplarSeries] + : [...seriesMapping, ...annotationSeries, ...exemplarSeries]; const option: EChartsCoreOption = { dataset: dataset, @@ -327,6 +384,7 @@ export const TimeSeriesChartBase = forwardRef(fun data, seriesMapping, annotationSeries, + exemplarSeries, timeScale, yAxis, format, @@ -353,6 +411,16 @@ export const TimeSeriesChartBase = forwardRef(fun } } } + // A pinned exemplar tooltip is also unpinned when a tooltip is pinned in another chart, + // unless it is the one just pinned by this chart at these exact coordinates. + if ( + pinnedExemplarPos !== null && + lastTooltipPinnedCoords !== null && + !isEqual(lastTooltipPinnedCoords, pinnedExemplarPos) + ) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + } // tooltipPinnedCoords CANNOT be in dep array or tooltip pinning breaks in the current chart's onClick // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastTooltipPinnedCoords, seriesMapping]); @@ -365,6 +433,45 @@ export const TimeSeriesChartBase = forwardRef(fun // e.preventDefault(); // Prevent the default behaviour when right clicked // }} onClick={(e) => { + // Allows user to opt-in to multi tooltip pinning when Ctrl or Cmd key held down + const isControlKeyPressed = e.ctrlKey || e.metaKey; + if (isControlKeyPressed) { + e.preventDefault(); + } + + // If clicking while hovering an exemplar marker, toggle the exemplar tooltip pin + // instead of pinning the TimeChartTooltip. Pinning an exemplar tooltip unpins the + // pinned TimeChartTooltip, so only one tooltip stays pinned at a time unless + // Ctrl or Cmd is held down. + if (hoveredExemplar !== null && e.target instanceof HTMLCanvasElement) { + const pinnedPos: CursorCoordinates = { + page: { x: e.pageX, y: e.pageY }, + client: { x: e.clientX, y: e.clientY }, + plotCanvas: { x: e.nativeEvent.offsetX, y: e.nativeEvent.offsetY }, + target: e.target, + }; + const isUnpinClick = pinnedExemplar !== null && pinnedExemplar.exemplar === hoveredExemplar.exemplar; + // Compute the next state first and call the setters separately: updater functions + // may run more than once (e.g. StrictMode), so they must stay side-effect free. + setPinnedExemplar(isUnpinClick ? null : hoveredExemplar); + setPinnedExemplarPos(isUnpinClick ? null : pinnedPos); + if (!isUnpinClick && !isControlKeyPressed) { + // Unpin the pinned TimeChartTooltip and let adjacent charts know a tooltip is + // pinned at these coordinates, so only one tooltip is pinned at a time. + setTooltipPinnedCoords(null); + setPinnedCrosshair(null); + setLastTooltipPinnedCoords(pinnedPos); + } + return; + } + + // Unpin a pinned exemplar tooltip when clicking elsewhere on the chart canvas, + // so the same click can pin the TimeChartTooltip instead. Ctrl or Cmd keeps both. + if (pinnedExemplar !== null && !isControlKeyPressed && e.target instanceof HTMLCanvasElement) { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + } + // If clicking while hovering an annotation, toggle the annotation tooltip pin // instead of pinning the TimeChartTooltip, so pinned TimeChartTooltip is preserved. if (hoveredAnnotation !== null && e.target instanceof HTMLCanvasElement) { @@ -385,12 +492,6 @@ export const TimeSeriesChartBase = forwardRef(fun return; } - // Allows user to opt-in to multi tooltip pinning when Ctrl or Cmd key held down - const isControlKeyPressed = e.ctrlKey || e.metaKey; - if (isControlKeyPressed) { - e.preventDefault(); - } - // Determine where on chart canvas to plot pinned crosshair as markLine. const pointInGrid = getPointInGrid(e.nativeEvent.offsetX, e.nativeEvent.offsetY, chartRef.current); if (pointInGrid === null) { @@ -467,6 +568,25 @@ export const TimeSeriesChartBase = forwardRef(fun return; } const { clientX } = e; + // 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 + // is no longer within its marker, so the next hover can take over. + setHoveredExemplar((current) => { + if (current === null || chartRef.current === undefined) return current; + let markerPixel: number[] | undefined; + try { + // Use the plotted value so negated markers (negativeY) are tracked correctly. + markerPixel = chartRef.current.convertToPixel('grid', [current.exemplar.timestamp, current.plottedValue]); + } catch { + // Coordinates cannot be resolved (e.g. grid not ready yet), keep the current hover. + return current; + } + const [markerPixelX, markerPixelY] = markerPixel ?? []; + if (markerPixelX === undefined || markerPixelY === undefined) return current; + const distance = Math.hypot(e.nativeEvent.offsetX - markerPixelX, e.nativeEvent.offsetY - markerPixelY); + return distance <= EXEMPLAR_HOVER_RADIUS ? current : null; + }); if (isDragging) { const deltaX = clientX - startX; if (deltaX > 0) { @@ -484,8 +604,9 @@ export const TimeSeriesChartBase = forwardRef(fun if (tooltipPinnedCoords === null) { setShowTooltip(false); } - // Defensive: clear hovered annotation in case ECharts missed a mouseout event. + // Defensive: clear hovered annotation and exemplar in case ECharts missed a mouseout event. setHoveredAnnotation(null); + setHoveredExemplar(null); if (chartRef.current !== undefined) { clearHighlightedSeries(chartRef.current); } @@ -509,9 +630,11 @@ export const TimeSeriesChartBase = forwardRef(fun }} > {/* Allows overrides prop to hide custom tooltip and use the ECharts option.tooltip instead. - Keep the time chart tooltip visible when pinned even if user hovers an annotation. */} + Keep the time chart tooltip visible when pinned even if user hovers an annotation or exemplar, + but do not show the mouse-following tooltip on top of a pinned exemplar tooltip. */} {showTooltip === true && - (tooltipPinnedCoords !== null || hoveredAnnotation === null) && + (tooltipPinnedCoords !== null || + (pinnedExemplar === null && hoveredAnnotation === null && hoveredExemplar === null)) && (option.tooltip as TooltipComponentOption)?.showContent === false && tooltipConfig.hidden !== true && ( (fun }} /> )} + {/* Pinned exemplar takes priority over hovered. While a TimeChartTooltip is pinned, the + mouse-following exemplar tooltip is not rendered so it does not appear on top of it. */} + {(pinnedExemplar !== null || (hoveredExemplar !== null && tooltipPinnedCoords === null)) && ( + { + setPinnedExemplar(null); + setPinnedExemplarPos(null); + }} + /> + )} {/* Pinned annotation takes priority over hovered. */} {(pinnedAnnotation ?? hoveredAnnotation) && ( ({ lastChartOption: { current: undefined as unknown } })); +vi.mock('@perses-dev/components', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + EChart: (props: Record): ReactElement => { + lastChartOption.current = props.option; + return
; + }, + }; +}); import type { TimeSeriesChartProps } from './TimeSeriesChartPanel'; import { TimeSeriesChartPanel } from './TimeSeriesChartPanel'; @@ -76,7 +92,7 @@ function getLegendByName(name?: string): HTMLElement { describe('TimeSeriesChartPanel', () => { // Helper to render the panel with some context set - const renderPanel = (): void => { + const renderPanel = (data = MOCK_TIME_SERIES_DATA_MULTIVALUE): void => { const mockTimeRangeContext = { refreshIntervalInMs: 0, setRefreshInterval: (): Record => ({}), @@ -92,7 +108,7 @@ describe('TimeSeriesChartPanel', () => { @@ -100,6 +116,67 @@ describe('TimeSeriesChartPanel', () => { ); }; + describe('exemplars', () => { + const getExemplarSeries = (): unknown[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const option = lastChartOption.current as any; + return (option?.series ?? []).filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (s: any) => typeof s?.id === 'string' && s.id.startsWith('exemplar-'), + ); + }; + + it('should render exemplars as diamond scatter series with embedded metadata', async () => { + renderPanel({ ...MOCK_TIME_SERIES_DATA_MULTIVALUE, exemplars: MOCK_TIME_SERIES_EXEMPLARS }); + const exemplarSeries = await waitFor(() => { + const series = getExemplarSeries(); + expect(series).toHaveLength(2); + return series; + }); + + expect(exemplarSeries).toHaveLength(2); + for (const series of exemplarSeries) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const s = series as any; + expect(s.type).toEqual('scatter'); + expect(s.symbol).toEqual('diamond'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const vda1Series = exemplarSeries.find((s) => (s as any)?.id?.includes('vda1')) as any; + const firstItem = vda1Series?.data?.[0]; + const expectedExemplars = MOCK_TIME_SERIES_EXEMPLARS[0]?.exemplars ?? []; + expect(firstItem?.exemplar).toEqual(expectedExemplars[0]); + expect(firstItem?.seriesLabels).toEqual(MOCK_TIME_SERIES_EXEMPLARS[0]?.seriesLabels); + }); + + it('should not render exemplar series when the query data has no exemplars', async () => { + renderPanel(); + await screen.findByText( + 'device="/dev/vda1", env="demo", fstype="ext4", instance="demo.do.prometheus.io:9100", job="node", mountpoint="/"', + ); + expect(getExemplarSeries()).toHaveLength(0); + }); + + it('should only render exemplars of series selected in the legend', async () => { + renderPanel({ ...MOCK_TIME_SERIES_DATA_MULTIVALUE, exemplars: MOCK_TIME_SERIES_EXEMPLARS }); + await waitFor(() => { + expect(getExemplarSeries()).toHaveLength(2); + }); + + // NOTE: the project pins @testing-library/user-event v13, whose direct + // `userEvent.click` API is synchronous (the v14 `setup()` API is not available). + userEvent.click(getLegendByName(MOCK_TIME_SERIES_DATA_MULTIVALUE.series[0]?.name)); + + await waitFor(() => { + const exemplarSeries = getExemplarSeries(); + expect(exemplarSeries).toHaveLength(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((exemplarSeries[0] as any)?.id).toContain('vda1'); + }); + }); + }); + it('should render the legend with unformatted series labels', async () => { renderPanel(); expect( diff --git a/timeserieschart/src/TimeSeriesChartPanel.tsx b/timeserieschart/src/TimeSeriesChartPanel.tsx index e0145d9c4..9ddd1fff2 100644 --- a/timeserieschart/src/TimeSeriesChartPanel.tsx +++ b/timeserieschart/src/TimeSeriesChartPanel.tsx @@ -43,7 +43,7 @@ import { legendValues, getCalculations, } from '@perses-dev/plugin-system'; -import type { TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; +import type { Labels, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import type { GridComponentOption } from 'echarts'; import merge from 'lodash/merge'; import type { ReactElement } from 'react'; @@ -54,6 +54,7 @@ import { DEFAULT_FORMAT, DEFAULT_VISUAL, THRESHOLD_PLOT_INTERVAL } from './time- import { TimeSeriesChartBase } from './TimeSeriesChartBase'; import type { TimeSeriesAnnotation } from './utils/annotation'; import { convertAnnotationToTimeSeriesAnnotation } from './utils/annotation'; +import type { ExemplarChartData } from './utils/data-transform'; import { getTimeSeries, getCommonTimeScaleForQueries, @@ -65,6 +66,18 @@ import { getSeriesColor } from './utils/palette-gen'; export type TimeSeriesChartProps = PanelProps; +/** + * Stable string key for a labels record, so exemplars can be matched to their + * series regardless of the labels order. + */ +function labelsKey(labels: Labels): string { + return JSON.stringify( + Object.keys(labels) + .toSorted() + .map((labelName) => [labelName, labels[labelName]]), + ); +} + // Using an "ALL" value to handle the case on first loading the chart where we // want to select all, but do not want all of the legend items to be visually highlighted. // This helps us differentiate those cases more clearly instead of inferring it @@ -168,14 +181,17 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement timeChartData, timeSeriesMapping, legendItems, + chartExemplars, seriesFormatMap: computedSeriesFormatMap, maxValuesByFormat, } = useMemo(() => { const timeScale = getCommonTimeScaleForQueries(queryResults); if (timeScale === undefined) { return { - timeChartData: [], - timeSeriesMapping: [], + timeChartData: [] as TimeSeries[], + timeSeriesMapping: [] as TimeChartSeriesMapping, + chartExemplars: [] as ExemplarChartData[], + legendItems: [] as LegendItem[], seriesFormatMap: new Map(), maxValuesByFormat: new Map(), }; @@ -194,6 +210,10 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement // Index is counted across multiple queries which ensures the categorical color palette does not reset for every query let seriesIndex = 0; + const seriesByLabels = new Map(); + + const chartExemplars: ExemplarChartData[] = []; + // Mapping of each set of query results to be ECharts option compatible // TODO: Look into performance optimizations and moving parts of mapping to the lower level chart for (let queryIndex = 0; queryIndex < queryResults.length; queryIndex++) { @@ -214,7 +234,12 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement for (let i = 0; i < result.data.series.length; i++) { const timeSeries: TimeSeries | undefined = result.data.series[i]; if (timeSeries === undefined) { - return { timeChartData: [], timeSeriesMapping: [], legendItems: [] }; + return { + timeChartData: [] as TimeSeries[], + timeSeriesMapping: [] as TimeChartSeriesMapping, + chartExemplars: [] as ExemplarChartData[], + legendItems: [] as LegendItem[], + }; } // Format is determined by seriesNameFormat in query spec @@ -302,6 +327,20 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement name: formattedSeriesName, values: renderedValues, }); + + if (timeSeries.labels) { + seriesByLabels.set(labelsKey(timeSeries.labels), { + seriesId, + seriesName: formattedSeriesName, + color: seriesColor, + seriesLabels: timeSeries.labels, + yAxisIndex, + // Exemplar markers must render on the same side of the X axis as their + // series, so they inherit the negativeY visual transform of the query. + negativeY: querySettings?.negativeY, + exemplars: [], + }); + } } if (legend && legendItems) { @@ -317,6 +356,16 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement seriesIndex++; } } + + const queryExemplars = result?.data.exemplars; + if (queryExemplars) { + for (const seriesExemplars of queryExemplars) { + if (seriesExemplars.exemplars.length === 0) continue; + const matched = seriesByLabels.get(labelsKey(seriesExemplars.seriesLabels)); + if (matched === undefined) continue; + chartExemplars.push({ ...matched, exemplars: seriesExemplars.exemplars }); + } + } } // map thresholds only if there is at least one time series to avoid displaying thresholds without any data @@ -362,6 +411,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement timeChartData, timeSeriesMapping, legendItems, + chartExemplars, seriesFormatMap, maxValuesByFormat, }; @@ -517,6 +567,7 @@ export function TimeSeriesChartPanel(props: TimeSeriesChartProps): ReactElement height={height} data={timeChartData} seriesMapping={timeSeriesMapping} + exemplars={chartExemplars} annotations={annotations} timeScale={timeScale} yAxis={multipleYAxes ?? echartsYAxis} diff --git a/timeserieschart/src/test/mock-query-results.ts b/timeserieschart/src/test/mock-query-results.ts index a946dfbc5..0b69bc18d 100644 --- a/timeserieschart/src/test/mock-query-results.ts +++ b/timeserieschart/src/test/mock-query-results.ts @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { TimeSeriesData } from '@perses-dev/spec'; +import type { TimeSeriesData, TimeSeriesExemplars } from '@perses-dev/spec'; export const MOCK_TIME_SERIES_QUERY_RESULT_MULTIVALUE = [ { @@ -175,6 +175,43 @@ export const MOCK_TIME_SERIES_DATA_MULTIVALUE: TimeSeriesData = { ], }; +export const MOCK_TIME_SERIES_EXEMPLARS: TimeSeriesExemplars[] = [ + { + seriesLabels: { + device: '/dev/vda1', + env: 'demo', + fstype: 'ext4', + instance: 'demo.do.prometheus.io:9100', + job: 'node', + mountpoint: '/', + }, + exemplars: [ + { + labels: { trace_id: 'trace-vda1' }, + value: 0.27700745551584494, + timestamp: 1666479357903, + }, + ], + }, + { + seriesLabels: { + device: '/dev/vda15', + env: 'demo', + fstype: 'vfat', + instance: 'demo.do.prometheus.io:9100', + job: 'node', + mountpoint: '/boot/efi', + }, + exemplars: [ + { + labels: { trace_id: 'trace-vda15' }, + value: 0.08486496097624485, + timestamp: 1666479382282, + }, + ], + }, +]; + export const MOCK_TIME_SERIES_DATA_SINGLEVALUE: TimeSeriesData = { timeRange: { start: new Date(1666625535000), diff --git a/timeserieschart/src/utils/data-transform.test.ts b/timeserieschart/src/utils/data-transform.test.ts index a0e0f79c4..ef147002e 100644 --- a/timeserieschart/src/utils/data-transform.test.ts +++ b/timeserieschart/src/utils/data-transform.test.ts @@ -15,7 +15,16 @@ import type { LegacyTimeSeries } from '@perses-dev/components'; import type { TimeScale } from '@perses-dev/spec'; import type { TimeSeriesChartVisualOptions, TimeSeriesChartYAxisOptions } from '../time-series-chart-model'; -import { convertPercentThreshold, convertPanelYAxis, getTimeSeries, roundDown } from './data-transform'; +import type { ExemplarChartData } from './data-transform'; +import { + EXEMPLAR_SERIES_ID_PREFIX, + EXEMPLAR_SYMBOL_SIZE, + convertPercentThreshold, + convertPanelYAxis, + getExemplarSeries, + getTimeSeries, + roundDown, +} from './data-transform'; const MAX_VALUE = 120; const MOCK_ECHART_TIME_SERIES_DATA: LegacyTimeSeries[] = [ @@ -27,6 +36,48 @@ const MOCK_ECHART_TIME_SERIES_DATA: LegacyTimeSeries[] = [ }, ]; +describe('getExemplarSeries', () => { + const exemplarData: ExemplarChartData = { + seriesId: 'chart1http_requests_total0', + seriesName: 'http_requests_total', + color: '#5f6caf', + seriesLabels: { __name__: 'http_requests_total', job: 'demo' }, + yAxisIndex: 0, + exemplars: [ + { labels: { trace_id: 'abc-123' }, value: 42, timestamp: 1700000000000 }, + { labels: { trace_id: 'def-456' }, value: 7, timestamp: 1700000150000 }, + ], + }; + + it('should render a diamond scatter series prefixed with the exemplar series id', () => { + const series = getExemplarSeries(exemplarData); + expect(series.type).toEqual('scatter'); + expect(series.id).toEqual(`${EXEMPLAR_SERIES_ID_PREFIX}${exemplarData.seriesId}`); + expect(series.symbol).toEqual('diamond'); + expect(series.symbolSize).toEqual(EXEMPLAR_SYMBOL_SIZE); + expect(series.color).toEqual('#5f6caf'); + }); + + it('should embed exemplar metadata in each data item so the dialog can be populated on click', () => { + const series = getExemplarSeries(exemplarData); + const data = series.data as Array<{ value: [number, number]; exemplar: unknown; seriesLabels: unknown }>; + expect(data).toHaveLength(2); + expect(data[0]?.value).toEqual([1700000000000, 42]); + expect(data[0]?.exemplar).toEqual(exemplarData.exemplars[0]); + expect(data[0]?.seriesLabels).toEqual(exemplarData.seriesLabels); + }); + + it('should negate plotted Y values while keeping original exemplar values when negativeY is enabled', () => { + const series = getExemplarSeries({ ...exemplarData, negativeY: true }); + const data = series.data as Array<{ value: [number, number]; exemplar: unknown }>; + expect(data[0]?.value).toEqual([1700000000000, -42]); + expect(data[1]?.value).toEqual([1700000150000, -7]); + // The embedded exemplar keeps the original (positive) value for the metadata tooltip. + expect(data[0]?.exemplar).toEqual(exemplarData.exemplars[0]); + expect(data[1]?.exemplar).toEqual(exemplarData.exemplars[1]); + }); +}); + describe('convertPercentThreshold', () => { it('should return 25 if percent threshold is 25 and max is 100', () => { const value = convertPercentThreshold(25, MOCK_ECHART_TIME_SERIES_DATA, 100); diff --git a/timeserieschart/src/utils/data-transform.ts b/timeserieschart/src/utils/data-transform.ts index a7244f29a..fece15276 100644 --- a/timeserieschart/src/utils/data-transform.ts +++ b/timeserieschart/src/utils/data-transform.ts @@ -20,9 +20,9 @@ import type { } from '@perses-dev/components'; import { OPTIMIZED_MODE_SERIES_LIMIT, getCommonTimeScale } from '@perses-dev/components'; import type { useTimeSeriesQueries, PanelData } from '@perses-dev/plugin-system'; -import type { TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; +import type { Exemplar, Labels, TimeScale, TimeSeries, TimeSeriesData, TimeSeriesValueTuple } from '@perses-dev/spec'; import type { YAXisComponentOption } from 'echarts'; -import type { LineSeriesOption, BarSeriesOption } from 'echarts/charts'; +import type { LineSeriesOption, BarSeriesOption, ScatterSeriesOption } from 'echarts/charts'; import type { TimeSeriesChartVisualOptions, @@ -51,6 +51,30 @@ export const HIDE_DATAPOINTS_LIMIT = 70; export const BLUR_FADEOUT_OPACITY = 0.5; +export const EXEMPLAR_SERIES_ID_PREFIX = 'exemplar-'; + +export const EXEMPLAR_SYMBOL_SIZE = 14; + +/** + * The exemplars of one series, converted to a chart-friendly shape with the + * rendering attributes (color, y axis) of the matching time series. + */ +export interface ExemplarChartData { + seriesId: string; + seriesName: string; + color: string; + seriesLabels?: Labels; + yAxisIndex?: number; + /** + * When the matching series is rendered with `querySettings.negativeY`, its values are + * visually negated so it renders below the X axis. The same transform is applied to + * the exemplar markers' plotted Y values so they stay next to their series. The + * original (positive) values remain on each `exemplar` for metadata display. + */ + negativeY?: boolean; + exemplars: Exemplar[]; +} + /** * Given a list of running queries, calculates a common time scale for use on * the x axis (i.e. start/end dates and a step that is divisible into all of @@ -144,6 +168,32 @@ export function getTimeSeries( return series; } +/** + * Gets an ECharts scatter series rendering exemplar markers for a single series. + * Each data item embeds its exemplar (and series labels) so the metadata dialog + * can be populated when a marker is clicked. + */ +export function getExemplarSeries(data: ExemplarChartData): ScatterSeriesOption { + return { + type: 'scatter', + id: `${EXEMPLAR_SERIES_ID_PREFIX}${data.seriesId}`, + name: data.seriesName, + color: data.color, + yAxisIndex: data.yAxisIndex, + symbol: 'diamond', + symbolSize: EXEMPLAR_SYMBOL_SIZE, + z: 10, + cursor: 'pointer', + data: data.exemplars.map((exemplar) => ({ + // The plotted Y value is negated when negativeY is enabled for the matching series, + // while `exemplar.value` keeps the original value for the metadata tooltip. + value: [exemplar.timestamp, data.negativeY ? -exemplar.value : exemplar.value], + exemplar, + seriesLabels: data.seriesLabels, + })), + }; +} + /** * Gets threshold-specific line series styles * markLine cannot be used since it does not update yAxis max / min