Compare commits

...

22 Commits

Author SHA1 Message Date
Abhi Kumar
f4b9c1bb2b feat(dashboard): hide legend format for scatter plot
It only renames a value column here; axis labels name what each axis measures.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
c50a90ac76 fix(dashboard): send reduceTo with scatter plot metric queries
The V5 conversion kept reduceTo only for table, pie and value panels, so the
API rejected a scatter plot's scalar metric query.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
db03efb495 feat(query-builder): treat scatter plot like table in the builder
Reduce to for metrics, and no step interval for traces or logs, since both
send the same scalar request.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
4c36a55188 feat(dashboard): drill down and download CSV from a scatter plot
A dot drills into its group through Y's query, or X's when Y has none. A
breakout opens as a table, since regrouping one query would break the join.
CSV keeps both of two columns that share a name.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
3bad49e8ed fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
ab4cae6fa1 fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
8b87bd7c3a feat(dashboard): edit scatter plot point size and opacity
A single size slider until a size column is bound, then a min/max range.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
Abhi Kumar
a0d9e9c8fd feat(dashboard): add scatter plot panel
One dot per group from the joined scalar table. Dimensions bind to Table's
column keys so column units carry across a Table/Scatter switch.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:12 +05:30
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
Abhi Kumar
cefbece944 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
b1b4453fbd feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
1c29e402c7 fix(charts): outline the hovered scatter point
Snap the ring to whole pixels so uPlot's ceil on left/top doesn't shift it
off the disc.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
a5d41cd114 feat(charts): scatter axis scales and titles
auto picks log only for positive values spanning three decades; log falls
back to symlog when given values at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
6569d7f74c chore: scatter plot base work 2026-10-02 01:14:11 +05:30
Abhi Kumar
31ed7dd84d chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
1cc63d1e04 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
d93c4cf193 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
Abhi Kumar
1df9350b90 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-02 01:14:11 +05:30
126 changed files with 7189 additions and 102 deletions

View File

@@ -3379,6 +3379,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4042,6 +4049,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4056,6 +4064,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4068,6 +4077,7 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4141,6 +4151,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4220,6 +4242,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4393,6 +4425,87 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
color:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
size:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user

View File

@@ -48,6 +48,7 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -61,6 +62,9 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in path builders is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -4305,6 +4305,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5224,6 +5230,101 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
color?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
size?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5233,7 +5334,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6159,6 +6261,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,6 +21,7 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -904,6 +905,12 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,6 +51,7 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -277,6 +278,7 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -43,7 +43,9 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,6 +95,7 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],

View File

@@ -0,0 +1,45 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -34,6 +34,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,6 +337,7 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',

View File

@@ -28,6 +28,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
TEXT: false,

View File

@@ -16,6 +16,7 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -14,6 +14,7 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
@@ -135,6 +136,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -0,0 +1,100 @@
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
// Padding lives on the sections so a footer can reach the container edges.
.container {
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 200px;
&.pinned {
border-color: var(--ring);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
// Matches the legend row's marker.
.marker {
width: 10px;
height: 10px;
border-radius: var(--radius);
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-vanilla-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 11px;
color: var(--text-vanilla-400);
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-4);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
color: var(--text-vanilla-100);
}
// The group values name the point; the channels are what it says.
.rowMuted {
color: var(--text-vanilla-400);
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,111 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/**
* One point, its channels, then the group values that name it. Purpose-built
* rather than composed from the shared `Tooltip`, whose list is one row per
* series at a shared x; a scatter point has no such neighbours.
*/
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointLabels,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const point = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
[point, channels, decimalPrecision],
);
const labels = useMemo(
() =>
point
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
: [],
[point, resolvePointLabels],
);
if (!point) {
return null;
}
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="scatter-tooltip"
>
<div className={Styles.header}>
<span className={Styles.marker} style={{ backgroundColor: point.color }} />
<span
className={Styles.title}
title={point.label}
data-testid="scatter-tooltip-title"
>
{point.label}
</span>
{isPinned && (
<span className={Styles.status} data-testid="scatter-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</span>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{row.value}</span>
</div>
))}
</div>
{labels.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{labels.map((label) => (
<div
key={label.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid="scatter-tooltip-label"
>
<span className={Styles.rowLabel} title={label.key}>
{label.key}
</span>
<span className={Styles.rowValue} title={label.value}>
{label.value}
</span>
</div>
))}
</div>
</>
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import type uPlot from 'uplot';
import {
buildChannelRows,
resolveHoveredPoint,
ScatterHoveredPoint,
} from '../scatterTooltipContent';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
`${value} ${unit ?? ''}`.trim(),
),
}));
const plot = {
data: [
null,
[
[10, 20],
[100, 200],
[5, null],
],
[[30], [300]],
],
series: [
{},
{ label: 'cart', stroke: '#ff0000' },
{ label: 'checkout', stroke: (): string => '#00ff00' },
],
} as unknown as uPlot;
describe('resolveHoveredPoint', () => {
it('reads the focused series at its own data index', () => {
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
seriesIndex: 1,
dataIndex: 1,
label: 'cart',
color: '#ff0000',
x: 20,
y: 200,
size: null,
});
});
it('carries the size column when present and resolves function strokes', () => {
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
label: 'checkout',
color: '#00ff00',
size: null,
});
});
it('is null without a focused series or an index for it', () => {
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
});
});
describe('buildChannelRows', () => {
const point: ScatterHoveredPoint = {
seriesIndex: 1,
dataIndex: 0,
label: 'cart',
color: '#f00',
x: 12,
y: 340,
size: 7,
};
it('formats x and y with their own units', () => {
const rows = buildChannelRows(point, {
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99', unit: 'ms' },
});
expect(rows).toStrictEqual([
{ label: 'Throughput', value: '12 reqps' },
{ label: 'p99', value: '340 ms' },
]);
});
it('adds the size row only when the channel is mapped and the point has one', () => {
const channels = {
x: { label: 'x' },
y: { label: 'y' },
size: { label: 'Errors' },
};
expect(buildChannelRows(point, channels)).toHaveLength(3);
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,90 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import type uPlot from 'uplot';
import type {
ScatterChannel,
ScatterChannels,
ScatterSeriesData,
} from '../../plugins/ScatterPlugin/types';
import { resolveSeriesColor } from './utils';
export interface ScatterHoveredPoint {
seriesIndex: number;
dataIndex: number;
label: string;
color: string;
x: number;
y: number;
size: number | null;
}
export interface ScatterTooltipRow {
label: string;
value: string;
}
/** The point the cursor resolved to: the focused series' own index into its columns. */
export function resolveHoveredPoint(
u: uPlot,
seriesIndex: number | null,
dataIndexes: Array<number | null>,
): ScatterHoveredPoint | null {
if (seriesIndex == null || seriesIndex < 1) {
return null;
}
const dataIndex = dataIndexes[seriesIndex];
const series = u.series[seriesIndex];
const columns = u.data[seriesIndex] as unknown as
| ScatterSeriesData
| undefined;
if (dataIndex == null || !series || !columns) {
return null;
}
const x = columns[0][dataIndex];
const y = columns[1][dataIndex];
if (x == null || y == null) {
return null;
}
return {
seriesIndex,
dataIndex,
label: String(series.label ?? ''),
color: resolveSeriesColor(series.stroke, u, seriesIndex),
x,
y,
size: columns[2]?.[dataIndex] ?? null,
};
}
function formatChannel(
value: number,
channel: ScatterChannel,
decimalPrecision?: PrecisionOption,
): string {
return getToolTipValue(value, channel.unit, decimalPrecision);
}
export function buildChannelRows(
point: ScatterHoveredPoint,
channels: ScatterChannels,
decimalPrecision?: PrecisionOption,
): ScatterTooltipRow[] {
const rows: ScatterTooltipRow[] = [
{
label: channels.x.label,
value: formatChannel(point.x, channels.x, decimalPrecision),
},
{
label: channels.y.label,
value: formatChannel(point.y, channels.y, decimalPrecision),
},
];
if (channels.size && point.size != null) {
rows.push({
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
});
}
return rows;
}

View File

@@ -5,6 +5,10 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -103,6 +107,17 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
export interface HistogramTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {}
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointLabels?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

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
*/
@@ -58,32 +72,49 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
}
/**
* Build values formatter for X-axis (time)
* Build values formatter for X-axis: time, or a value axis when a unit or
* precision is given (scatter). Neither leaves uPlot's numeric default.
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis } = this.props;
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
return this.buildValueAxisFormatter();
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
* Build values formatter for a value axis (values with units). A split outside
* the scale's range gets no label: uPlot's arcsinh splits always include
* ±threshold, and it would draw that label past the plot's edge.
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
private buildValueAxisFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return (u, t): string[] => {
const scale = u?.scales?.[scaleKey];
const min = scale?.min ?? -Infinity;
const max = scale?.max ?? Infinity;
return t.map((v) => {
if (
v === null ||
v === undefined ||
Number.isNaN(v) ||
v < min ||
v > max
) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
};
}
/**
@@ -101,7 +132,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -148,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();
@@ -176,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;
}
@@ -185,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

@@ -19,6 +19,7 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -65,6 +66,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -160,6 +163,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
setMode(mode: PlotMode): void {
this.mode = mode;
}
getMode(): PlotMode {
return this.mode;
}
/**
* Add or merge a scale configuration
*/
@@ -512,6 +524,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
{} as Record<string, uPlot.Scale>,
);
if (this.mode === PlotMode.Faceted) {
config.mode = this.mode as number as uPlot.Mode;
}
config.hooks = this.hooks;
config.select = this.select;

View File

@@ -93,6 +93,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold: this.props.asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -87,6 +87,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (this.props.drawStyle === DrawStyle.Scatter) {
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
@@ -122,7 +124,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
// Scatter without a `pathBuilder` has nothing to draw its discs with.
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
return { paths: (): null => null };
}
@@ -194,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
if (drawStyle === DrawStyle.Points) {
return true;
}
// The discs are the series path; uPlot's own points would double-draw them.
if (drawStyle === DrawStyle.Scatter) {
return false;
}
return !!showPoints;
}
@@ -218,7 +225,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
const resolvedLineColor = this.getLineColor();
@@ -246,6 +253,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(facets && { facets }),
};
}
}

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(
@@ -376,3 +415,51 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBeDefined();
});
});
describe('UPlotAxisBuilder value x axis', () => {
it('formats a non-time x axis with its unit', () => {
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
).getConfig();
const values = (config.values as uPlot.Axis.DynamicValues)(
{} as uPlot,
[1200],
0,
0,
0,
);
expect(values).toStrictEqual(['1.2K req/s']);
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
});
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
).getConfig();
expect(config.values).toBeUndefined();
});
});
describe('UPlotAxisBuilder out-of-range splits', () => {
it('leaves a split the scale cannot place unlabelled', () => {
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
).getConfig();
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
const values = (config.values as uPlot.Axis.DynamicValues)(
u,
[-10, 0, 500, 5000],
0,
0,
0,
);
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
});
});

View File

@@ -5,7 +5,12 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import {
DrawStyle,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -651,3 +656,15 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('UPlotConfigBuilder plot mode', () => {
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
expect(aligned.getConfig().mode).toBeUndefined();
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getMode()).toBe(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

View File

@@ -399,3 +399,39 @@ describe('UPlotSeriesBuilder', () => {
expect(builder.getConfig().fill).toBeUndefined();
});
});
describe('UPlotSeriesBuilder scatter', () => {
it('draws through the given path builder and hides uPlot points', () => {
const pathBuilder = jest.fn();
const config = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Scatter,
pathBuilder,
facets: [{ scale: 'x' }, { scale: 'y' }],
lineColor: '#ff0000',
fillOpacity: 0.5,
lineWidth: 1,
pointSize: 8,
}),
).getConfig();
expect(config.paths).toBe(pathBuilder);
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
expect(config.points?.show).toBe(false);
expect(config.points?.size).toBe(8);
expect(config.stroke).toBe('#ff0000');
expect(config.width).toBe(1);
expect(config.fill).toBe('#ff000080');
});
it('draws nothing without a path builder', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Scatter }),
).getConfig();
expect(
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
).toBeNull();
expect(config.facets).toBeUndefined();
});
});

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,9 +92,10 @@ 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 y axis ticks are formatted in (`spec.formatting.unit`). */
/** 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. */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
@@ -107,6 +114,15 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
* negatives, which a plain log cannot place. */
SymmetricLog = 'symlog',
}
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -123,6 +139,8 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
asinhThreshold?: number;
}
export enum DisconnectedValuesMode {
@@ -144,6 +162,8 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -227,6 +247,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -0,0 +1,114 @@
import type uPlot from 'uplot';
import { Quadtree } from '../../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
} from '../geometry';
import { ScatterHit, ScatterPointSize } from '../types';
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
const asData = (columns: unknown[]): uPlot.AlignedData =>
columns as unknown as uPlot.AlignedData;
describe('resolveSizeDomain', () => {
it('spans the size columns of every series, skipping nulls', () => {
const data = asData([
null,
[
[1, 2],
[1, 2],
[10, null],
],
[[3], [3], [40]],
]);
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
});
it('is null when no series carries sizes', () => {
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
});
});
describe('resolvePointDiameter', () => {
it('uses the fixed diameter without a size or a domain', () => {
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
});
it('maps the domain ends to min and max', () => {
const domain = { min: 0, max: 100 };
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
});
it('scales by area, not diameter', () => {
const midArea = (4 ** 2 + 20 ** 2) / 2;
expect(
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
).toBeCloseTo(Math.sqrt(midArea));
});
it('clamps values outside the domain', () => {
const domain = { min: 10, max: 20 };
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
});
it('uses the midpoint when every size is the same', () => {
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
});
});
describe('resolveHit', () => {
const hit = (
seriesIndex: number,
dataIndex: number,
x: number,
y: number,
d: number,
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
it('returns the disc under the cursor', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
tree.add(hit(2, 3, 50, 50, 6));
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
seriesIndex: 1,
dataIndex: 0,
});
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
seriesIndex: 2,
dataIndex: 3,
});
});
it('is null when the cursor is off every disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
});
it('tolerance widens each disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
});
it('prefers the disc whose centre is nearest when they overlap', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 10));
tree.add(hit(1, 1, 14, 10, 10));
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
});
});

