Compare commits

..

5 Commits

Author SHA1 Message Date
Abhi Kumar
dd5b152b34 feat(charts): draw axis lines in the grid's style by default
Padding the scatter range moved the plot edges off the outer grid lines, which
had stood in for the axes.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
e1f55cb8df fix(charts): keep scatter dots whole at the plot edges
uPlot clips series to the plot area, so a dot at an extreme value lost the half
past the edge. Each scatter scale now pads by the largest dot's radius in
pixels. Log axes are no longer snapped to powers of ten, since snapping after
the pad could add a whole empty decade; they place 1/2/5 ticks themselves.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
929f96d06d fix(charts): draw log axis grid lines at powers of ten only
uPlot splits a log axis at every 1–9 × 10ⁿ, and a line on each buried the
points under a mesh.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
91eb08a97f feat(charts): report the scatter point a click lands on
The shared click data finds its series by x position, which a faceted plot has
no axis for; the hit is read off the cursor instead.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
f4f1630595 fix(charts): forward plotRef and skip x lookups on faceted clicks
ChartWrapper dropped its plotRef prop, and a click on a faceted plot threw in
posToIdx, which reads the null data[0].

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
16 changed files with 719 additions and 8 deletions

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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. */

View File

@@ -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 : '';

View File

@@ -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,
});
});
});

View File

@@ -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,
};
}

View 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,
]);
});
});

View 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));
};

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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}
>

View File

@@ -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]);
});
});

View File

@@ -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' },

View 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)];
};
}

View File

@@ -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) => {

View File

@@ -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;
}
/**