mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-03 00:30:47 +01:00
Compare commits
5 Commits
feat/scatt
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd5b152b34 | ||
|
|
e1f55cb8df | ||
|
|
929f96d06d | ||
|
|
91eb08a97f | ||
|
|
f4f1630595 |
@@ -4,6 +4,7 @@ import uPlot, { Axis } from 'uplot';
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { keepDecadeSplits } from '../utils/logGridSplits';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
/**
|
||||
@@ -20,8 +21,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
const { grid, isDarkMode, isLogScale } = this.props;
|
||||
|
||||
const defaultStroke = getGridColor(isDarkMode ?? false);
|
||||
const defaultWidth = isLogScale ? 0.1 : 0.2;
|
||||
const defaultWidth = 0.2;
|
||||
const defaultShow = true;
|
||||
const filter = isLogScale ? { filter: keepDecadeSplits } : {};
|
||||
|
||||
// Merge partial or full grid config with defaults
|
||||
if (grid) {
|
||||
@@ -29,6 +31,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
stroke: grid.stroke ?? defaultStroke,
|
||||
width: grid.width ?? defaultWidth,
|
||||
show: grid.show ?? defaultShow,
|
||||
...filter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,9 +39,20 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
stroke: defaultStroke,
|
||||
width: defaultWidth,
|
||||
show: defaultShow,
|
||||
...filter,
|
||||
};
|
||||
}
|
||||
|
||||
/** The axis line reads as the outermost grid line, so it takes the grid's style. */
|
||||
private buildBorderConfig(
|
||||
grid: uPlot.Axis.Grid | undefined,
|
||||
): uPlot.Axis.Border | undefined {
|
||||
if (this.props.showBorder === false || !grid) {
|
||||
return undefined;
|
||||
}
|
||||
return { show: true, stroke: grid.stroke, width: grid.width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ticks configuration
|
||||
*/
|
||||
@@ -165,10 +179,13 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
filter,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
const grid = this.buildGridConfig();
|
||||
const border = this.buildBorderConfig(grid);
|
||||
const ticks = this.buildTicksConfig();
|
||||
const values = this.buildValuesFormatter();
|
||||
const size = this.buildSizeCalculator();
|
||||
@@ -193,6 +210,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (ticks) {
|
||||
axisConfig.ticks = ticks;
|
||||
}
|
||||
if (border) {
|
||||
axisConfig.border = border;
|
||||
}
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
@@ -202,6 +222,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (space !== undefined) {
|
||||
axisConfig.space = space;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (filter) {
|
||||
axisConfig.filter = filter;
|
||||
}
|
||||
if (size) {
|
||||
axisConfig.size = size;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { keepDecadeSplits } from '../../utils/logGridSplits';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
@@ -117,9 +118,47 @@ describe('UPlotAxisBuilder', () => {
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
filter: keepDecadeSplits,
|
||||
});
|
||||
});
|
||||
|
||||
it('draws the axis line by default, styled like the grid', () => {
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.border).toStrictEqual({
|
||||
show: true,
|
||||
stroke: config.grid?.stroke,
|
||||
width: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the axis line off when showBorder is false', () => {
|
||||
expect(
|
||||
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
|
||||
).not.toHaveProperty('border');
|
||||
});
|
||||
|
||||
it('passes explicit splits and a label filter through to uPlot', () => {
|
||||
const splits = jest.fn();
|
||||
const filter = jest.fn();
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ splits, filter }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.splits).toBe(splits);
|
||||
expect(config.filter).toBe(filter);
|
||||
});
|
||||
|
||||
it('draws log grid lines at powers of ten only, and leaves a linear grid unfiltered', () => {
|
||||
const log = new UPlotAxisBuilder(createAxisProps({ isLogScale: true }));
|
||||
const linear = new UPlotAxisBuilder(createAxisProps({ isLogScale: false }));
|
||||
|
||||
expect(log.getConfig().grid?.filter).toBe(keepDecadeSplits);
|
||||
expect(linear.getConfig().grid).not.toHaveProperty('filter');
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit tick positions, replacing uPlot's per-distribution default. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** Which splits get a label, replacing uPlot's per-distribution default. */
|
||||
filter?: uPlot.Axis.Filter;
|
||||
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
|
||||
showBorder?: boolean;
|
||||
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
|
||||
gap?: number;
|
||||
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */
|
||||
@@ -86,7 +92,7 @@ export interface AxisProps {
|
||||
space?: number;
|
||||
/** Picks the dark or light default for stroke and grid color. */
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
/** Axis is on a log scale — grid lines fall on powers of ten only. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
|
||||
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
|
||||
|
||||
@@ -42,7 +42,7 @@ function snapToPixel(start: number, length: number): [number, number] {
|
||||
return [snapped, length + (start - snapped) * 2];
|
||||
}
|
||||
|
||||
function getSeriesStroke(u: uPlot, seriesIdx: number): string {
|
||||
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
|
||||
const { stroke } = u.series[seriesIdx];
|
||||
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
|
||||
return typeof color === 'string' ? color : '';
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildClickData } from '../utils';
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
|
||||
}));
|
||||
|
||||
const event = {
|
||||
offsetX: 10,
|
||||
offsetY: 20,
|
||||
clientX: 110,
|
||||
clientY: 220,
|
||||
} as MouseEvent;
|
||||
|
||||
describe('buildClickData', () => {
|
||||
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
|
||||
const posToIdx = jest.fn(() => {
|
||||
throw new Error('faceted data[0] is null');
|
||||
});
|
||||
const plot = {
|
||||
data: [null],
|
||||
posToVal: (pos: number): number => pos * 2,
|
||||
posToIdx,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toStrictEqual({
|
||||
xValue: 20,
|
||||
yValue: 40,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: 20,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
});
|
||||
expect(posToIdx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
|
||||
const plot = {
|
||||
data: [[100, 200, 300]],
|
||||
posToVal: (pos: number): number => pos,
|
||||
posToIdx: (): number => 1,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toMatchObject({
|
||||
focusedSeries: { seriesIndex: 1 },
|
||||
clickedDataTimestamp: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,6 +174,25 @@ export function createLayoutObserver(
|
||||
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
const xValue = plot.posToVal(event.offsetX, 'x');
|
||||
const yValue = plot.posToVal(event.offsetY, 'y');
|
||||
const pointer = {
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
|
||||
// A faceted plot (scatter) has no shared x series, so `data[0]` is null and
|
||||
// `posToIdx` would throw; the hit is the plot's own to resolve.
|
||||
if (plot.data[0] == null) {
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: xValue,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
@@ -193,9 +212,6 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
101
frontend/src/lib/uPlotV2/utils/__tests__/logGridSplits.test.ts
Normal file
101
frontend/src/lib/uPlotV2/utils/__tests__/logGridSplits.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
keepDecadeSplits,
|
||||
logScaleSplits,
|
||||
spacedLogLabels,
|
||||
} from '../logGridSplits';
|
||||
|
||||
const filter = (splits: number[]): (number | null)[] =>
|
||||
keepDecadeSplits({} as uPlot, splits, 0, 0, 0) as (number | null)[];
|
||||
|
||||
describe('keepDecadeSplits', () => {
|
||||
it('keeps the powers of ten and drops the minor splits between them', () => {
|
||||
expect(filter([1, 2, 5, 9, 10, 20, 100, 300])).toStrictEqual([
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
10,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps sub-unit decades despite float noise', () => {
|
||||
expect(filter([0.001, 0.0010000000000000002, 0.002, 0.1])).toStrictEqual([
|
||||
0.001,
|
||||
0.0010000000000000002,
|
||||
null,
|
||||
0.1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps zero and negative decades on a symmetric log', () => {
|
||||
expect(filter([-100, -50, -1, 0, 1, 5])).toStrictEqual([
|
||||
-100,
|
||||
null,
|
||||
-1,
|
||||
0,
|
||||
1,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logScaleSplits', () => {
|
||||
const splitsFor = (min: number, max: number): number[] =>
|
||||
(
|
||||
logScaleSplits as (
|
||||
u: uPlot,
|
||||
axisIdx: number,
|
||||
min: number,
|
||||
max: number,
|
||||
) => number[]
|
||||
)({} as uPlot, 0, min, max);
|
||||
|
||||
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
|
||||
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
|
||||
});
|
||||
|
||||
it('includes the ends when they fall on a split', () => {
|
||||
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
|
||||
});
|
||||
|
||||
it('is empty for a range a log scale cannot hold', () => {
|
||||
expect(splitsFor(0, 10)).toStrictEqual([]);
|
||||
expect(splitsFor(5, 5)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spacedLogLabels', () => {
|
||||
// 100px per decade.
|
||||
const plot = {
|
||||
axes: [{ scale: 'x' }],
|
||||
valToPos: (value: number): number => Math.log10(value) * 100,
|
||||
} as unknown as uPlot;
|
||||
const label = (splits: number[], space: number): (number | null)[] =>
|
||||
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
|
||||
|
||||
it('always labels the powers of ten', () => {
|
||||
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('adds a 2 or 5 that clears the labels already kept', () => {
|
||||
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
|
||||
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
|
||||
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
|
||||
});
|
||||
|
||||
it('labels both ends of a range spanning under a decade', () => {
|
||||
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
|
||||
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
|
||||
20,
|
||||
null,
|
||||
100,
|
||||
null,
|
||||
500,
|
||||
]);
|
||||
});
|
||||
});
|
||||
76
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
76
frontend/src/lib/uPlotV2/utils/logGridSplits.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
const DECADE_TOLERANCE = 1e-9;
|
||||
|
||||
function isDecade(value: number): boolean {
|
||||
if (value === 0) {
|
||||
return true;
|
||||
}
|
||||
const exponent = Math.log10(Math.abs(value));
|
||||
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* A log axis splits at every 1–9 × 10ⁿ; a line on each buries the data under
|
||||
* a mesh. Keeps the powers of ten (and 0, which a symmetric log can include).
|
||||
*/
|
||||
export const keepDecadeSplits: uPlot.Axis.Filter = (_u, splits) =>
|
||||
splits.map((split) => (split != null && isDecade(split) ? split : null));
|
||||
|
||||
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
|
||||
|
||||
/**
|
||||
* Splits at 1, 2 and 5 × 10ⁿ inside the scale. uPlot's own log splits step
|
||||
* from the scale's minimum, so a range not snapped to a power of ten would
|
||||
* label 10.79, 21.58, … instead.
|
||||
*/
|
||||
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
|
||||
if (!(min > 0) || !(max > min)) {
|
||||
return [];
|
||||
}
|
||||
const splits: number[] = [];
|
||||
for (
|
||||
let exponent = Math.floor(Math.log10(min));
|
||||
exponent <= Math.ceil(Math.log10(max));
|
||||
exponent++
|
||||
) {
|
||||
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
|
||||
// Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max].
|
||||
const split = Number((multiple * 10 ** exponent).toPrecision(12));
|
||||
if (split >= min && split <= max) {
|
||||
splits.push(split);
|
||||
}
|
||||
});
|
||||
}
|
||||
return splits;
|
||||
};
|
||||
|
||||
/**
|
||||
* Labels for `logScaleSplits`: every power of ten, then any 2 or 5 that sits
|
||||
* at least `space` px from the labels already kept, so a range spanning under
|
||||
* one decade still reads at both ends.
|
||||
*/
|
||||
export const spacedLogLabels: uPlot.Axis.Filter = (
|
||||
u,
|
||||
splits,
|
||||
axisIdx,
|
||||
space,
|
||||
) => {
|
||||
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
|
||||
const positions = splits.map((split) =>
|
||||
split == null ? null : u.valToPos(split, scaleKey),
|
||||
);
|
||||
const kept = splits.map((split) => split != null && isDecade(split));
|
||||
const isClear = (index: number): boolean =>
|
||||
kept.every(
|
||||
(isKept, other) =>
|
||||
!isKept ||
|
||||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
|
||||
);
|
||||
splits.forEach((split, index) => {
|
||||
if (split != null && !kept[index] && isClear(index)) {
|
||||
kept[index] = true;
|
||||
}
|
||||
});
|
||||
return splits.map((split, index) => (kept[index] ? split : null));
|
||||
};
|
||||
@@ -37,6 +37,7 @@ export default function ChartWrapper({
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
plotRef,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
@@ -114,6 +115,7 @@ export default function ChartWrapper({
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import ChartWrapper from '../ChartWrapper';
|
||||
|
||||
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
|
||||
|
||||
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
|
||||
__esModule: true,
|
||||
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
|
||||
plotRef?.(FAKE_PLOT);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ChartWrapper', () => {
|
||||
it('hands the plot instance to the caller', () => {
|
||||
const plotRef = jest.fn();
|
||||
render(
|
||||
<ChartWrapper
|
||||
config={new UPlotConfigBuilder({ id: 'chart' })}
|
||||
data={[[1], [2]]}
|
||||
width={400}
|
||||
height={300}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
showLegend={false}
|
||||
showTooltip={false}
|
||||
plotRef={plotRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
|
||||
import {
|
||||
ScatterTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
|
||||
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
|
||||
import { getCursorHit } from './utils';
|
||||
|
||||
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
|
||||
// mount; empty aligned data makes the shell show its no-data state instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
@@ -20,9 +25,39 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
pinnedTooltipElement,
|
||||
onPointClick,
|
||||
plotRef,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const handlePlotRef = useCallback(
|
||||
(plot: uPlot | null): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
},
|
||||
[plotRef],
|
||||
);
|
||||
|
||||
// The shared click data finds its series by x position, which a faceted plot
|
||||
// has no single axis for; the hit comes from the cursor instead.
|
||||
const handleClick = useCallback(
|
||||
(click: ChartClickData): void => {
|
||||
const plot = plotInstanceRef.current;
|
||||
const hit = plot ? getCursorHit(plot) : null;
|
||||
if (!plot || !hit || !onPointClick) {
|
||||
return;
|
||||
}
|
||||
onPointClick({
|
||||
...hit,
|
||||
color: getSeriesStroke(plot, hit.seriesIndex),
|
||||
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
|
||||
});
|
||||
},
|
||||
[onPointClick],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
@@ -56,6 +91,8 @@ export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
|
||||
plotRef={handlePlotRef}
|
||||
onClick={onPointClick ? handleClick : rest.onClick}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange, type ScatterRangeOptions } from '../scatterRange';
|
||||
|
||||
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
|
||||
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
|
||||
|
||||
function rangeOf(
|
||||
options: Partial<ScatterRangeOptions>,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
): [number, number] {
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
...options,
|
||||
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
|
||||
return range(plot, dataMin, dataMax);
|
||||
}
|
||||
|
||||
describe('createScatterRange', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
|
||||
});
|
||||
|
||||
it('widens a linear range so the data sits marginPx inside each edge', () => {
|
||||
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
|
||||
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
|
||||
});
|
||||
|
||||
it('measures the margin along its own axis', () => {
|
||||
// 400px of data inside 420px.
|
||||
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
|
||||
});
|
||||
|
||||
it('pads a log range in decades, without snapping to the next power of ten', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic },
|
||||
1,
|
||||
10,
|
||||
);
|
||||
expect(Math.log10(min)).toBeCloseTo(-0.05);
|
||||
expect(Math.log10(max)).toBeCloseTo(1.05);
|
||||
});
|
||||
|
||||
it('pads a symmetric log range in its asinh space', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
|
||||
0,
|
||||
20,
|
||||
);
|
||||
const span = Math.asinh(10);
|
||||
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
|
||||
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
|
||||
});
|
||||
|
||||
it('stretches to the soft limits before padding', () => {
|
||||
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
|
||||
-10, 210,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a soft min at or below zero on a log scale', () => {
|
||||
const [min] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic, softMin: 0 },
|
||||
10,
|
||||
100,
|
||||
);
|
||||
expect(min).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('spreads a single value around itself', () => {
|
||||
const [min, max] = rangeOf({}, 5, 5);
|
||||
expect(min).toBeLessThan(5);
|
||||
expect(max).toBeGreaterThan(5);
|
||||
expect(5 - min).toBeCloseTo(max - 5);
|
||||
});
|
||||
|
||||
it('falls back to a unit range with no data', () => {
|
||||
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
|
||||
expect(
|
||||
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
|
||||
).toStrictEqual([1, 10]);
|
||||
});
|
||||
|
||||
it('skips the margin on a plot too small to hold it', () => {
|
||||
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
}) as (u: uPlot, min: number, max: number) => [number, number];
|
||||
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildScatterConfig,
|
||||
getCursorHit,
|
||||
prepareScatterChartData,
|
||||
resolveAxisDistribution,
|
||||
ScatterAxisScale,
|
||||
@@ -31,6 +33,30 @@ describe('prepareScatterChartData', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCursorHit', () => {
|
||||
const plotWith = (idxs: Array<number | null>): uPlot =>
|
||||
({ cursor: { idxs } }) as unknown as uPlot;
|
||||
|
||||
it('reads the series the scatter plugin answered for', () => {
|
||||
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
|
||||
seriesIndex: 2,
|
||||
dataIndex: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes a data index of 0 as a hit', () => {
|
||||
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null off every dot', () => {
|
||||
expect(getCursorHit(plotWith([null, null]))).toBeNull();
|
||||
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAxisDistribution', () => {
|
||||
it('is linear when asked, whatever the values', () => {
|
||||
expect(
|
||||
@@ -108,6 +134,13 @@ describe('buildScatterConfig', () => {
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('draws a line along both plot edges', () => {
|
||||
const [xAxis, yAxis] = build().getConfig().axes ?? [];
|
||||
|
||||
expect(xAxis?.border?.show).toBe(true);
|
||||
expect(yAxis?.border?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
|
||||
const config = build({
|
||||
x: { unit: 'reqps', label: 'Throughput' },
|
||||
|
||||
108
frontend/src/lib/visualization/charts/Scatter/scatterRange.ts
Normal file
108
frontend/src/lib/visualization/charts/Scatter/scatterRange.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { AxisDistribution } from './utils';
|
||||
|
||||
interface ScaleTransform {
|
||||
forward: (value: number) => number;
|
||||
inverse: (position: number) => number;
|
||||
}
|
||||
|
||||
/** The space uPlot lays the scale out in, so a pixel margin is the same size along it. */
|
||||
function getScaleTransform({
|
||||
distribution,
|
||||
asinhThreshold = 1,
|
||||
}: AxisDistribution): ScaleTransform {
|
||||
switch (distribution) {
|
||||
case DistributionType.Logarithmic:
|
||||
return {
|
||||
forward: (value): number => Math.log10(value),
|
||||
inverse: (position): number => 10 ** position,
|
||||
};
|
||||
case DistributionType.SymmetricLog:
|
||||
return {
|
||||
forward: (value): number => Math.asinh(value / asinhThreshold),
|
||||
inverse: (position): number => Math.sinh(position) * asinhThreshold,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
forward: (value): number => value,
|
||||
inverse: (position): number => position,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Half the span a single value spreads to, in transformed units. */
|
||||
const SINGLE_VALUE_HALF_SPAN = 0.5;
|
||||
|
||||
/** The data's extent stretched to the soft limits; null with neither. */
|
||||
function resolveExtent(
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
softMin: number | null | undefined,
|
||||
softMax: number | null | undefined,
|
||||
): [number, number] | null {
|
||||
const lows = [dataMin, softMin].filter(
|
||||
(value): value is number => value != null && Number.isFinite(value),
|
||||
);
|
||||
const highs = [dataMax, softMax].filter(
|
||||
(value): value is number => value != null && Number.isFinite(value),
|
||||
);
|
||||
return lows.length > 0 && highs.length > 0
|
||||
? [Math.min(...lows), Math.max(...highs)]
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface ScatterRangeOptions extends AxisDistribution {
|
||||
/** The axis this scale is laid out along. */
|
||||
dimension: 'x' | 'y';
|
||||
/** CSS pixels kept clear at each end so the largest dot draws whole. */
|
||||
marginPx: number;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits the scale to the data and then widens it by `marginPx` at each end.
|
||||
* uPlot clips series to the plot area, so a dot at the extreme value would
|
||||
* otherwise lose the half that hangs past the edge. Log scales are left
|
||||
* unsnapped: rounding out to the next power of ten after the margin can add a
|
||||
* whole empty decade.
|
||||
*/
|
||||
export function createScatterRange({
|
||||
dimension,
|
||||
marginPx,
|
||||
softMin,
|
||||
softMax,
|
||||
...distribution
|
||||
}: ScatterRangeOptions): uPlot.Scale.Range {
|
||||
const transform = getScaleTransform(distribution);
|
||||
const isLog = distribution.distribution === DistributionType.Logarithmic;
|
||||
|
||||
// A log scale cannot place a soft limit at or below zero.
|
||||
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
|
||||
|
||||
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
|
||||
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
|
||||
if (!extent) {
|
||||
return isLog ? [1, 10] : [0, 1];
|
||||
}
|
||||
|
||||
let start = transform.forward(extent[0]);
|
||||
let end = transform.forward(extent[1]);
|
||||
if (start === end) {
|
||||
start -= SINGLE_VALUE_HALF_SPAN;
|
||||
end += SINGLE_VALUE_HALF_SPAN;
|
||||
}
|
||||
|
||||
const plotPx =
|
||||
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
|
||||
if (plotPx > 2 * marginPx) {
|
||||
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
|
||||
start -= pad;
|
||||
end += pad;
|
||||
}
|
||||
|
||||
return [transform.inverse(start), transform.inverse(end)];
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
type AxisProps,
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
@@ -17,8 +18,15 @@ import {
|
||||
ScatterPointSize,
|
||||
ScatterSeriesData,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
logScaleSplits,
|
||||
spacedLogLabels,
|
||||
} from 'lib/uPlotV2/utils/logGridSplits';
|
||||
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange } from './scatterRange';
|
||||
|
||||
/** Circle outline; the fill carries the colour. */
|
||||
const POINT_STROKE_WIDTH = 1;
|
||||
|
||||
@@ -73,6 +81,23 @@ export interface BuildScatterConfigArgs {
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dot under the cursor, read off uPlot's cursor: the scatter plugin's
|
||||
* `dataIdx` answers only for the hit series, so its index is the one set.
|
||||
*/
|
||||
export function getCursorHit(
|
||||
plot: uPlot,
|
||||
): { seriesIndex: number; dataIndex: number } | null {
|
||||
const idxs = plot.cursor.idxs ?? [];
|
||||
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
|
||||
const dataIndex = idxs[seriesIndex];
|
||||
if (dataIndex != null) {
|
||||
return { seriesIndex, dataIndex };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
|
||||
export function prepareScatterChartData(
|
||||
series: ScatterSeries[],
|
||||
@@ -156,6 +181,15 @@ export function resolveAxisDistribution(
|
||||
}
|
||||
}
|
||||
|
||||
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
|
||||
function getLogAxisTicks({
|
||||
distribution,
|
||||
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
|
||||
return distribution === DistributionType.Logarithmic
|
||||
? { splits: logScaleSplits, filter: spacedLogLabels }
|
||||
: {};
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
@@ -195,11 +229,30 @@ export function buildScatterConfig({
|
||||
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
|
||||
: undefined;
|
||||
|
||||
// The largest disc drawn, plus its outline, kept clear of each plot edge.
|
||||
const largestDiameter = series.some((entry) => entry.sizes)
|
||||
? pointSize.max
|
||||
: pointSize.fixed;
|
||||
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
|
||||
const ySoftLimits = adjustSoftLimitsWithThresholds(
|
||||
y.softMin ?? null,
|
||||
y.softMax ?? null,
|
||||
thresholds,
|
||||
y.unit,
|
||||
);
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
softMin: x.softMin ?? undefined,
|
||||
softMax: x.softMax ?? undefined,
|
||||
range: createScatterRange({
|
||||
dimension: 'x',
|
||||
marginPx,
|
||||
softMin: x.softMin,
|
||||
softMax: x.softMax,
|
||||
...xDistribution,
|
||||
}),
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
@@ -208,6 +261,12 @@ export function buildScatterConfig({
|
||||
softMin: y.softMin ?? undefined,
|
||||
softMax: y.softMax ?? undefined,
|
||||
thresholds: yThresholds,
|
||||
range: createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx,
|
||||
...ySoftLimits,
|
||||
...yDistribution,
|
||||
}),
|
||||
...yDistribution,
|
||||
});
|
||||
|
||||
@@ -220,6 +279,7 @@ export function buildScatterConfig({
|
||||
yAxisUnit: x.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: xDistribution.distribution !== DistributionType.Linear,
|
||||
...getLogAxisTicks(xDistribution),
|
||||
space: X_AXIS_TICK_SPACE_PX,
|
||||
});
|
||||
builder.addAxis({
|
||||
@@ -230,6 +290,7 @@ export function buildScatterConfig({
|
||||
yAxisUnit: y.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: yDistribution.distribution !== DistributionType.Linear,
|
||||
...getLogAxisTicks(yDistribution),
|
||||
});
|
||||
|
||||
series.forEach((entry) => {
|
||||
|
||||
@@ -78,6 +78,16 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
|
||||
export interface ScatterPointClick {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
/** The series' drawn colour. */
|
||||
color: string;
|
||||
/** Viewport coordinates of the click. */
|
||||
coordinates: { x: number; y: number };
|
||||
}
|
||||
|
||||
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
|
||||
export interface ScatterChartProps extends ChartWrapperProps {
|
||||
channels: ScatterChannels;
|
||||
@@ -85,6 +95,8 @@ export interface ScatterChartProps extends ChartWrapperProps {
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
|
||||
onPointClick?: (click: ScatterPointClick) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user