View File

@@ -0,0 +1,288 @@
import uPlot from 'uplot';
import { PlotMode } from '../../../config/types';
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from '../scatterPlugin';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
class FakePath2D {
moveTo = jest.fn();
arc = jest.fn();
}
type OrientCallback = Parameters<typeof uPlot.orient>[2];
interface FakePlotArgs {
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
cursor?: { left: number; top: number };
scaleX?: { min: number; max: number };
scaleY?: { min: number; max: number };
}
/**
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
*/
function createFakePlot({
series,
cursor = { left: -1, top: -1 },
scaleX = { min: 0, max: 100 },
scaleY = { min: 0, max: 100 },
}: FakePlotArgs): uPlot {
const data = [
null,
...series.map((entry) =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return {
data,
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
bbox: { left: 0, top: 0, width: 100, height: 100 },
cursor,
scales: { x: scaleX, y: scaleY },
} as unknown as uPlot;
}
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
function orientWithIdentityScales(
u: uPlot,
seriesIdx: number,
cb: OrientCallback,
): void {
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
if (!columns) {
return;
}
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
const valToPosX = (value: number): number => value;
const valToPosY = (value: number): number => 100 - value;
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
const arc = (path: FakePath2D, ...args: number[]): void => {
path.arc(...args);
};
cb(
u.series[seriesIdx],
columns[0],
columns[1],
scaleX,
scaleY,
valToPosX as unknown as uPlot.ValToPos,
valToPosY as unknown as uPlot.ValToPos,
0,
0,
100,
100,
jest.fn() as never,
jest.fn() as never,
jest.fn() as never,
arc as never,
jest.fn() as never,
);
}
describe('createScatterPlugin', () => {
beforeAll(() => {
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
});
beforeEach(() => {
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
(uPlot.orient as jest.Mock).mockReset();
});
function drawAll(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): void {
plugin.hooks.drawClear(u);
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
const columns = (u.data as unknown as number[][][])[seriesIdx];
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
}
}
/** Runs the cursor scan the way uPlot does: every data series, in order. */
function scan(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): Array<number | null> {
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
const indexes: Array<number | null> = [null];
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
indexes.push(dataIdx(u, seriesIdx, 0, 0));
}
return indexes;
}
it('returns one path that strokes and fills the same discs', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
plugin.hooks.drawClear(u);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect(paths.stroke).toBeInstanceOf(FakePath2D);
expect(paths.fill).toBe(paths.stroke);
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
});
it('resolves the hovered point to its own series and index', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 6, min: 4, max: 20 },
});
const u = createFakePlot({
series: [
{ xs: [10, 50], ys: [10, 50] },
{ xs: [80], ys: [80] },
],
// Over the second series' only point: x 80, y drawn at 100 − 80.
cursor: { left: 80, top: 20 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
});
it('returns null for every series when the cursor is off the plot or off any disc', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: -1, top: -1 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null]);
(u.cursor as { left: number; top: number }).left = 50;
(u.cursor as { left: number; top: number }).top = 50;
expect(scan(u, plugin)).toStrictEqual([null, null]);
});
it('skips points outside the visible scale range', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10, 500], ys: [10, 10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
expect(scan(u, plugin)).toStrictEqual([null, 0]);
});
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 8, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
const bbox = plugin.cursor.points?.bbox;
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
expect(bbox?.(u, 1)).toStrictEqual({
left: 1,
top: 81,
width: 18,
height: 18,
});
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
});
it('snaps the ring to whole pixels without moving its centre', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 7, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
const ring = plugin.cursor.points?.bbox?.(u, 1);
expect(ring).toMatchObject({ left: 1, width: 18 });
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
});
it('draws the hover marker as an outline in the series colour', () => {
const plugin = createScatterPlugin();
const u = {
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
} as unknown as uPlot;
const points = plugin.cursor.points as {
fill: (u: uPlot, seriesIdx: number) => string;
stroke: (u: uPlot, seriesIdx: number) => string;
width: (u: uPlot, seriesIdx: number, size: number) => number;
};
expect(points.fill(u, 1)).toBe('transparent');
expect(points.stroke(u, 1)).toBe('#E5484D');
expect(points.stroke(u, 2)).toBe('#30A46C');
expect(points.width(u, 1, 0)).toBe(2);
});
it('drawClear drops cached paths on data series only', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
xSeries._paths = 'x';
dataSeries._paths = 'cached';
plugin.hooks.drawClear(u);
expect(xSeries._paths).toBe('x');
expect(dataSeries._paths).toBeNull();
});
it('focus distance is zero, so the hit series wins focus', () => {
const plugin = createScatterPlugin();
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
});
});
describe('applyScatterPlugin', () => {
it('switches the builder to faceted mode and disables drag selection', () => {
const builder = new UPlotConfigBuilder({ id: 'scatter' });
const plugin = createScatterPlugin();
applyScatterPlugin(builder, plugin);
const config = builder.getConfig();
expect(builder.getMode()).toBe(PlotMode.Faceted);
expect(config.mode).toBe(2);
expect(config.cursor?.drag).toMatchObject({
x: false,
y: false,
setScale: false,
});
expect(config.hooks?.drawClear).toHaveLength(1);
expect(config.hooks?.destroy).toHaveLength(1);
});
it('facets read x and y against the shared scales', () => {
expect(SCATTER_FACETS).toStrictEqual([
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
]);
});
});

View File

@@ -0,0 +1,96 @@
import type uPlot from 'uplot';
import type { Quadtree } from '../../utils/quadtree';
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
export interface SizeDomain {
min: number;
max: number;
}
/**
* Extent of the size column across every series, so equal values draw equal
* discs whichever group they belong to. `null` when nothing carries a size.
*/
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
let min = Infinity;
let max = -Infinity;
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
if (!sizes) {
continue;
}
for (const size of sizes) {
if (size == null || !Number.isFinite(size)) {
continue;
}
min = Math.min(min, size);
max = Math.max(max, size);
}
}
return min <= max ? { min, max } : null;
}
/**
* Disc diameter for a size value. Area, not diameter, follows the value: a
* point worth twice as much should look twice as big.
*/
export function resolvePointDiameter(
size: number | null | undefined,
domain: SizeDomain | null,
pointSize: ScatterPointSize,
): number {
if (size == null || domain == null || !Number.isFinite(size)) {
return pointSize.fixed;
}
if (domain.max === domain.min) {
return (pointSize.min + pointSize.max) / 2;
}
const t = Math.min(
1,
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
);
const minArea = pointSize.min ** 2;
const maxArea = pointSize.max ** 2;
return Math.sqrt(minArea + (maxArea - minArea) * t);
}
/**
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
* whose centre is closest; `tolerance` widens every disc so thin points stay
* hoverable.
*/
export function resolveHit(
tree: Quadtree<ScatterHit>,
cx: number,
cy: number,
tolerance: number,
): ScatterHit | null {
let best: ScatterHit | null = null;
let bestDistance = Infinity;
tree.get(
cx - tolerance,
cy - tolerance,
tolerance * 2,
tolerance * 2,
(hit) => {
const left = hit.x - tolerance;
const top = hit.y - tolerance;
const right = hit.x + hit.w + tolerance;
const bottom = hit.y + hit.h + tolerance;
if (cx < left || cx > right || cy < top || cy > bottom) {
return;
}
const dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -0,0 +1,234 @@
import uPlot, { Series } from 'uplot';
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
import { PlotMode } from '../../config/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { Quadtree } from '../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
SizeDomain,
} from './geometry';
import {
DEFAULT_HOVER_TOLERANCE_PX,
DEFAULT_SCATTER_POINT_SIZE,
ScatterHit,
ScatterPluginOptions,
ScatterSeriesData,
} from './types';
/** Every scatter series reads its own x and y columns against the shared scales. */
export const SCATTER_FACETS: Series.Facet[] = [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
];
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
const TWO_PI = 2 * Math.PI;
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
const HOVER_RING_GAP_PX = 3;
const HOVER_RING_WIDTH_PX = 2;
/**
* uPlot `ceil`s a cursor point's left/top but not its size, which pulls a ring
* up to 1px right and down of the disc. Snapping the edge to a whole pixel and
* growing the span by twice the snap keeps the centre exact.
*/
function snapToPixel(start: number, length: number): [number, number] {
const snapped = Math.floor(start);
return [snapped, length + (start - snapped) * 2];
}
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 : '';
}
/** uPlot caches built paths on the series; the field is internal to it. */
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
export interface ScatterPlugin {
/** Draws every point of a series as one path and indexes the discs for hover. */
pathBuilder: Series.PathBuilder;
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
cursor: uPlot.Cursor;
hooks: {
drawClear: (u: uPlot) => void;
destroy: (u: uPlot) => void;
};
getHit: () => ScatterHit | null;
}
export function createScatterPlugin({
pointSize = DEFAULT_SCATTER_POINT_SIZE,
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
}: ScatterPluginOptions = {}): ScatterPlugin {
let tree: Quadtree<ScatterHit> | null = null;
let hit: ScatterHit | null = null;
// The domain spans every series, so it is resolved once per dataset rather than
// once per series path.
let cachedData: uPlot.AlignedData | null = null;
let cachedDomain: SizeDomain | null = null;
function getSizeDomain(u: uPlot): SizeDomain | null {
if (cachedData !== u.data) {
cachedDomain = resolveSizeDomain(u.data);
cachedData = u.data;
}
return cachedDomain;
}
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
const domain = getSizeDomain(u);
const { pxRatio } = uPlot;
uPlot.orient(
u,
seriesIdx,
(
_series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
_moveTo,
_lineTo,
_rect,
arc,
) => {
const xMin = scaleX.min ?? -Infinity;
const xMax = scaleX.max ?? Infinity;
const yMin = scaleY.min ?? -Infinity;
const yMax = scaleY.max ?? Infinity;
for (let i = idx0; i <= idx1; i++) {
const x = dataX[i];
const y = dataY[i];
if (
x == null ||
y == null ||
x < xMin ||
x > xMax ||
y < yMin ||
y > yMax
) {
continue;
}
const diameter =
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
const radius = diameter / 2;
const cx = valToPosX(x, scaleX, xDim, xOff);
const cy = valToPosY(y, scaleY, yDim, yOff);
path.moveTo(cx + radius, cy);
arc(path, cx, cy, radius, 0, TWO_PI);
tree?.add({
x: cx - radius - u.bbox.left,
y: cy - radius - u.bbox.top,
w: diameter,
h: diameter,
seriesIndex: seriesIdx,
dataIndex: i,
});
}
},
);
return { stroke: path, fill: path, clip: null };
};
const cursor: uPlot.Cursor = {
// Selection would set the dashboard time range; neither axis is time here.
drag: { x: false, y: false, setScale: false },
dataIdx: (u, seriesIdx): number | null => {
// uPlot asks series 1..n in order on every cursor move; resolve once.
if (seriesIdx === 1) {
const { left = -1, top = -1 } = u.cursor;
const { pxRatio } = uPlot;
hit =
tree && left >= 0 && top >= 0
? resolveHit(
tree,
left * pxRatio,
top * pxRatio,
hoverTolerance * pxRatio,
)
: null;
}
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
},
points: {
bbox: (_u, seriesIdx): uPlot.BBox => {
if (hit?.seriesIndex !== seriesIdx) {
return HIDDEN_BBOX;
}
const { pxRatio } = uPlot;
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
const [left, width] = snapToPixel(
hit.x / pxRatio - inset,
hit.w / pxRatio + inset * 2,
);
const [top, height] = snapToPixel(
hit.y / pxRatio - inset,
hit.h / pxRatio + inset * 2,
);
return { left, top, width, height };
},
// An outline around the disc, so the point itself stays readable.
fill: (): string => 'transparent',
stroke: getSeriesStroke,
width: (): number => HOVER_RING_WIDTH_PX,
},
// uPlot only measures series that returned a data index, i.e. the hit one.
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
};
return {
pathBuilder,
cursor,
hooks: {
drawClear: (u: uPlot): void => {
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
// The tree only knows what the path builder last drew, so cached paths
// must be rebuilt alongside it.
u.series.forEach((series, index) => {
if (index > 0) {
(series as SeriesWithPaths)._paths = null;
}
});
},
destroy: (): void => {
tree = null;
hit = null;
cachedData = null;
cachedDomain = null;
},
},
getHit: (): ScatterHit | null => hit,
};
}
export function applyScatterPlugin(
builder: UPlotConfigBuilder,
plugin: ScatterPlugin,
): void {
builder.setMode(PlotMode.Faceted);
builder.setCursor(plugin.cursor);
builder.addHook('drawClear', plugin.hooks.drawClear);
builder.addHook('destroy', plugin.hooks.destroy);
}

View File

@@ -0,0 +1,58 @@
import type { QuadtreeRect } from '../../utils/quadtree';
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
export interface ScatterPointSize {
fixed: number;
min: number;
max: number;
}
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
fixed: 6,
min: 4,
max: 24,
};
/** CSS pixels around a point's disc that still register as a hover. */
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
export interface ScatterPluginOptions {
pointSize?: ScatterPointSize;
hoverTolerance?: number;
}
/**
* One faceted series: parallel columns, one point per index. Sizes are in the
* caller's units and mapped to `pointSize` at draw time; `null` draws at `fixed`.
*/
export type ScatterSeriesData = [
xs: number[],
ys: number[],
sizes?: Array<number | null>,
];
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
export type ScatterChartData = [null, ...ScatterSeriesData[]];
/** A drawn point's disc, in canvas pixels relative to the plot area. */
export interface ScatterHit extends QuadtreeRect {
seriesIndex: number;
dataIndex: number;
}
export interface ScatterChannel {
label: string;
unit?: string;
}
/** What each visual channel plots, for the tooltip and axes. */
export interface ScatterChannels {
x: ScatterChannel;
y: ScatterChannel;
size?: ScatterChannel;
}
export interface ScatterPointLabel {
key: string;
value: string;
}

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,70 @@
import { Quadtree, QuadtreeRect } from '../quadtree';
interface Item extends QuadtreeRect {
id: number;
}
function collect(
tree: Quadtree<Item>,
x: number,
y: number,
w: number,
h: number,
): Set<number> {
const ids = new Set<number>();
tree.get(x, y, w, h, (item) => ids.add(item.id));
return ids;
}
describe('Quadtree', () => {
it('returns items in the queried region and not those far from it', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
// Below the split threshold every item is visited; callers refine the hit.
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
});
it('splits past the object limit and still finds every item', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
const total = 50;
for (let id = 0; id < total; id++) {
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
}
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
});
it('after a split, a query in one quadrant skips items confined to another', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
// All in the north-west quadrant.
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
}
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
const northWest = collect(tree, 0, 0, 10, 10);
expect(northWest.has(99)).toBe(false);
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
});
it('reports an item straddling the midline from either side', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
}
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
});
it('clear empties the tree', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.clear();
expect(collect(tree, 0, 0, 100, 100).size).toBe(0);
});
});

View File

@@ -195,3 +195,40 @@ describe('scale utils', () => {
});
});
});
describe('symmetric log scale', () => {
it('maps to uPlot arcsinh with the given linear threshold', () => {
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
}),
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
}).asinh,
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
});
it('ranges a distr 4 scale through uPlot.rangeAsinh', () => {
const rangeAsinh = jest.fn(() => [-10, 1000] as uPlot.Range.MinMax);
Object.assign(uPlot, { rangeAsinh });
const rangeFn = scaleUtils.createRangeFunction({
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: false,
hardMaxOnly: false,
hasFixedRange: false,
min: null,
max: null,
});
const u = { scales: { y: { distr: 4, log: 10 } } } as unknown as uPlot;
expect(rangeFn(u, -3, 700, 'y')).toStrictEqual([-10, 1000]);
expect(rangeAsinh).toHaveBeenCalledWith(-3, 700, 10, true);
});
});

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

@@ -0,0 +1,110 @@
export interface QuadtreeRect {
x: number;
y: number;
w: number;
h: number;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
/**
* Spatial index over axis-aligned rectangles, for answering "what is under the
* cursor" on charts whose marks have no shared x order to binary-search. An item
* straddling a quadrant boundary lives in every quadrant it touches, so `get` can
* report it more than once.
*/
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
private items: T[] = [];
private quadrants: Quadtree<T>[] | null = null;
constructor(
private readonly x: number,
private readonly y: number,
private readonly w: number,
private readonly h: number,
private readonly level = 0,
) {}
add(item: T): void {
if (this.quadrants) {
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
return;
}
this.items.push(item);
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
this.split();
const items = this.items;
this.items = [];
for (const existing of items) {
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
}
}
}
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
get(
x: number,
y: number,
w: number,
h: number,
visit: (item: T) => void,
): void {
for (const item of this.items) {
visit(item);
}
if (this.quadrants) {
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
quadrant.get(x, y, w, h, visit),
);
}
}
clear(): void {
this.items = [];
this.quadrants = null;
}
private split(): void {
const w = this.w / 2;
const h = this.h / 2;
const level = this.level + 1;
// North-east, north-west, south-west, south-east.
this.quadrants = [
new Quadtree<T>(this.x + w, this.y, w, h, level),
new Quadtree<T>(this.x, this.y, w, h, level),
new Quadtree<T>(this.x, this.y + h, w, h, level),
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
];
}
private forEachQuadrant(
rect: QuadtreeRect,
visit: (quadrant: Quadtree<T>) => void,
): void {
if (!this.quadrants) {
return;
}
const midX = this.x + this.w / 2;
const midY = this.y + this.h / 2;
const startsNorth = rect.y < midY;
const startsWest = rect.x < midX;
const endsEast = rect.x + rect.w > midX;
const endsSouth = rect.y + rect.h > midY;
if (startsNorth && endsEast) {
visit(this.quadrants[0]);
}
if (startsWest && startsNorth) {
visit(this.quadrants[1]);
}
if (startsWest && endsSouth) {
visit(this.quadrants[2]);
}
if (endsEast && endsSouth) {
visit(this.quadrants[3]);
}
}
}

View File

@@ -58,18 +58,23 @@ function normalizeLogLimit(
return logBase ** exp;
}
export const DEFAULT_ASINH_THRESHOLD = 1;
/**
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
* Returns uPlot scale distribution options for a value axis.
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
asinhThreshold,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
asinhThreshold?: number;
}): Partial<Scale> {
if (time) {
return {};
@@ -77,6 +82,14 @@ export function getDistributionConfig({
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
if (distr === DistributionType.SymmetricLog) {
return {
distr: 4,
log: resolvedLogBase,
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
};
}
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,
@@ -197,6 +210,33 @@ function getLogScaleRange(
);
}
/**
* Computes the arcsinh-scale range using uPlot.rangeAsinh, which pads to whole
* magnitudes on either side of zero and pins an edge that sits exactly on zero.
*/
function getAsinhScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
logBase?: uPlot.Scale['log'],
): Range.MinMax {
const { min, max } = params;
const resolvedMin = min ?? dataMin;
const resolvedMax = max ?? dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeAsinh(
resolvedMin,
resolvedMax,
(logBase ?? 10) as 2 | 10,
true,
);
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
@@ -299,6 +339,8 @@ export function createRangeFunction(
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
} else if (scale.distr === 4) {
minMax = getAsinhScaleRange(minMax, params, dataMin, dataMax, logBase);
}
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);

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

@@ -0,0 +1,256 @@
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import Scatter from './Scatter';
import {
buildScatterConfig,
prepareScatterChartData,
ScatterAxisScale,
ScatterSeries,
} from './utils';
const SERVICES = [
'frontend',
'cart',
'checkout',
'payment',
'shipping',
'currency',
'email',
'recommendation',
'ads',
'product-catalog',
];
type Shape = 'spread' | 'single' | 'sameX';
interface ScatterStoryProps {
groups: number;
pointsPerGroup: number;
/** Adds an error-count size column. */
sized: boolean;
xScale: ScatterAxisScale;
yScale: ScatterAxisScale;
/** Zeroes a share of y values, which forces the symmetric log. */
withZeros: boolean;
shape: Shape;
thresholds: boolean;
pointSize: number;
/** 0–1. */
fillOpacity: number;
width: number;
height: number;
}
/** Deterministic, so a story renders the same points on every run. */
function createRng(seed: number): () => number {
let state = seed >>> 0;
return (): number => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
function buildSeries({
groups,
pointsPerGroup,
sized,
withZeros,
shape,
}: ScatterStoryProps): ScatterSeries[] {
const rng = createRng(42);
return Array.from({ length: groups }, (_, groupIndex) => {
const label = SERVICES[groupIndex % SERVICES.length];
// Each service sits in its own throughput/latency band, so groups are telling
// apart rather than one cloud.
const baseThroughput = 20 * 2 ** (groupIndex % 5);
const baseLatency = 40 + 60 * (groupIndex % 4);
const count = shape === 'single' ? 1 : pointsPerGroup;
const xs: number[] = [];
const ys: number[] = [];
const sizes: Array<number | null> = [];
for (let i = 0; i < count; i++) {
const throughput =
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
let latency =
baseLatency *
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
outlier;
if (withZeros && rng() < 0.2) {
latency = 0;
}
xs.push(Number(throughput.toFixed(2)));
ys.push(Number(latency.toFixed(2)));
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
}
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
});
}
const THRESHOLDS: Threshold[] = [
{
thresholdValue: 300,
thresholdUnit: 'ms',
thresholdColor: '#E5484D',
thresholdLabel: 'p99 SLO',
},
];
function ScatterStory(props: ScatterStoryProps): JSX.Element {
const {
xScale,
yScale,
sized,
thresholds,
pointSize,
fillOpacity,
width,
height,
} = props;
const isDarkMode = useIsDarkMode();
const [drawMs, setDrawMs] = useState<number | null>(null);
const series = useMemo(() => buildSeries(props), [props]);
const pointCount = series.reduce((sum, entry) => sum + entry.xs.length, 0);
const drawLabel = drawMs === null ? '—' : `${drawMs.toFixed(1)} ms`;
const config = useMemo(() => {
const builder = buildScatterConfig({
id: 'scatter-story',
series,
isDarkMode,
x: { unit: 'reqps', scale: xScale },
y: { unit: 'ms', scale: yScale },
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
fillOpacity,
thresholds: thresholds ? THRESHOLDS : undefined,
});
let started = 0;
builder.addHook('drawClear', (): void => {
started = performance.now();
});
builder.addHook('draw', (): void => {
setDrawMs(performance.now() - started);
});
return builder;
}, [series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds]);
const data = useMemo(() => prepareScatterChartData(series), [series]);
const resolvePointLabels = (
seriesIndex: number,
dataIndex: number,
): ScatterPointLabel[] => [
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
{
key: 'k8s.pod.name',
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
},
];
return (
<div style={{ width, padding: 16 }}>
<Scatter
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: LegendPosition.BOTTOM }}
channels={{
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99 latency', unit: 'ms' },
...(sized && { size: { label: 'Errors', unit: 'short' } }),
}}
resolvePointLabels={resolvePointLabels}
canPinTooltip
/>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, opacity: 0.7 }}>
{`${pointCount.toLocaleString()} points · last draw ${drawLabel}`}
</p>
</div>
);
}
const meta = {
title: 'Charts/Scatter',
component: ScatterStory,
parameters: { layout: 'padded' },
args: {
groups: 1,
pointsPerGroup: 10,
sized: false,
xScale: ScatterAxisScale.Linear,
yScale: ScatterAxisScale.Linear,
withZeros: false,
shape: 'spread',
thresholds: false,
pointSize: 6,
fillOpacity: 0.7,
width: 800,
height: 420,
},
argTypes: {
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
xScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
yScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
},
} satisfies Meta<ScatterStoryProps>;
export default meta;
type Story = StoryObj<ScatterStoryProps>;
/** One service, ten points: axes formatted with units, hover picks the right point. */
export const Basic: Story = {};
/** Five services, one legend entry each; toggling a row hides its points. */
export const Grouped: Story = {
args: { groups: 5, pointsPerGroup: 40 },
};
/** Error count as disc area, between the configured min and max diameters. */
export const Sized: Story = {
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
};
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
export const LogAxes: Story = {
args: {
groups: 5,
pointsPerGroup: 60,
xScale: ScatterAxisScale.Log,
yScale: ScatterAxisScale.Log,
withZeros: true,
},
};
/** A single point still gets a padded range rather than an empty plot. */
export const SinglePoint: Story = {
args: { shape: 'single' },
};
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
export const SameX: Story = {
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
};
/** Horizontal line with label on the y axis; the scale stretches to include it. */
export const Thresholds: Story = {
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
};
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
export const Dense: Story = {
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
};

View File

@@ -0,0 +1,102 @@
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 = [[]];
export default function Scatter(props: ScatterChartProps): JSX.Element {
const {
children,
customTooltip,
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) {
return customTooltip(args);
}
const tooltipProps: ScatterTooltipProps = {
...args,
id: rest.config.getId(),
channels,
resolvePointLabels,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <ScatterTooltip {...tooltipProps} />;
},
[
customTooltip,
channels,
resolvePointLabels,
rest.config,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
const hasSeries = rest.data.length > 1;
return (
<ChartWrapper
{...rest}
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
plotRef={handlePlotRef}
onClick={onPointClick ? handleClick : rest.onClick}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);
}

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

@@ -0,0 +1,199 @@
import type uPlot from 'uplot';
import { DistributionType } from 'lib/uPlotV2/config/types';
import {
buildScatterConfig,
getCursorHit,
prepareScatterChartData,
resolveAxisDistribution,
ScatterAxisScale,
ScatterSeries,
} from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('prepareScatterChartData', () => {
it('lays series out as facets behind an empty x slot', () => {
expect(prepareScatterChartData(SERIES)).toStrictEqual([
null,
[
[10, 20],
[100, 200],
[1, null],
],
[[30], [0]],
]);
});
});
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(
resolveAxisDistribution([1, 1e6], ScatterAxisScale.Linear),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('is a plain log when every value is positive', () => {
expect(resolveAxisDistribution([1, 100], ScatterAxisScale.Log)).toStrictEqual(
{ distribution: DistributionType.Logarithmic },
);
});
it('falls back from log to a symmetric log around the smallest magnitude when zero is present', () => {
expect(
resolveAxisDistribution([0, 0.05, 300], ScatterAxisScale.Log),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
});
});
it('is a symmetric log when asked, even with only positive values', () => {
expect(
resolveAxisDistribution([2, 300], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('uses a unit threshold when nothing is positive', () => {
expect(
resolveAxisDistribution([0, -5], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
describe('auto', () => {
it.each([
[
'positive values spanning three decades',
[1, 1000],
DistributionType.Logarithmic,
],
['positive values spanning less', [1, 999], DistributionType.Linear],
['a wide span that includes zero', [0, 1, 1e6], DistributionType.Linear],
['no finite values', [Number.NaN], DistributionType.Linear],
])('picks a scale for %s', (_, values, distribution) => {
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
{ distribution },
);
});
it('is the default', () => {
expect(resolveAxisDistribution([1, 1e4])).toStrictEqual({
distribution: DistributionType.Logarithmic,
});
});
});
});
describe('buildScatterConfig', () => {
const build = (
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
): ReturnType<typeof buildScatterConfig> =>
buildScatterConfig({
id: 'scatter',
series: SERIES,
isDarkMode: true,
x: { unit: 'reqps' },
y: { unit: 'ms', scale: ScatterAxisScale.Log },
...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' },
y: { unit: 'ms', label: '' },
}).getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis?.label).toBe('Throughput');
expect(yAxis?.label).toBeUndefined();
});
it('emits a faceted plot with two value scales', () => {
const config = build().getConfig();
expect(config.mode).toBe(2);
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
// The y column has a 0, so log becomes the symmetric variant.
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
});
it('draws one faceted series per group with the plugin path builder', () => {
const config = build().getConfig();
const [, cart, checkout] = config.series ?? [];
expect(config.series).toHaveLength(3);
expect(cart).toMatchObject({
label: 'cart',
facets: [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
],
});
expect(typeof cart?.paths).toBe('function');
expect(cart?.paths).toBe(checkout?.paths);
expect(cart?.points?.show).toBe(false);
});
it('formats both axes with their units', () => {
const config = build().getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
expect(typeof xAxis?.values).toBe('function');
expect(typeof yAxis?.values).toBe('function');
});
it('registers a y threshold draw hook when thresholds are given', () => {
const config = build({
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
}).getConfig();
expect(config.hooks?.draw).toHaveLength(1);
expect(build().getConfig().hooks?.draw).toBeUndefined();
});
});

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

@@ -0,0 +1,316 @@
import { PrecisionOption } from 'components/Graph/types';
import {
type AxisProps,
DistributionType,
DrawStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { Threshold } from 'lib/uPlotV2/hooks/types';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import {
DEFAULT_SCATTER_POINT_SIZE,
ScatterChartData,
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;
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
const X_AXIS_TICK_SPACE_PX = 90;
const X_AXIS_END_LABEL_PADDING_PX = 40;
export interface ScatterSeries {
/** Group label, as the legend names it. */
label: string;
xs: number[];
ys: number[];
/** Optional third channel, in the caller's units. */
sizes?: Array<number | null>;
}
export enum ScatterAxisScale {
/** Log when the values are positive and span several decades, else linear. */
Auto = 'auto',
Linear = 'linear',
Log = 'log',
/** Log-like, but places zero and negatives. */
SymLog = 'symlog',
}
/** Decades of positive values `auto` needs before it picks a log axis. */
export const AUTO_LOG_MIN_DECADES = 3;
export interface ScatterAxisOptions {
/** Axis title. */
label?: string;
unit?: string;
softMin?: number | null;
softMax?: number | null;
scale?: ScatterAxisScale;
}
export interface BuildScatterConfigArgs {
id: string;
series: ScatterSeries[];
isDarkMode: boolean;
x: ScatterAxisOptions;
y: ScatterAxisOptions;
pointSize?: ScatterPointSize;
/** 0–1. */
fillOpacity?: number;
colorMapping?: Record<string, string>;
/** Drawn on the y axis. */
thresholds?: Threshold[];
decimalPrecision?: PrecisionOption;
selectionPreferencesSource?: SelectionPreferencesSource;
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[],
): uPlot.AlignedData {
const data: ScatterChartData = [
null,
...series.map(
(entry): ScatterSeriesData =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return data as unknown as uPlot.AlignedData;
}
export interface AxisDistribution {
distribution: DistributionType;
asinhThreshold?: number;
}
function getPositiveRange(values: number[]): {
minPositive: number;
maxPositive: number;
hasNonPositive: boolean;
} {
let minPositive = Infinity;
let maxPositive = -Infinity;
let hasNonPositive = false;
for (const value of values) {
if (!Number.isFinite(value)) {
continue;
}
if (value <= 0) {
hasNonPositive = true;
} else {
minPositive = Math.min(minPositive, value);
maxPositive = Math.max(maxPositive, value);
}
}
return { minPositive, maxPositive, hasNonPositive };
}
/**
* The symmetric log's linear band ends at the smallest non-zero magnitude, so
* the small values still spread out.
*/
function symmetricLogDistribution(minPositive: number): AxisDistribution {
const asinhThreshold = Number.isFinite(minPositive)
? 10 ** Math.floor(Math.log10(minPositive))
: 1;
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
}
/**
* A plain log axis cannot place zero or negatives, so `log` falls back to the
* symmetric log rather than lose those points; callers that would rather drop
* them filter first.
*/
export function resolveAxisDistribution(
values: number[],
scale: ScatterAxisScale = ScatterAxisScale.Auto,
): AxisDistribution {
const { minPositive, maxPositive, hasNonPositive } = getPositiveRange(values);
switch (scale) {
case ScatterAxisScale.Linear:
return { distribution: DistributionType.Linear };
case ScatterAxisScale.SymLog:
return symmetricLogDistribution(minPositive);
case ScatterAxisScale.Log:
return hasNonPositive
? symmetricLogDistribution(minPositive)
: { distribution: DistributionType.Logarithmic };
case ScatterAxisScale.Auto:
default: {
const spansDecades =
Number.isFinite(minPositive) &&
Math.log10(maxPositive / minPositive) >= AUTO_LOG_MIN_DECADES;
return !hasNonPositive && spansDecades
? { distribution: DistributionType.Logarithmic }
: { distribution: DistributionType.Linear };
}
}
}
/** 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,
isDarkMode,
x,
y,
pointSize = DEFAULT_SCATTER_POINT_SIZE,
fillOpacity,
colorMapping = {},
thresholds,
decimalPrecision,
selectionPreferencesSource,
shouldSaveSelectionPreference,
}: BuildScatterConfigArgs): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({
id,
selectionPreferencesSource,
shouldSaveSelectionPreference,
});
const plugin = createScatterPlugin({ pointSize });
applyScatterPlugin(builder, plugin);
// The last x label is centred on the plot's right edge; room for its unit.
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
);
const yThresholds =
thresholds && thresholds.length > 0
? { 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({
scaleKey: 'y',
time: false,
softMin: y.softMin ?? undefined,
softMax: y.softMax ?? undefined,
thresholds: yThresholds,
range: createScatterRange({
dimension: 'y',
marginPx,
...ySoftLimits,
...yDistribution,
}),
...yDistribution,
});
builder.addAxis({
scaleKey: 'x',
side: 2,
label: x.label || undefined,
isDarkMode,
isTimeAxis: false,
yAxisUnit: x.unit ?? '',
decimalPrecision,
isLogScale: xDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(xDistribution),
space: X_AXIS_TICK_SPACE_PX,
});
builder.addAxis({
scaleKey: 'y',
side: 3,
label: y.label || undefined,
isDarkMode,
yAxisUnit: y.unit ?? '',
decimalPrecision,
isLogScale: yDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(yDistribution),
});
series.forEach((entry) => {
builder.addSeries({
scaleKey: 'y',
label: entry.label,
colorMapping,
drawStyle: DrawStyle.Scatter,
pathBuilder: plugin.pathBuilder,
facets: SCATTER_FACETS,
lineWidth: POINT_STROKE_WIDTH,
pointSize: pointSize.fixed,
fillOpacity,
isDarkMode,
});
});
if (yThresholds) {
builder.addThresholds(yThresholds);
}
return builder;
}

View File

@@ -9,6 +9,10 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -74,6 +78,27 @@ 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;
resolvePointLabels?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
onPointClick?: (click: ScatterPointClick) => void;
}
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -38,6 +38,8 @@ interface ConfigPaneProps {
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Group-by labels of a joined scalar result, for the scatter dimensions editor. */
groupColumns?: string[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
@@ -64,6 +66,7 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -132,6 +135,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -158,6 +162,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,6 +12,7 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isChanged } from '../utils/changes';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -60,6 +61,7 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -71,7 +73,10 @@ function SectionSlot({
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
if (
config.kind === SectionKind.Visualization ||
config.kind === SectionKind.Dimensions
) {
return true;
}
const value = editor?.get(spec);
@@ -125,6 +130,8 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -0,0 +1,61 @@
.chips {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
position: relative;
display: inline-flex;
align-items: center;
max-width: 100%;
height: 32px;
padding: 0 14px;
border: 1px solid var(--l1-border);
border-radius: 999px;
background: var(--l2-background);
color: var(--l2-foreground);
font-family: var(--font-mono, monospace);
font-size: 13px;
cursor: pointer;
transition:
border-color 150ms,
background 150ms;
&:hover {
border-color: var(--l2-border);
}
&:has(:focus-visible) {
outline: 1px solid var(--bg-robin-500);
outline-offset: 1px;
}
}
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
border-color: var(--bg-robin-500);
background: color-mix(in srgb, var(--bg-robin-500) 12%, var(--l1-background));
color: var(--l1-foreground);
&:hover {
border-color: var(--bg-robin-500);
}
}
.label {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,68 @@
import cx from 'classnames';
import styles from './ConfigChips.module.scss';
export interface ConfigChipItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigChipsProps<T extends string> {
testId: string;
/** Selected values, kept in `items` order. */
value: T[];
items: ConfigChipItem<T>[];
onChange: (value: T[]) => void;
'aria-label'?: string;
}
/** Multi-choice toggle chips; any number may be on, including none. */
function ConfigChips<T extends string>({
testId,
value,
items,
onChange,
'aria-label': ariaLabel,
}: ConfigChipsProps<T>): JSX.Element {
const toggle = (item: T): void => {
const next = value.includes(item)
? value.filter((selected) => selected !== item)
: [...value, item];
onChange(
items
.map((candidate) => candidate.value)
.filter((candidate) => next.includes(candidate)),
);
};
return (
<fieldset
aria-label={ariaLabel}
data-testid={testId}
className={styles.chips}
>
{items.map((item) => {
const selected = value.includes(item.value);
return (
<label
key={item.value}
data-testid={`${testId}-${item.value}`}
className={cx(styles.chip, { [styles.selected]: selected })}
title={item.label}
>
<input
type="checkbox"
className={styles.input}
checked={selected}
aria-label={item.label}
onChange={(): void => toggle(item.value)}
/>
<span className={styles.label}>{item.label}</span>
</label>
);
})}
</fieldset>
);
}
export default ConfigChips;

View File

@@ -0,0 +1,59 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigChips from '../ConfigChips';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
describe('ConfigChips', () => {
it('marks the selected chips as checked', () => {
render(
<ConfigChips
testId="chips"
value={['b']}
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('checkbox', { name: 'beta' })).toBeChecked();
expect(screen.getByRole('checkbox', { name: 'alpha' })).not.toBeChecked();
});
it('adds a chip in item order, not click order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith(['a', 'c']);
});
it('removes a selected chip, down to none', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigChips
testId="chips"
value={['a']}
items={ITEMS}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('chips-a'));
expect(onChange).toHaveBeenCalledWith([]);
});
});

View File

@@ -0,0 +1,23 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// The design-system slider insets its track by half a thumb on each side so the
// fill follows the thumb's centre. Pull that inset back off the row so the track
// lines up with the other controls; the thumb never paints past the track edge.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 56px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -0,0 +1,49 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigRangeSlider.module.scss';
interface ConfigRangeSliderProps {
testId: string;
value: [number, number];
min: number;
max: number;
step: number;
/** Renders the current range beside the track (e.g. `4–24 px`). */
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
/** Two-thumb slider for a min/max pair; the thumbs can meet but not cross. */
function ConfigRangeSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigRangeSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
range
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => {
if (Array.isArray(next) && next.length === 2) {
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
}
}}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
</span>
</div>
);
}
export default ConfigRangeSlider;

View File

@@ -0,0 +1,51 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigRangeSlider from '../ConfigRangeSlider';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ConfigRangeSlider', () => {
it('renders a thumb per end and the formatted range', () => {
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
formatValue={([min, max]): string => `${min}–${max} px`}
onChange={jest.fn()}
/>,
);
expect(screen.getAllByRole('slider')).toHaveLength(2);
expect(screen.getByText('4–24 px')).toBeInTheDocument();
});
it('moves one end and reports the pair low to high', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
onChange={onChange}
/>,
);
const [, maxThumb] = screen.getAllByRole('slider');
maxThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
});
});

View File

@@ -88,6 +88,16 @@ export const TILE_DRAWINGS = {
<path d="M4 20 C 8 8, 20 5, 60 4" />
</TileSvg>
),
scaleAuto: (
<TileSvg tone="robin">
<path d="M4 20 L60 4" strokeDasharray="3 3" />
</TileSvg>
),
scaleSymlog: (
<TileSvg tone="robin">
<path d="M4 20 C 20 20, 22 12, 32 12 S 44 4, 60 4" />
</TileSvg>
),
barsSideBySide: (
<TileSvg tone="robin">
<rect x={10} y={10} width={7} height={12} {...FILLED} opacity={0.5} />

View File

@@ -13,6 +13,10 @@ import { EQueryType } from 'types/common/dashboard';
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
/** Group-by labels of the joined scalar result; the colour-by options. */
groupColumns?: string[];
/** Label of the value column a Scatter Plot sizes its dots by; unset when none is. */
sizeColumnLabel?: string;
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -5,6 +5,8 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -25,13 +27,18 @@ import BucketsSection from './sections/BucketsSection/BucketsSection';
import { summarizeBuckets } from './sections/BucketsSection/summary';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import { summarizeChartAppearance } from './sections/ChartAppearanceSection/summary';
import { DEFAULT_POINTS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import { summarizeDimensions } from './sections/DimensionsSection/summary';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import { DEFAULT_DECIMAL_PRECISION } from './sections/FormattingSection/options';
import { summarizeFormatting } from './sections/FormattingSection/summary';
import LegendSection from './sections/LegendSection/LegendSection';
import { summarizeLegend } from './sections/LegendSection/summary';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import { summarizeScatterAxes } from './sections/ScatterAxesSection/summary';
import { summarizeTextLayout } from './sections/TextLayoutSection/summary';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
@@ -107,6 +114,22 @@ export const SECTION_REGISTRY: {
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeAxes,
},
// Same `axes` key as above, in Scatter Plot's per-axis shape.
[SectionKind.ScatterAxes]: {
Component: ScatterAxesSection,
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
summarize: summarizeScatterAxes,
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
summarize: summarizeDimensions,
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>
@@ -121,6 +144,7 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
update: (spec, chartAppearance): PanelSpec =>
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
renderedDefaults: { points: DEFAULT_POINTS },
summarize: summarizeChartAppearance,
},
[SectionKind.Visualization]: {

View File

@@ -13,6 +13,7 @@ import { SWITCH_SKETCHES } from '../../controls/drawings/switchSketches';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -37,8 +38,9 @@ function ChartAppearanceSection({
controls,
onChange,
stepInterval,
sizeColumnLabel,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -120,6 +122,15 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
defaultValue={defaultValue?.points}
sizeColumnLabel={sizeColumnLabel}
onChange={(points): void => onChange({ ...value, points })}
/>
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"

View File

@@ -0,0 +1,87 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
POINT_OPACITY_BOUNDS,
POINT_SIZE_BOUNDS,
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import { createFieldResetter } from '../../utils/changes';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
const OPACITY_STEP = 0.01;
interface PointsFieldsProps {
value: DashboardtypesScatterPlotPointsDTO | undefined;
defaultValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
/** The column dots are sized by; unset draws every dot at one size. */
sizeColumnLabel?: string;
}
function PointsFields({
value,
defaultValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
// The renderer's defaults, so a thumb starts where an unset field draws.
const size = resolvePointSize(value);
return (
<>
{sizeColumnLabel ? (
<ConfigField
label="Point size"
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
{...reset('minSize', 'maxSize')}
>
<ConfigRangeSlider
testId="panel-editor-v2-point-size-range"
value={[size.min, size.max]}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSizeRange}
onChange={([minSize, maxSize]): void =>
onChange({ ...value, minSize, maxSize })
}
/>
</ConfigField>
) : (
<ConfigField
label="Point size"
help="Every dot uses this size. Map a Size column to draw a bubble chart."
{...reset('size')}
>
<ConfigSlider
testId="panel-editor-v2-point-size"
value={size.fixed}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSize}
onChange={(next): void => onChange({ ...value, size: next })}
/>
</ConfigField>
)}
<ConfigField label="Fill opacity" {...reset('opacity')}>
<ConfigSlider
testId="panel-editor-v2-point-opacity"
value={resolvePointOpacity(value)}
min={POINT_OPACITY_BOUNDS.min}
max={POINT_OPACITY_BOUNDS.max}
step={OPACITY_STEP}
formatValue={formatOpacity}
onChange={(opacity): void => onChange({ ...value, opacity })}
/>
</ConfigField>
</>
);
}
export default PointsFields;

View File

@@ -0,0 +1,114 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChartAppearanceSection from '../ChartAppearanceSection';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartAppearanceSection points', () => {
it('offers one point size when nothing sizes the dots', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ points: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size-range'),
).not.toBeInTheDocument();
expect(screen.getByText('6 px')).toBeInTheDocument();
expect(
screen.getByText(
'Every dot uses this size. Map a Size column to draw a bubble chart.',
),
).toBeInTheDocument();
expect(screen.getByText('70%')).toBeInTheDocument();
});
it('offers a min/max range named after the size column when one is bound', () => {
render(
<ChartAppearanceSection
value={{ points: { minSize: 5 } }}
controls={{ points: true }}
sizeColumnLabel="B.count()"
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-point-size-range'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.getByText('5–24 px')).toBeInTheDocument();
expect(
screen.getByText(
"Each dot's area scales with B.count() between min and max.",
),
).toBeInTheDocument();
});
it('steps the fixed size by a whole pixel, keeping the range', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const [sizeThumb] = screen.getAllByRole('slider');
sizeThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
points: { size: 7, minSize: 4, maxSize: 30 },
});
});
it('stops the opacity at 10%', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { opacity: 0.1 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const thumbs = screen.getAllByRole('slider');
const opacityThumb = thumbs[thumbs.length - 1];
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
opacityThumb.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('leaves the points out of a kind that does not declare them', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,25 @@
import { summarizeChartAppearance } from '../summary';
describe('summarizeChartAppearance points', () => {
it('reads one size when nothing sizes the dots', () => {
expect(
summarizeChartAppearance({ points: { size: 8 } }, { points: true }, {}),
).toBe('8 px dots, 70%');
});
it('reads the range when a size column is bound', () => {
expect(
summarizeChartAppearance(
{ points: { minSize: 3, opacity: 0.5 } },
{ points: true },
{ sizeColumnLabel: 'B.count()' },
),
).toBe('3–24 px dots, 50%');
});
it('says nothing about points for a kind without them', () => {
expect(summarizeChartAppearance(undefined, { showPoints: true }, {})).toBe(
'',
);
});
});

View File

@@ -1,15 +1,33 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesLineStyleDTO,
type DashboardtypesScatterPlotPointsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/points';
import type {
PanelChartAppearanceSlice,
SectionControlsOf,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
import { FILL_MODE_OPTIONS, LINE_INTERPOLATION_OPTIONS } from './options';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
function describePoints(
points: DashboardtypesScatterPlotPointsDTO | undefined,
sizeColumnLabel: string | undefined,
): string {
const size = resolvePointSize(points);
const dots = sizeColumnLabel
? `${formatPointSizeRange([size.min, size.max])} dots`
: `${formatPointSize(size.fixed)} dots`;
return `${dots}, ${formatOpacity(resolvePointOpacity(points))}`;
}
function describeLine(value: PanelChartAppearanceSlice): string | undefined {
const interpolation = LINE_INTERPOLATION_OPTIONS.find(
@@ -25,6 +43,7 @@ function describeLine(value: PanelChartAppearanceSlice): string | undefined {
export function summarizeChartAppearance(
value: PanelChartAppearanceSlice | undefined,
controls: SectionControlsOf<SectionKind.ChartAppearance>,
ctx: SectionEditorContext,
): string {
const appearance = value ?? {};
const fill = FILL_MODE_OPTIONS.find(
@@ -37,5 +56,6 @@ export function summarizeChartAppearance(
controls.fillMode && fill && `${fill.label} fill`,
controls.showPoints && appearance.showPoints && 'points',
controls.spanGaps && appearance.spanGaps?.fillOnlyBelow && 'breaks gaps',
controls.points && describePoints(appearance.points, ctx.sizeColumnLabel),
]);
}

View File

@@ -12,3 +12,11 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
export function formatPointSize(size: number): string {
return `${size} px`;
}
export function formatPointSizeRange([min, max]: [number, number]): string {
return `${min}–${max} px`;
}

View File

@@ -0,0 +1,98 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigChips from '../../controls/ConfigChips/ConfigChips';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import {
buildColorKeyItems,
buildDimensionItems,
getColorByHelp,
UNSET_DIMENSION,
} from './utils';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const VALUE_FIELDS = [
{
dimension: 'x',
label: 'X axis',
help: 'The value that places each dot left to right.',
unsetLabel: 'Auto: first value',
},
{
dimension: 'y',
label: 'Y axis',
help: 'The value that places each dot bottom to top.',
unsetLabel: 'Auto: second value',
},
{
dimension: 'size',
label: 'Size',
help:
'Optional. Bigger values draw bigger dots; a group without one draws at the default size.',
unsetLabel: 'None',
},
] as const;
/**
* Edits the `dimensions` slice of a Scatter Plot: which value column each axis
* and the dot size read, and which group-by keys colour the dots. Options come
* from the preview query's joined result.
*/
function DimensionsSection({
value,
defaultValue,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({ value: column.key, label: column.label })),
[tableColumns],
);
const color = value?.color ?? [];
return (
<>
{VALUE_FIELDS.map(({ dimension, label, help, unsetLabel }) => (
<ConfigField
key={dimension}
label={label}
help={help}
{...reset(dimension)}
>
<ConfigSelect
testId={`panel-editor-v2-dimension-${dimension}`}
value={value?.[dimension] ?? UNSET_DIMENSION}
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
onChange={(next): void => onChange({ ...value, [dimension]: next })}
/>
</ConfigField>
))}
<ConfigField
label="Colour by"
help={getColorByHelp(color, groupColumns)}
{...reset('color')}
>
<ConfigChips
testId="panel-editor-v2-dimension-color"
aria-label="Colour by"
value={color}
items={buildColorKeyItems(groupColumns, color)}
onChange={(next): void => onChange({ ...value, color: next })}
/>
</ConfigField>
</>
);
}
export default DimensionsSection;

View File

@@ -0,0 +1,163 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A.count()', label: 'count()' },
{ key: 'A.p99(duration_nano)', label: 'p99(duration_nano)' },
];
async function pick(dimension: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('DimensionsSection', () => {
it('shows each unset dimension as its default', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto: first value',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-size'),
).toHaveTextContent('None');
expect(
screen.getByText('None selected: colour by every group key.'),
).toBeInTheDocument();
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'p99(duration_nano)');
expect(onChange).toHaveBeenCalledWith({
x: 'A.count()',
y: 'A.p99(duration_nano)',
});
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
const { rerender } = render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.pod.name'),
);
expect(onChange).toHaveBeenLastCalledWith({ color: ['k8s.pod.name'] });
rerender(
<DimensionsSection
value={{ color: ['k8s.pod.name'] }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
groupColumns={['k8s.namespace.name', 'k8s.pod.name']}
/>,
);
expect(
screen.getByText('One colour per value of the selected keys.'),
).toBeInTheDocument();
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-k8s.namespace.name'),
);
expect(onChange).toHaveBeenLastCalledWith({
color: ['k8s.namespace.name', 'k8s.pod.name'],
});
});
it('unselects the last colour key back to every key', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ color: ['service.name'] }}
onChange={onChange}
groupColumns={['service.name']}
/>,
);
await user.click(
screen.getByTestId('panel-editor-v2-dimension-color-service.name'),
);
expect(onChange).toHaveBeenCalledWith({ color: [] });
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ color: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-color-host.name'),
).toHaveTextContent('host.name (not in results)');
});
it('asks for a group by when there is nothing to colour by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(
screen.getByText('Group the query by a label to colour dots by it.'),
).toBeInTheDocument();
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ size: 'A.count()' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('size', 'None');
expect(onChange).toHaveBeenCalledWith({ size: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'B' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'B (not in results)',
);
});
});

View File

@@ -0,0 +1,23 @@
import type { DashboardtypesScatterPlotDimensionsDTO } from 'api/generated/services/sigNoz.schemas';
import type { SectionEditorContext } from '../../sectionContext';
import { joinSummary } from '../../utils/summary';
export function summarizeDimensions(
value: DashboardtypesScatterPlotDimensionsDTO | undefined,
_controls: unknown,
{ tableColumns = [] }: SectionEditorContext,
): string {
const labelOf = (key: string | undefined): string | undefined =>
key
? (tableColumns.find((column) => column.key === key)?.label ?? key)
: undefined;
const x = labelOf(value?.x);
const y = labelOf(value?.y);
const size = labelOf(value?.size);
return joinSummary([
x || y ? `${x ?? 'auto'} vs ${y ?? 'auto'}` : 'Auto',
size && `sized by ${size}`,
(value?.color?.length ?? 0) > 0 && `coloured by ${value?.color?.join(', ')}`,
]);
}

View File

@@ -0,0 +1,46 @@
import type { ConfigChipItem } from '../../controls/ConfigChips/ConfigChips';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/**
* The unset option first, then the result's columns. A bound key the current
* result no longer has stays listed, so the select never shows a bare value.
*/
export function buildDimensionItems(
columns: ConfigSelectItem[],
current: string | undefined,
unsetLabel: string,
): ConfigSelectItem[] {
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
if (current && !columns.some((column) => column.value === current)) {
items.push({ value: current, label: `${current} (not in results)` });
}
return items;
}
/** The result's group-by keys, then any selected key the result no longer has. */
export function buildColorKeyItems(
groupColumns: string[],
selected: string[],
): ConfigChipItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}
export function getColorByHelp(
selected: string[],
groupColumns: string[],
): string {
if (groupColumns.length === 0 && selected.length === 0) {
return 'Group the query by a label to colour dots by it.';
}
return selected.length > 0
? 'One colour per value of the selected keys.'
: 'None selected: colour by every group key.';
}

View File

@@ -0,0 +1,32 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ScatterAxisFields from './ScatterAxisFields';
/** Edits a Scatter Plot's `axes` slice: soft bounds and scale, per axis. */
function ScatterAxesSection({
value,
defaultValue,
onChange,
}: SectionEditorProps<SectionKind.ScatterAxes>): JSX.Element {
return (
<>
<ScatterAxisFields
axis="x"
value={value?.x}
defaultValue={defaultValue?.x}
onChange={(x): void => onChange({ ...value, x })}
/>
<ScatterAxisFields
axis="y"
value={value?.y}
defaultValue={defaultValue?.y}
onChange={(y): void => onChange({ ...value, y })}
/>
</>
);
}
export default ScatterAxesSection;

View File

@@ -0,0 +1,84 @@
import { Input } from 'antd';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigFieldRow from '../../controls/ConfigFieldRow/ConfigFieldRow';
import ConfigNumberInput from '../../controls/ConfigNumberInput/ConfigNumberInput';
import ConfigTiles from '../../controls/ConfigTiles/ConfigTiles';
import { createFieldResetter } from '../../utils/changes';
import { SCALE_HELP, SCALE_OPTIONS } from './options';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
defaultValue: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
}
function ScatterAxisFields({
axis,
value,
defaultValue,
onChange,
}: ScatterAxisFieldsProps): JSX.Element {
const reset = createFieldResetter(value, defaultValue, onChange);
const name = axis.toUpperCase();
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
return (
<>
<ConfigField
label={`${name}-axis label`}
help="Drawn along the axis, and names its value in the tooltip."
{...reset('label')}
>
<Input
data-testid={`panel-editor-v2-${axis}-label`}
placeholder="None"
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigField>
<ConfigField
label={`${name}-axis range`}
help="The axis always shows at least this range. Data outside it still stretches the axis."
{...reset('softMin', 'softMax')}
>
<ConfigFieldRow>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-min`}
label="Min"
value={value?.softMin}
onChange={(softMin): void => onChange({ ...value, softMin })}
/>
<ConfigNumberInput
testId={`panel-editor-v2-${axis}-soft-max`}
label="Max"
value={value?.softMax}
onChange={(softMax): void => onChange({ ...value, softMax })}
/>
</ConfigFieldRow>
</ConfigField>
<ConfigField
label={`${name}-axis scale`}
help={SCALE_HELP[scale]}
{...reset('scale')}
>
<ConfigTiles
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</ConfigField>
</>
);
}
export default ScatterAxisFields;

View File

@@ -0,0 +1,94 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import ScatterAxesSection from '../ScatterAxesSection';
describe('ScatterAxesSection', () => {
it('renders bounds and scale for both axes', () => {
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
['x', 'y'].forEach((axis) => {
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
).toBeInTheDocument();
});
});
it('writes a soft bound to its own axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
expect(onChange).toHaveBeenCalledWith({
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { softMax: 5 },
});
});
it('writes an axis label', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
);
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
});
it.each([
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
])('sets the x scale to %s', async (scale) => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
});
it('marks a changed scale against the default and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
defaultValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesAxisScaleDTO.auto },
});
});
});

View File

@@ -0,0 +1,37 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type { ConfigTileItem } from '../../controls/ConfigTiles/ConfigTiles';
import { TILE_DRAWINGS } from '../../controls/drawings/tileDrawings';
export const SCALE_OPTIONS: ConfigTileItem<DashboardtypesAxisScaleDTO>[] = [
{
value: DashboardtypesAxisScaleDTO.auto,
label: 'Auto',
drawing: TILE_DRAWINGS.scaleAuto,
},
{
value: DashboardtypesAxisScaleDTO.linear,
label: 'Linear',
drawing: TILE_DRAWINGS.scaleLinear,
},
{
value: DashboardtypesAxisScaleDTO.log,
label: 'Logarithmic',
drawing: TILE_DRAWINGS.scaleLog,
},
{
value: DashboardtypesAxisScaleDTO.symlog,
label: 'Symlog',
drawing: TILE_DRAWINGS.scaleSymlog,
},
];
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
[DashboardtypesAxisScaleDTO.auto]:
'Logarithmic when every value is positive and they span three or more orders of magnitude, otherwise linear.',
[DashboardtypesAxisScaleDTO.linear]: 'Evenly spaced values.',
[DashboardtypesAxisScaleDTO.log]:
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
[DashboardtypesAxisScaleDTO.symlog]:
'Logarithmic, but places zero and negative values too.',
};

View File

@@ -0,0 +1,24 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxesDTO,
} from 'api/generated/services/sigNoz.schemas';
import { joinSummary } from '../../utils/summary';
import { SCALE_OPTIONS } from './options';
function scaleLabel(scale: DashboardtypesAxisScaleDTO | undefined): string {
return (
SCALE_OPTIONS.find(
(option) => option.value === (scale ?? DashboardtypesAxisScaleDTO.auto),
)?.label ?? ''
);
}
export function summarizeScatterAxes(
value: DashboardtypesScatterPlotAxesDTO | undefined,
): string {
return joinSummary([
`X ${scaleLabel(value?.x?.scale)}`,
`Y ${scaleLabel(value?.y?.scale)}`,
]);
}

View File

@@ -0,0 +1,38 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getSizeColumnLabel } from '../sizeColumnLabel';
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { size } } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
const COLUMNS = [{ key: 'B', label: 'B.count()' }];
describe('getSizeColumnLabel', () => {
it('names the bound column by its label', () => {
expect(getSizeColumnLabel(scatterSpec('B'), COLUMNS)).toBe('B.count()');
});
it('is unset when nothing is bound', () => {
expect(getSizeColumnLabel(scatterSpec(''), COLUMNS)).toBeUndefined();
expect(getSizeColumnLabel(scatterSpec(), COLUMNS)).toBeUndefined();
});
it('is unset for a bound key the loaded result no longer has, as the chart draws it', () => {
expect(getSizeColumnLabel(scatterSpec('C'), COLUMNS)).toBeUndefined();
});
it('falls back to the key before the result loads', () => {
expect(getSizeColumnLabel(scatterSpec('B'), [])).toBe('B');
});
it('is unset for other kinds', () => {
const spec = {
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
expect(getSizeColumnLabel(spec, COLUMNS)).toBeUndefined();
});
});

View File

@@ -0,0 +1,24 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* What the dots are sized by, mirroring the renderer: a bound key the loaded
* result no longer has sizes nothing. Before the result loads the key stands in.
*/
export function getSizeColumnLabel(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): string | undefined {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return undefined;
}
const key = spec.plugin.spec.dimensions?.size;
if (!key) {
return undefined;
}
if (tableColumns.length === 0) {
return key;
}
return tableColumns.find((column) => column.key === key)?.label;
}

View File

@@ -35,6 +35,7 @@ import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useGroupColumns } from './hooks/useGroupColumns';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
@@ -196,6 +197,7 @@ function QueryEditorBody({
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
const groupColumns = useGroupColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
@@ -325,6 +327,7 @@ function QueryEditorBody({
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useGroupColumns } from '../useGroupColumns';
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind, spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
}
describe('useGroupColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a kind that does not join its scalar rows', () => {
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/PieChartPanel'), DATA),
);
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('keeps only the group-by columns, by key', () => {
mockPrepareScalarTables.mockReturnValue([
{ columns: [], rows: [] },
{
columns: [
{
id: 'k8s.namespace.name',
name: 'k8s.namespace.name',
isValueColumn: false,
},
{ id: 'A', name: 'cpu', isValueColumn: true },
{ id: 'k8s.pod.name', name: 'k8s.pod.name', isValueColumn: false },
],
rows: [],
},
]);
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
);
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
});
});

View File

@@ -3,6 +3,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -47,6 +48,7 @@ const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
@@ -60,6 +62,14 @@ const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const scatterPanel = (color?: string[]): DashboardtypesPanelDTO =>
({
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions: { color } } },
queries: [],
},
}) as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -133,6 +143,36 @@ describe('useLegendSeries', () => {
expect(mockFlatten).not.toHaveBeenCalled();
});
it('resolves scatter plot panels by the colour-by label', () => {
mockPrepareScalarTables.mockReturnValue([
{
queryName: 'A',
legend: '',
columns: [
{ id: 'ns', name: 'ns', queryName: '', isValueColumn: false },
{ id: 'pod', name: 'pod', queryName: '', isValueColumn: false },
{ id: 'A', name: 'A', queryName: 'A', isValueColumn: true },
{ id: 'B', name: 'B', queryName: 'B', isValueColumn: true },
],
rows: [
{ data: { ns: 'prod', pod: 'p1', A: 1, B: 2 } },
{ data: { ns: 'dev', pod: 'p2', A: 3, B: 4 } },
{ data: { ns: 'prod', pod: 'p3', A: 5, B: 6 } },
],
},
]);
const { result } = renderHook(() =>
useLegendSeries(scatterPanel(['ns']), DATA),
);
expect(result.current).toStrictEqual([
{ label: 'prod', defaultColor: 'color:prod' },
{ label: 'dev', defaultColor: 'color:dev' },
]);
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -56,6 +56,23 @@ describe('useTableColumns', () => {
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('resolves columns for a scatter plot, which joins its rows like a table', () => {
tableWith([{ id: 'A', name: 'p99', isValueColumn: true }]);
const panel = {
kind: 'Panel',
spec: {
plugin: { kind: 'signoz/ScatterPlotPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
const { result } = renderHook(() => useTableColumns(panel, DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
]);
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);

View File

@@ -0,0 +1,37 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
/**
* Group-by labels of a joined scalar result, the keys a group column is stored
* under. Empty for kinds that don't join their scalar rows or before data arrives.
*/
export function useGroupColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): string[] {
return useMemo(() => {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap,
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0);
return (table?.columns ?? [])
.filter((column) => !column.isValueColumn)
.map((column) => column.id);
}, [
panel.spec.plugin.kind,
data.response,
data.legendMap,
data.requestPayload,
]);
}

View File

@@ -21,7 +21,12 @@ export function useLegendSeries(
return useMemo(() => {
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
? resolve({
spec: panel.spec,
queries: panel.spec.queries,
data,
isDarkMode,
})
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
}, [kind, panel.spec, data, isDarkMode]);
}

View File

@@ -1,7 +1,9 @@
import { useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelFormattingSlice } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getColumnUnit } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getColumnUnit';
import { getValueColumnLabels } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getValueColumnLabels';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -28,14 +30,18 @@ export interface TableColumnOption {
* Resolves a Table panel's value (aggregation) columns into `{ key, label }`
* options, so the table-only config editors (column units, per-column thresholds)
* store the query-keyed value the renderer looks up by while showing the readable
* column name. Empty for non-table kinds or before data arrives.
* column name. Empty for kinds that don't join their scalar rows (Table and
* Scatter Plot do) or before data arrives.
*/
export function useTableColumns(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): TableColumnOption[] {
return useMemo(() => {
if (panel.spec.plugin.kind !== 'signoz/TablePanel') {
if (
!getQueryPanelDefinition(panel.spec.plugin.kind)?.queryCapabilities
.formatTableResultForUI
) {
return [];
}
const table = prepareScalarTables({
@@ -49,13 +55,14 @@ export function useTableColumns(
const columnUnits =
(panel.spec.plugin.spec as { formatting?: PanelFormattingSlice }).formatting
?.columnUnits ?? {};
const labels = getValueColumnLabels(table.columns);
return table.columns
.filter((column) => column.isValueColumn)
.map((column) => {
const key = column.id || column.name;
return {
key,
label: column.name,
label: labels[key] ?? column.name,
unit: getColumnUnit(key, columnUnits),
};
});

View File

@@ -33,6 +33,7 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
@@ -48,6 +49,7 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
'signoz/TextPanel': [],
'signoz/ScatterPlotPanel': [metrics, logs, traces],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
@@ -100,7 +102,7 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
// Only Table asks the server to transpose its scalar result into UI rows.
// Only Table and Scatter Plot ask the server to join their scalar results into UI rows.
'signoz/TablePanel': {
requestType: scalar,
formatTableResultForUI: true,
@@ -108,6 +110,13 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/ScatterPlotPanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
// Only List reads raw rows, pages them server-side, and needs an order tiebreaker.
'signoz/ListPanel': {
requestType: raw,

View File

@@ -0,0 +1,5 @@
.chart {
flex: 1;
min-width: 0;
min-height: 0;
}

View File

@@ -0,0 +1,232 @@
import { useCallback, useMemo, useRef } from 'react';
import { ChartScatter } from '@signozhq/icons';
import type { DashboardtypesScatterPlotPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import type { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { ScatterPointClick } from 'lib/visualization/charts/types';
import Scatter from 'lib/visualization/charts/Scatter/Scatter';
import {
buildScatterConfig,
prepareScatterChartData,
} from 'lib/visualization/charts/Scatter/utils';
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import NoData from '../../components/NoData/NoData';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import PanelStyles from '../../panel.module.scss';
import type { PanelRendererProps } from '../../types/rendererProps';
import { mapThresholds } from '../../utils/baseConfigBuilder';
import { enrichScatterClick } from '../../utils/drilldown/enrichScatterClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import {
resolveSelectionPreferencesSource,
shouldSaveSelectionPreference,
} from '../../utils/selectionPreferences';
import ScatterPlotFooter from './components/ScatterPlotFooter/ScatterPlotFooter';
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from './messages';
import { resolvePointOpacity, resolvePointSize } from './points';
import { prepareScatterPlotData } from './prepareData';
import styles from './Renderer.module.scss';
import { ScatterPlotDataStatus } from './types';
import { toScatterAxisOptions } from './utils';
function ScatterPlotPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
panelMode,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/ScatterPlotPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const spec = useMemo<DashboardtypesScatterPlotPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
// V5 joins every query into one scalar result, so the first non-empty table
// holds every group.
const table = useMemo(
() =>
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}).find((candidate) => candidate.columns.length > 0),
[data.response, data.legendMap, data.requestPayload],
);
const scatterData = useMemo(
() =>
prepareScatterPlotData({
table,
dimensions: spec.dimensions,
axes: spec.axes,
columnUnits: spec.formatting?.columnUnits ?? {},
}),
[table, spec.dimensions, spec.axes, spec.formatting?.columnUnits],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const emptyMessage = getScatterPlotEmptyMessage(scatterData);
const footerText = getScatterPlotFooterText(scatterData);
const readyData =
scatterData.status === ScatterPlotDataStatus.Ready &&
scatterData.drawnGroups > 0
? scatterData
: undefined;
const config = useMemo(
() =>
readyData
? buildScatterConfig({
id: panelId,
series: readyData.series,
isDarkMode,
x: toScatterAxisOptions(spec.axes?.x, readyData.channels.x.unit),
y: toScatterAxisOptions(spec.axes?.y, readyData.channels.y.unit),
pointSize: resolvePointSize(spec.chartAppearance?.points),
fillOpacity: resolvePointOpacity(spec.chartAppearance?.points),
colorMapping: spec.legend?.customColors ?? {},
thresholds: mapThresholds(spec.thresholds),
decimalPrecision,
selectionPreferencesSource: resolveSelectionPreferencesSource(panelMode),
shouldSaveSelectionPreference: shouldSaveSelectionPreference(panelMode),
})
: undefined,
[
readyData,
panelId,
isDarkMode,
spec.axes?.x,
spec.axes?.y,
spec.chartAppearance?.points,
spec.legend?.customColors,
spec.thresholds,
decimalPrecision,
panelMode,
],
);
const chartData = useMemo(
() => (readyData ? prepareScatterChartData(readyData.series) : undefined),
[readyData],
);
const resolvePointLabels = useCallback(
(seriesIndex: number, dataIndex: number): ScatterPointLabel[] =>
readyData?.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
[readyData],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const handlePointClick = useCallback(
({ seriesIndex, dataIndex, color, coordinates }: ScatterPointClick): void => {
if (!onClick || !readyData) {
return;
}
const payload = enrichScatterClick({
labels: readyData.pointLabels[seriesIndex - 1]?.[dataIndex] ?? [],
label: readyData.series[seriesIndex - 1]?.label ?? '',
color,
axisQueries: readyData.axisQueries,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, readyData, builderQueries, data.requestPayload],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={!!enableDrillDown}
/>
),
[panelId, enableDrillDown],
);
return (
<div
data-testid="scatter-plot-panel-renderer"
className={PanelStyles.panelContainer}
>
{!readyData && emptyMessage && (
<PanelMessage
icon={<ChartScatter size={18} />}
title={emptyMessage.title}
description={emptyMessage.description}
data-testid="scatter-plot-empty-message"
/>
)}
{!readyData && !emptyMessage && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{readyData && config && chartData && (
<>
<div ref={graphRef} className={styles.chart}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<Scatter
key={panelId}
config={config}
data={chartData}
channels={readyData.channels}
resolvePointLabels={resolvePointLabels}
legendConfig={{ position: legendPosition }}
decimalPrecision={decimalPrecision}
canPinTooltip
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onPointClick={enableDrillDown ? handlePointClick : undefined}
data-testid="scatter-plot-chart"
/>
)}
</div>
{footerText && <ScatterPlotFooter text={footerText} />}
</>
)}
</div>
);
}
export default ScatterPlotPanelRenderer;

View File

@@ -0,0 +1,126 @@
import type {
DashboardtypesPanelDTO,
DashboardtypesScatterPlotPanelSpecDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import type { BaseRendererProps } from '../../../types/rendererProps';
import BaseScatterPlotPanelRenderer from '../Renderer';
const ScatterPlotPanelRenderer =
BaseScatterPlotPanelRenderer as React.FC<BaseRendererProps>;
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesScatterPlotPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/ScatterPlotPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
/** A joined scalar result: one group column plus one value column per query. */
function dataWith(queryNames: string[], rows: unknown[][]): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: queryNames[0],
columns: [
{ name: 'service.name', queryName: '', columnType: 'group' },
...queryNames.map((queryName) => ({
name: '__result_0',
queryName,
columnType: 'aggregation',
aggregationIndex: 0,
})),
],
data: rows,
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
function renderPanel(data: PanelQueryData): ReturnType<typeof render> {
return render(
<ScatterPlotPanelRenderer
panelId="panel-1"
panel={panelWith({})}
data={data}
isFetching={false}
error={null}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
}
describe('ScatterPlotPanelRenderer', () => {
it('shows the plain empty state when the query returns no groups', () => {
const { getByTestId } = renderPanel(dataWith(['A', 'B'], []));
expect(getByTestId('panel-no-data')).toBeInTheDocument();
});
it('asks for a second value when the query has one value column', () => {
const { getByTestId, getByText } = renderPanel(
dataWith(['A'], [['cart', 120]]),
);
expect(getByTestId('scatter-plot-empty-message')).toBeInTheDocument();
expect(getByText('Add a second aggregation or query')).toBeInTheDocument();
});
it('explains the group by mismatch when no row has both an x and a y', () => {
const { getByText } = renderPanel(
dataWith(['A', 'B'], [['checkout', 45, 'n/a']]),
);
expect(getByText('0 of 1 group plotted')).toBeInTheDocument();
});
it('plots once two value columns line up for a group, and says how many did', () => {
const { getByTestId, queryByTestId } = renderPanel(
dataWith(
['A', 'B'],
[
['cart', 120, 340],
['checkout', 45, 'n/a'],
],
),
);
expect(getByTestId('scatter-plot-panel-renderer')).toBeInTheDocument();
expect(queryByTestId('panel-no-data')).not.toBeInTheDocument();
expect(getByTestId('scatter-plot-footer')).toHaveTextContent(
'Showing 1 of 2 groups',
);
});
it('has no footer when every group plots', () => {
const { queryByTestId } = renderPanel(
dataWith(['A', 'B'], [['cart', 120, 340]]),
);
expect(queryByTestId('scatter-plot-footer')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,109 @@
import {
getScatterPlotEmptyMessage,
getScatterPlotFooterText,
} from '../messages';
import { type ScatterPlotData, ScatterPlotDataStatus } from '../types';
function ready(counts: {
totalGroups: number;
drawnGroups: number;
missingValueGroups?: number;
nonPositiveOnLogGroups?: number;
}): ScatterPlotData {
return {
status: ScatterPlotDataStatus.Ready,
series: [],
pointLabels: [],
channels: { x: { label: 'x' }, y: { label: 'y' } },
axisQueries: { x: 'A', y: 'A' },
missingValueGroups: 0,
nonPositiveOnLogGroups: 0,
...counts,
};
}
describe('getScatterPlotEmptyMessage', () => {
it('leaves an empty result to the plain no-data state', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 0,
}),
).toBeUndefined();
expect(
getScatterPlotEmptyMessage(ready({ totalGroups: 0, drawnGroups: 0 })),
).toBeUndefined();
});
it('asks for a second value', () => {
expect(
getScatterPlotEmptyMessage({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 3,
})?.title,
).toBe('Add a second aggregation or query');
});
it('says nothing once a group plots', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 3, drawnGroups: 1, missingValueGroups: 2 }),
),
).toBeUndefined();
});
it('points at the group by when no group has both values', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 52, drawnGroups: 0, missingValueGroups: 52 }),
),
).toStrictEqual({
title: '0 of 52 groups plotted',
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
});
});
it('points at symlog when a log axis hid every group', () => {
expect(
getScatterPlotEmptyMessage(
ready({ totalGroups: 1, drawnGroups: 0, nonPositiveOnLogGroups: 1 }),
),
).toStrictEqual({
title: '0 of 1 group plotted',
description:
"1 group with a value ≤ 0 can't go on a log axis. Switch the axis to symlog to show them.",
});
});
});
describe('getScatterPlotFooterText', () => {
it('is absent when every group plots', () => {
expect(
getScatterPlotFooterText(ready({ totalGroups: 4, drawnGroups: 4 })),
).toBeUndefined();
});
it('counts the groups shown', () => {
expect(
getScatterPlotFooterText(
ready({ totalGroups: 52, drawnGroups: 37, missingValueGroups: 15 }),
),
).toBe('Showing 37 of 52 groups');
});
it('calls out the groups a log axis hid', () => {
expect(
getScatterPlotFooterText(
ready({
totalGroups: 52,
drawnGroups: 37,
missingValueGroups: 12,
nonPositiveOnLogGroups: 3,
}),
),
).toBe(
"Showing 37 of 52 groups · 3 groups with a value ≤ 0 can't go on a log axis",
);
});
});

View File

@@ -0,0 +1,23 @@
import {
DEFAULT_POINTS,
resolvePointOpacity,
resolvePointSize,
} from '../points';
describe('scatter plot points', () => {
it('draws unset points at the defaults', () => {
expect(resolvePointSize(undefined)).toStrictEqual({
fixed: DEFAULT_POINTS.size,
min: DEFAULT_POINTS.minSize,
max: DEFAULT_POINTS.maxSize,
});
expect(resolvePointOpacity(undefined)).toBe(DEFAULT_POINTS.opacity);
});
it('takes each set field and defaults the rest, null included', () => {
expect(
resolvePointSize({ size: 10, minSize: null, maxSize: 30 }),
).toStrictEqual({ fixed: 10, min: DEFAULT_POINTS.minSize, max: 30 });
expect(resolvePointOpacity({ opacity: 0.1 })).toBe(0.1);
});
});

View File

@@ -0,0 +1,405 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
PanelTable,
PanelTableColumn,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { prepareScatterPlotData, UNGROUPED_SERIES_LABEL } from '../prepareData';
import { ScatterPlotDataStatus } from '../types';
const group = (id: string): PanelTableColumn => ({
id,
name: id,
queryName: '',
isValueColumn: false,
});
const value = (id: string, name = id): PanelTableColumn => ({
id,
name,
queryName: id.split('.')[0],
isValueColumn: true,
});
function makeTable(
columns: PanelTableColumn[],
rows: Record<string, unknown>[],
): PanelTable {
return {
queryName: 'A',
legend: '',
columns,
rows: rows.map((data) => ({ data })),
};
}
const tracesTable = makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('A.countIf(has_error = true)', 'errors'),
],
[
{
'service.name': 'cart',
'A.count()': 12000,
'A.p99(duration_nano)': 340,
'A.countIf(has_error = true)': 15,
},
{
'service.name': 'payment',
'A.count()': 8500,
'A.p99(duration_nano)': 900,
'A.countIf(has_error = true)': 420,
},
],
);
describe('prepareScatterPlotData', () => {
it('plots the first two value columns when no dimensions are set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
columnUnits: {},
});
expect(result.status).toBe(ScatterPlotDataStatus.Ready);
if (result.status !== ScatterPlotDataStatus.Ready) {
return;
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
expect(result.series).toStrictEqual([
{ label: 'cart', xs: [12000], ys: [340] },
{ label: 'payment', xs: [8500], ys: [900] },
]);
expect(result.pointLabels).toStrictEqual([
[[{ key: 'service.name', value: 'cart' }]],
[[{ key: 'service.name', value: 'payment' }]],
]);
});
it('binds x, y and size to the configured columns, with their units', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: {
x: 'A.p99(duration_nano)',
y: 'A.count()',
size: 'A.countIf(has_error = true)',
},
columnUnits: { 'A.p99(duration_nano)': 'ns' },
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels).toStrictEqual({
x: { label: 'A.p99(duration_nano)', unit: 'ns' },
y: { label: 'A.count()', unit: undefined },
size: { label: 'A.errors', unit: undefined },
});
expect(result.series[1]).toStrictEqual({
label: 'payment',
xs: [900],
ys: [8500],
sizes: [420],
});
});
it('falls back to the defaults when a dimension names a column that is gone', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'B', y: 'A.avg(duration_nano)', size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size).toBeUndefined();
});
it('labels each value column with its query, so shared names read apart', () => {
const result = prepareScatterPlotData({
table: makeTable(
[
group('service.name'),
value('A.count()', 'count()'),
value('A.p99(duration_nano)', 'p99(duration_nano)'),
value('B', 'count()'),
],
[
{
'service.name': 'cart',
'A.count()': 1,
'A.p99(duration_nano)': 2,
B: 3,
},
],
),
dimensions: { size: 'B' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('A.count()');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
expect(result.channels.size?.label).toBe('B.count()');
expect(result.axisQueries).toStrictEqual({ x: 'A', y: 'A' });
});
it('names an axis channel by its axis label when one is set', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: undefined,
axes: { x: { label: 'Throughput' }, y: { label: '' } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.x.label).toBe('Throughput');
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('picks a y column other than the bound x', () => {
const result = prepareScatterPlotData({
table: tracesTable,
dimensions: { x: 'A.count()' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.channels.y.label).toBe('A.p99(duration_nano)');
});
it('colours by the configured group label and keeps every label for the tooltip', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'dev', 'k8s.pod.name': 'p2', A: 3, B: 4 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p3', A: 5, B: 6 },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { color: ['k8s.namespace.name'] },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual([
'prod',
'dev',
]);
expect(result.series[0]).toMatchObject({ xs: [1, 5], ys: [2, 6] });
expect(result.pointLabels[0][1]).toStrictEqual([
{ key: 'k8s.namespace.name', value: 'prod' },
{ key: 'k8s.pod.name', value: 'p3' },
]);
});
describe('colour keys', () => {
const table = makeTable(
[group('k8s.namespace.name'), group('k8s.pod.name'), value('A'), value('B')],
[
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p1', A: 1, B: 2 },
{ 'k8s.namespace.name': 'prod', 'k8s.pod.name': 'p2', A: 3, B: 4 },
],
);
const seriesLabels = (color: string[] | null | undefined): string[] => {
const result = prepareScatterPlotData({
table,
dimensions: { color },
columnUnits: {},
});
return result.status === ScatterPlotDataStatus.Ready
? result.series.map((series) => series.label)
: [];
};
it.each([
['none selected', []],
['an unset field', undefined],
['null from the wire', null],
])('colours by every group key with %s', (_, color) => {
expect(seriesLabels(color)).toStrictEqual(['prod, p1', 'prod, p2']);
});
it('colours by the combination of the selected keys, in result order', () => {
expect(seriesLabels(['k8s.pod.name', 'k8s.namespace.name'])).toStrictEqual([
'prod, p1',
'prod, p2',
]);
});
it('colours by one key alone', () => {
expect(seriesLabels(['k8s.namespace.name'])).toStrictEqual(['prod']);
});
it('ignores keys the result does not have', () => {
expect(seriesLabels(['host.name', 'k8s.pod.name'])).toStrictEqual([
'p1',
'p2',
]);
expect(seriesLabels(['host.name'])).toStrictEqual(['prod, p1', 'prod, p2']);
});
});
it('draws everything as one series when there is no group by', () => {
const result = prepareScatterPlotData({
table: makeTable([value('A'), value('B')], [{ A: 1, B: 2 }]),
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series).toStrictEqual([
{ label: UNGROUPED_SERIES_LABEL, xs: [1], ys: [2] },
]);
});
it.each([
['a join miss', 'n/a'],
['NaN', 'NaN'],
['Inf', 'Inf'],
['-Inf', '-Inf'],
['an empty cell', undefined],
])('drops a row whose y is %s and counts it', (_, cell) => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'checkout', A: 45, B: cell },
],
);
const result = prepareScatterPlotData({
table,
dimensions: undefined,
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.totalGroups).toBe(2);
expect(result.drawnGroups).toBe(1);
});
describe('log axes', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B')],
[
{ 'service.name': 'cart', A: 120, B: 340 },
{ 'service.name': 'idle', A: 0, B: 12 },
{ 'service.name': 'refund', A: 4, B: -1 },
],
);
it('drops values ≤ 0 on a log axis and counts them apart from missing values', () => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: {
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { scale: DashboardtypesAxisScaleDTO.log },
},
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.label)).toStrictEqual(['cart']);
expect(result.nonPositiveOnLogGroups).toBe(2);
expect(result.missingValueGroups).toBe(0);
});
it.each([
DashboardtypesAxisScaleDTO.symlog,
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
])('keeps values ≤ 0 on a %s axis', (scale) => {
const result = prepareScatterPlotData({
table,
dimensions: undefined,
axes: { x: { scale }, y: { scale } },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.drawnGroups).toBe(3);
expect(result.nonPositiveOnLogGroups).toBe(0);
});
});
it('draws a row with a missing size at the default size', () => {
const table = makeTable(
[group('service.name'), value('A'), value('B'), value('C')],
[
{ 'service.name': 'search', A: 300, B: 120, C: 'n/a' },
{ 'service.name': 'cart', A: 120, B: 340, C: '0.2' },
],
);
const result = prepareScatterPlotData({
table,
dimensions: { size: 'C' },
columnUnits: {},
});
if (result.status !== ScatterPlotDataStatus.Ready) {
throw new Error('expected ready data');
}
expect(result.series.map((series) => series.sizes)).toStrictEqual([
[null],
[0.2],
]);
expect(result.drawnGroups).toBe(2);
});
it('asks for a second value when the query has one aggregation', () => {
const result = prepareScatterPlotData({
table: makeTable(
[group('service.name'), value('A')],
[{ 'service.name': 'cart', A: 1 }],
),
dimensions: undefined,
columnUnits: {},
});
expect(result).toStrictEqual({
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: 1,
});
});
it('asks for a second value when there is no table yet', () => {
expect(
prepareScatterPlotData({
table: undefined,
dimensions: undefined,
columnUnits: {},
}).status,
).toBe(ScatterPlotDataStatus.NeedsSecondValue);
});
});

View File

@@ -0,0 +1,11 @@
.footer {
flex-shrink: 0;
padding: 4px 8px;
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,15 @@
import styles from './ScatterPlotFooter.module.scss';
interface ScatterPlotFooterProps {
text: string;
}
function ScatterPlotFooter({ text }: ScatterPlotFooterProps): JSX.Element {
return (
<div className={styles.footer} title={text} data-testid="scatter-plot-footer">
{text}
</div>
);
}
export default ScatterPlotFooter;

View File

@@ -0,0 +1,55 @@
import { ChartScatter } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ScatterPlotPanel'> = {
kind: 'signoz/ScatterPlotPanel',
displayName: 'Scatter Plot',
mode: 'query',
icon: ChartScatter,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
queryBuilderFields: {
legend_format: {
state: 'hidden',
},
},
// Same request as Table: one joined row per group, so x and y can come from
// different queries.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,
clone: true,
// Every group of the joined table, plotted or not.
download: { csv: true, png: true, svg: true },
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -0,0 +1,64 @@
import { type ScatterPlotData, ScatterPlotDataStatus } from './types';
export interface ScatterPlotMessage {
title: string;
description: string;
}
function pluralizeGroups(count: number): string {
return count === 1 ? 'group' : 'groups';
}
function logAxisNote(count: number): string {
return `${count} ${pluralizeGroups(count)} with a value ≤ 0 can't go on a log axis`;
}
/**
* Why a query that returned groups still plots nothing. Undefined when the
* query returned no groups (the plain no-data state) or something plots.
*/
export function getScatterPlotEmptyMessage(
data: ScatterPlotData,
): ScatterPlotMessage | undefined {
if (data.totalGroups === 0) {
return undefined;
}
if (data.status === ScatterPlotDataStatus.NeedsSecondValue) {
return {
title: 'Add a second aggregation or query',
description:
'Each dot needs two values, one for X and one for Y. Add an aggregation to this query, or another query grouped by the same labels.',
};
}
if (data.drawnGroups > 0) {
return undefined;
}
const title = `0 of ${data.totalGroups} ${pluralizeGroups(data.totalGroups)} plotted`;
if (data.missingValueGroups === 0) {
return {
title,
description: `${logAxisNote(data.nonPositiveOnLogGroups)}. Switch the axis to symlog to show them.`,
};
}
return {
title,
description:
'No group has both an X and a Y value. Group every query by exactly the same labels; a group missing from one query has no dot.',
};
}
/** "Showing N of M groups", with the log-axis share called out. Undefined when every group plots. */
export function getScatterPlotFooterText(
data: ScatterPlotData,
): string | undefined {
if (
data.status !== ScatterPlotDataStatus.Ready ||
data.drawnGroups === data.totalGroups
) {
return undefined;
}
const showing = `Showing ${data.drawnGroups} of ${data.totalGroups} groups`;
return data.nonPositiveOnLogGroups > 0
? `${showing} · ${logAxisNote(data.nonPositiveOnLogGroups)}`
: showing;
}

View File

@@ -0,0 +1,35 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
DEFAULT_SCATTER_POINT_SIZE,
type ScatterPointSize,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
/** Bounds the spec validates point sizes (px) and opacity against. */
export const POINT_SIZE_BOUNDS = { min: 2, max: 40 } as const;
export const POINT_OPACITY_BOUNDS = { min: 0.1, max: 1 } as const;
export const DEFAULT_POINT_OPACITY = 0.7;
/** What an unset `points` draws as, spelled out for the editor's change tracking. */
export const DEFAULT_POINTS: Required<DashboardtypesScatterPlotPointsDTO> = {
size: DEFAULT_SCATTER_POINT_SIZE.fixed,
minSize: DEFAULT_SCATTER_POINT_SIZE.min,
maxSize: DEFAULT_SCATTER_POINT_SIZE.max,
opacity: DEFAULT_POINT_OPACITY,
};
export function resolvePointSize(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): ScatterPointSize {
return {
fixed: points?.size ?? DEFAULT_SCATTER_POINT_SIZE.fixed,
min: points?.minSize ?? DEFAULT_SCATTER_POINT_SIZE.min,
max: points?.maxSize ?? DEFAULT_SCATTER_POINT_SIZE.max,
};
}
export function resolvePointOpacity(
points: DashboardtypesScatterPlotPointsDTO | undefined,
): number {
return points?.opacity ?? DEFAULT_POINT_OPACITY;
}

View File

@@ -0,0 +1,181 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type {
PanelTableColumn,
PanelTableRow,
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { getColumnUnit } from '../../utils/getColumnUnit';
import { getValueColumnLabels } from '../../utils/getValueColumnLabels';
import {
type PrepareScatterPlotDataArgs,
type ScatterPlotData,
ScatterPlotDataStatus,
} from './types';
/** Series label when the query has no group by to colour by. */
export const UNGROUPED_SERIES_LABEL = 'All';
/**
* A finite number, or null. The backend sends NaN/±Inf as the strings
* `"NaN"`/`"Inf"`/`"-Inf"` and fills join misses with `"n/a"`; none of those
* may plot, least of all as 0.
*/
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function toLabelValue(value: unknown): string {
return typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
? String(value)
: '';
}
function findColumn(
columns: PanelTableColumn[],
key: string | undefined,
): PanelTableColumn | undefined {
return key ? columns.find((column) => column.id === key) : undefined;
}
function isPlaceable(
value: number,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return axis?.scale !== DashboardtypesAxisScaleDTO.log || value > 0;
}
function getPointLabels(
row: PanelTableRow,
groupColumns: PanelTableColumn[],
): ScatterPointLabel[] {
return groupColumns.map((column) => ({
key: column.name,
value: toLabelValue(row.data[column.id]),
}));
}
/** Joins the values of a dot's colour keys into its series label. */
export const COLOR_LABEL_SEPARATOR = ', ';
/**
* The selected colour keys the result has; every group-by column when none
* is selected or none of the selection is left.
*/
function resolveColorColumns(
groupColumns: PanelTableColumn[],
keys: string[] | null | undefined,
): PanelTableColumn[] {
const selected = groupColumns.filter((column) => keys?.includes(column.id));
return selected.length > 0 ? selected : groupColumns;
}
/**
* One dot per row of the joined scalar table. Unset or stale dimensions fall back
* to the first two value columns and every group-by label.
*/
export function prepareScatterPlotData({
table,
dimensions,
axes,
columnUnits,
}: PrepareScatterPlotDataArgs): ScatterPlotData {
const columns = table?.columns ?? [];
const rows = table?.rows ?? [];
const valueColumns = columns.filter((column) => column.isValueColumn);
const groupColumns = columns.filter((column) => !column.isValueColumn);
const xColumn = findColumn(valueColumns, dimensions?.x) ?? valueColumns[0];
const yColumn =
findColumn(valueColumns, dimensions?.y) ??
valueColumns.find((column) => column !== xColumn);
if (!xColumn || !yColumn) {
return {
status: ScatterPlotDataStatus.NeedsSecondValue,
totalGroups: rows.length,
};
}
const sizeColumn = findColumn(valueColumns, dimensions?.size);
const colorColumns = resolveColorColumns(groupColumns, dimensions?.color);
const seriesByLabel = new Map<
string,
{ series: ScatterSeries; labels: ScatterPointLabel[][] }
>();
let drawnGroups = 0;
let missingValueGroups = 0;
let nonPositiveOnLogGroups = 0;
rows.forEach((row) => {
const x = toFiniteNumber(row.data[xColumn.id]);
const y = toFiniteNumber(row.data[yColumn.id]);
if (x === null || y === null) {
missingValueGroups += 1;
return;
}
if (!isPlaceable(x, axes?.x) || !isPlaceable(y, axes?.y)) {
nonPositiveOnLogGroups += 1;
return;
}
const label =
colorColumns.length > 0
? colorColumns
.map((column) => toLabelValue(row.data[column.id]))
.join(COLOR_LABEL_SEPARATOR)
: UNGROUPED_SERIES_LABEL;
let entry = seriesByLabel.get(label);
if (!entry) {
entry = {
series: { label, xs: [], ys: [], ...(sizeColumn && { sizes: [] }) },
labels: [],
};
seriesByLabel.set(label, entry);
}
entry.series.xs.push(x);
entry.series.ys.push(y);
entry.series.sizes?.push(
sizeColumn ? toFiniteNumber(row.data[sizeColumn.id]) : null,
);
entry.labels.push(getPointLabels(row, groupColumns));
drawnGroups += 1;
});
const entries = [...seriesByLabel.values()];
const labels = getValueColumnLabels(columns);
const toChannel = (
column: PanelTableColumn,
axisLabel?: string,
): { label: string; unit: string | undefined } => ({
label: axisLabel || (labels[column.id] ?? column.name),
unit: getColumnUnit(column.id, columnUnits),
});
return {
status: ScatterPlotDataStatus.Ready,
series: entries.map((entry) => entry.series),
pointLabels: entries.map((entry) => entry.labels),
channels: {
x: toChannel(xColumn, axes?.x?.label),
y: toChannel(yColumn, axes?.y?.label),
...(sizeColumn && { size: toChannel(sizeColumn) }),
},
axisQueries: { x: xColumn.queryName, y: yColumn.queryName },
totalGroups: rows.length,
drawnGroups,
missingValueGroups,
nonPositiveOnLogGroups,
};
}

View File

@@ -0,0 +1,29 @@
import { resolveScatterLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
type SectionConfig,
} from '../../types/sections';
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Dimensions },
{ kind: SectionKind.ScatterAxes },
{ kind: SectionKind.ChartAppearance, controls: { points: true } },
{
kind: SectionKind.Formatting,
controls: { decimals: true, columnUnits: true },
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveScatterLegendSeries },
},
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
},
{ kind: SectionKind.ContextLinks },
];

View File

@@ -0,0 +1,49 @@
import type {
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ScatterSeries } from 'lib/visualization/charts/Scatter/utils';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import type { PanelTable } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
export interface PrepareScatterPlotDataArgs {
/** The joined scalar table (`formatTableResultForUI`). */
table: PanelTable | undefined;
dimensions: DashboardtypesScatterPlotDimensionsDTO | undefined;
/** A `log` axis drops values ≤ 0, which it cannot place. */
axes?: DashboardtypesScatterPlotAxesDTO;
columnUnits: Record<string, string>;
}
export enum ScatterPlotDataStatus {
Ready = 'ready',
/** Fewer than two value columns: nothing to put on y. */
NeedsSecondValue = 'needsSecondValue',
}
export type ScatterPlotData =
| {
status: ScatterPlotDataStatus.NeedsSecondValue;
totalGroups: number;
}
| {
status: ScatterPlotDataStatus.Ready;
/** One per colour group. */
series: ScatterSeries[];
/** `[seriesIndex][dataIndex]`: the point's group-by labels. */
pointLabels: ScatterPointLabel[][][];
channels: ScatterChannels;
/** The query each axis's column comes from, for drilldown. */
axisQueries: { x: string; y: string };
/** Rows the query returned. */
totalGroups: number;
/** Rows plotted. */
drawnGroups: number;
/** Rows missing a finite x or y, e.g. a group absent from one query. */
missingValueGroups: number;
/** Rows with a value ≤ 0 on a `log` axis. */
nonPositiveOnLogGroups: number;
};

View File

@@ -0,0 +1,28 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
type ScatterAxisOptions,
ScatterAxisScale,
} from 'lib/visualization/charts/Scatter/utils';
const CHART_SCALE: Record<DashboardtypesAxisScaleDTO, ScatterAxisScale> = {
[DashboardtypesAxisScaleDTO.auto]: ScatterAxisScale.Auto,
[DashboardtypesAxisScaleDTO.linear]: ScatterAxisScale.Linear,
[DashboardtypesAxisScaleDTO.log]: ScatterAxisScale.Log,
[DashboardtypesAxisScaleDTO.symlog]: ScatterAxisScale.SymLog,
};
export function toScatterAxisOptions(
axis: DashboardtypesScatterPlotAxisDTO | undefined,
unit: string | undefined,
): ScatterAxisOptions {
return {
label: axis?.label,
unit,
softMin: axis?.softMin,
softMax: axis?.softMax,
scale: axis?.scale ? CHART_SCALE[axis.scale] : ScatterAxisScale.Auto,
};
}

View File

@@ -79,6 +79,29 @@ describe('buildTableCsvRows', () => {
});
});
describe('buildTableCsvRows headers', () => {
it('keeps both of two columns that share a name, under their query labels', () => {
const rows = buildTableCsvRows({
table: {
queryName: 'A',
legend: '',
columns: [
{ name: 'service', queryName: '', isValueColumn: false, id: 'service' },
{ name: 'count()', queryName: 'A', isValueColumn: true, id: 'A.count()' },
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A.p99' },
{ name: 'count()', queryName: 'B', isValueColumn: true, id: 'B' },
],
rows: [{ data: { service: 'cart', 'A.count()': 120, 'A.p99': 4, B: 3 } }],
},
columnUnits: {},
});
expect(rows).toStrictEqual([
{ service: 'cart', 'A.count()': '120', p99: '4', 'B.count()': '3' },
]);
});
});
describe('getTableCsvRows', () => {
const panel = {
spec: { plugin: { spec: { formatting: { columnUnits: { A: 'ms' } } } } },
@@ -96,6 +119,23 @@ describe('getTableCsvRows', () => {
]);
});
it('exports a scatter plot from the same joined table', () => {
mockPrepareScalarTables.mockReturnValue([table]);
const scatter = {
spec: {
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { formatting: { columnUnits: { A: 'ns' } } },
},
},
} as unknown as PanelOfKind<'signoz/ScatterPlotPanel'>;
expect(getTableCsvRows(scatter, data)).toStrictEqual([
{ service: 'frontend', p99: '1234ns' },
{ service: 'cart', p99: '56ns' },
]);
});
it('returns no rows when the response has no table', () => {
mockPrepareScalarTables.mockReturnValue([]);

Some files were not shown because too many files have changed in this diff Show More