Compare commits

...

43 Commits

Author SHA1 Message Date
Abhi Kumar
f2cef094a1 feat(dashboards): allow formulas and trace operators in top list panels 2026-10-08 23:08:51 +05:30
Abhi Kumar
5a02fb10fd chore(openapi): regenerate specs 2026-10-08 17:55:19 +05:30
Abhi Kumar
51bb0d0617 feat(dashboards): add plugin schema for top list panel 2026-10-08 17:55:05 +05:30
Abhi Kumar
4b1b174dac feat(dashboard): use colorBy and sizeBy in the scatter plot, labelled Color by and Size by 2026-10-08 17:52:57 +05:30
Abhi Kumar
6bc1130484 feat(dashboard): explain mismatched group bys in the scatter plot 2026-10-08 17:52:57 +05:30
Abhi Kumar
64728f5468 refactor(dashboard): move scatter plot panel helpers into utils 2026-10-08 17:52:57 +05:30
Abhi Kumar
4be7cb6c97 style(charts): match scatter tooltip spacing and marker to the heatmap tooltip and legend 2026-10-08 17:52:57 +05:30
Abhi Kumar
9db0f5b0c7 fix(charts): drop the scatter tooltip size row when it repeats x or y 2026-10-08 17:52:56 +05:30
Abhi Kumar
bdabda9528 feat(dashboard): sync scatter plot group by across queries
Panel kinds declare which builder fields to keep equal across queries. A query
added or switched to another signal takes the others' values, since a signal
switch reloads its own.
2026-10-08 17:52:56 +05:30
Abhi Kumar
064f0bccda feat(dashboard): warn when a scatter plot dimension isn't in the results 2026-10-08 17:52:56 +05:30
Abhi Kumar
8712b747a4 feat(query-builder): show the scatter plot's default limit as the limit placeholder 2026-10-08 17:52:56 +05:30
Abhi Kumar
c49fad6bc7 feat(dashboard): move scatter plot notes from the footer into the panel warning 2026-10-08 17:52:56 +05:30
Abhi Kumar
45acfc88cc fix(dashboard): limit scatter plot queries to 10,000 groups on the server
Only a lone builder query gets the default: per-query limits on several
queries can keep different groups and break the join.
2026-10-08 17:52:56 +05:30
Abhi Kumar
55fcb854af fix(dashboard): cap the scatter plot at 10,000 dots
A canvas path of a few hundred thousand dots fails to paint and takes the later series with it.

Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
04a43b3860 fix(dashboard): redraw the scatter plot after an empty state
The chart area stays mounted, since the size observer binds to its node only once.

Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
140d39aa36 refactor(dashboard): build scatter plot axes from the shared axis fields
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
54baa41199 feat(dashboard): lay out scatter plot axes with label, range and scale rows
Range help says when a log axis ignores a bound at or below zero.

Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
7107dd6e8c feat(dashboard): restyle scatter plot dimensions with field info and auto labels
Colour is a multi-select of group-by keys; Auto options name the column they resolve to.

Assisted-by: Claude Opus 5.5
2026-10-08 17:52:56 +05:30
Abhi Kumar
6a9a330d9c 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-08 17:52:56 +05:30
Abhi Kumar
40f415170d 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-08 17:52:56 +05:30
Abhi Kumar
5270b7c208 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-08 17:52:56 +05:30
Abhi Kumar
0fd6e84df4 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-08 17:52:55 +05:30
Abhi Kumar
4104696846 fix(dashboard): carry builder fields on a switch to scatter plot
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:55 +05:30
Abhi Kumar
95d12f5458 fix(dashboard): keep column units across a Table/Scatter Plot switch
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:55 +05:30
Abhi Kumar
49f98057eb 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-08 17:52:55 +05:30
Abhi Kumar
c64ef3f3d9 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-08 17:52:55 +05:30
Abhi Kumar
44c807cacc fix(charts): restore the default log axis grid
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:49 +05:30
Abhi Kumar
62107fbb14 fix(dashboard): honour scatter plot soft limits on every axis scale
Only Auto weighs the soft limits when choosing a scale; an explicit scale keeps its choice.

Assisted-by: Claude Opus 5.5
2026-10-08 17:52:49 +05:30
Abhi Kumar
526c2c7d29 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-08 17:52:49 +05:30
Abhi Kumar
927b46a5b7 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-08 17:52:49 +05:30
Abhi Kumar
3cbc0f4601 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-08 17:52:49 +05:30
Abhi Kumar
47d38289f4 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-08 17:52:49 +05:30
Abhi Kumar
e334c48735 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-08 17:52:49 +05:30
Abhi Kumar
a7e8a9e19c 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-08 17:52:48 +05:30
Abhi Kumar
dfcad79e79 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-08 17:52:48 +05:30
Abhi Kumar
befac14f47 chore: scatter plot base work 2026-10-08 17:52:48 +05:30
Abhi Kumar
3de4d6eaea feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-08 17:52:05 +05:30
Abhi Kumar
e18afc0178 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
2bac73ee5c 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-08 17:52:05 +05:30
Abhi Kumar
552f35e5bb chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
9eba4a794f 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-08 17:52:05 +05:30
Abhi Kumar
8a0c6c9911 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
Abhi Kumar
d3662e66c7 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-08 17:52:05 +05:30
147 changed files with 8254 additions and 114 deletions

View File

@@ -3425,6 +3425,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4167,9 +4174,11 @@ 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'
signoz/TopListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4182,6 +4191,8 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4195,6 +4206,8 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
- signoz/TopListPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4280,6 +4293,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:
@@ -4316,6 +4341,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTopListPanelSpec:
properties:
kind:
enum:
- signoz/TopListPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTopListPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelSpec:
properties:
display:
@@ -4359,6 +4396,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:
@@ -4532,6 +4579,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:
colorBy:
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
sizeBy:
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
@@ -4716,6 +4844,18 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesTopListPanelSpec:
properties:
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesComparisonThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:

View File

@@ -57,6 +57,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(
@@ -70,6 +71,7 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -4372,6 +4372,12 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5372,6 +5378,101 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
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.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: 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
@@ -5382,7 +5483,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6309,6 +6411,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'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';
@@ -278,6 +279,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

@@ -518,7 +518,10 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder="Enter limit"
placeholder={
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
'Enter limit'
}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>

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

@@ -9,6 +9,7 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -169,6 +170,33 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('shows the configured limit placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.SCATTER}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'defaulted',
placeholder: 'Default 10,000',
},
}}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10,000',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

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

@@ -57,6 +57,19 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('keeps a defaulted field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -22,8 +22,12 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
*/
export type QueryBuilderFieldRule =
| { state: 'defaulted'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };

View File

@@ -10,6 +10,7 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -20,6 +21,8 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'defaulted':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':

View File

@@ -36,6 +36,7 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// 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',
HEATMAP = 'heatmap',

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,
HEATMAP: 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';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
@@ -149,6 +150,7 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_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,105 @@
// 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 {
// Same inset as the heatmap tooltip.
--gutter: 14px;
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: 220px;
&.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-3);
padding: var(--spacing-6) var(--gutter);
}
// Matches the legend row's marker.
.marker {
box-sizing: border-box;
width: 12px;
height: 12px;
border: 1.5px solid;
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-2);
padding: var(--spacing-5) var(--gutter);
}
.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,114 @@
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, borderColor: 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,107 @@
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);
});
it('leaves out a size row that repeats the x row', () => {
const rows = buildChannelRows(
{ ...point, size: point.x },
{
x: { label: 'A.count()' },
y: { label: 'p99' },
size: { label: 'A.count()' },
},
);
expect(rows.map((row) => row.label)).toStrictEqual(['A.count()', 'p99']);
});
});

View File

@@ -0,0 +1,98 @@
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) {
const sizeRow = {
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
};
// Size bound to the X or Y column would only repeat that row.
if (
!rows.some(
(row) => row.label === sizeRow.label && row.value === sizeRow.value,
)
) {
rows.push(sizeRow);
}
}
return rows;
}

View File

@@ -6,6 +6,10 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -119,6 +123,17 @@ export interface HeatmapTooltipProps
groupColor: string;
}
/** 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

@@ -39,6 +39,16 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** 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 +68,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 +128,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
? this.buildValueAxisFormatter()
: undefined;
}
@@ -149,10 +176,12 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
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();
@@ -177,6 +206,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -189,6 +221,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
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

@@ -99,6 +99,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

@@ -120,6 +120,35 @@ describe('UPlotAxisBuilder', () => {
});
});
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('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(
@@ -376,3 +405,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,8 +78,12 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
/** 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. */
@@ -90,7 +94,8 @@ export interface AxisProps {
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
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
@@ -109,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 {
@@ -125,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 {
@@ -146,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 {
@@ -229,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,59 @@
import type uPlot from 'uplot';
import { logScaleSplits, spacedLogLabels } from '../logGridSplits';
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,69 @@
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;
}
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,
@@ -123,6 +124,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,217 @@
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('keeps an explicit log axis for a soft min it cannot place', () => {
expect(
resolveAxisDistribution([1, 100], ScatterAxisScale.Log, [-10]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
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('stays linear when a soft limit reaches zero or below', () => {
expect(
resolveAxisDistribution([0.02, 5000], ScatterAxisScale.Auto, [-10]),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('counts a soft limit towards the decades it spans', () => {
expect(
resolveAxisDistribution([5, 50], ScatterAxisScale.Auto, [null, 5000]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
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,328 @@
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,
/**
* Soft limits (and thresholds) the axis must reach. Only `auto` weighs them:
* a soft min at or below zero rules out log. An explicit scale keeps its
* choice, and a log axis drops a bound it cannot place.
*/
softLimits: Array<number | null | undefined> = [],
): AxisDistribution {
const { minPositive, 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 extent = getPositiveRange([
...values,
...softLimits.filter((limit): limit is number => limit != null),
]);
const spansDecades =
Number.isFinite(extent.minPositive) &&
Math.log10(extent.maxPositive / extent.minPositive) >= AUTO_LOG_MIN_DECADES;
return !extent.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 ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
thresholds,
y.unit,
);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
[x.softMin, x.softMax],
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
[ySoftLimits.softMin, ySoftLimits.softMax],
);
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;
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

@@ -16,6 +16,10 @@ import type {
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -88,6 +92,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;
}
/**
* Data arrives as the query response carries it — bucket bounds plus one series per
* group — and the chart pivots and sums it, so no caller has to get the transpose

View File

@@ -40,6 +40,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;
/**
@@ -67,6 +69,7 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -136,6 +139,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -163,6 +167,7 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,6 +12,8 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isDifferent } from '../utils/changes';
import { getScatterAxisColumnNames } from '../utils/scatterAxisColumns';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -63,6 +65,7 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -74,7 +77,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);
@@ -129,6 +135,9 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -0,0 +1,12 @@
.select {
width: 100%;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
:global(.ant-select-selection-item) {
font-family: 'Geist Mono', monospace;
font-size: 12px;
}
}

View File

@@ -0,0 +1,57 @@
import { Select } from 'antd';
import styles from './ConfigMultiSelect.module.scss';
export interface ConfigMultiSelectItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigMultiSelectProps<T extends string = string> {
testId: string;
value: T[];
/** Shown while nothing is selected, which is a valid choice. */
placeholder?: string;
items: ConfigMultiSelectItem<T>[];
onChange: (value: T[]) => void;
disabled?: boolean;
'aria-label'?: string;
}
/**
* Multi-select for the config sections; the picks show as tags. The order of
* `items` is kept rather than the order they were picked in, so the same set
* always reads (and is stored) the same way.
*/
function ConfigMultiSelect<T extends string = string>({
testId,
value,
placeholder,
items,
onChange,
disabled,
'aria-label': ariaLabel,
}: ConfigMultiSelectProps<T>): JSX.Element {
return (
<Select<T[]>
mode="multiple"
className={styles.select}
data-testid={testId}
aria-label={ariaLabel}
value={value}
placeholder={placeholder}
disabled={disabled}
virtual={false}
options={items}
onChange={(next): void =>
onChange(
items
.map((item) => item.value)
.filter((candidate) => next.includes(candidate)),
)
}
/>
);
}
export default ConfigMultiSelect;

View File

@@ -0,0 +1,52 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigMultiSelect from '../ConfigMultiSelect';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
async function open(): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
await user.click(
screen
.getByTestId('multi')
.querySelector('.ant-select-selector') as HTMLElement,
);
return user;
}
describe('ConfigMultiSelect', () => {
it('shows the placeholder while nothing is picked', () => {
render(
<ConfigMultiSelect
testId="multi"
value={[]}
placeholder="Every group key"
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
});
it('keeps picks in item order, not the order they were picked', async () => {
const onChange = jest.fn();
render(
<ConfigMultiSelect
testId="multi"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
const user = await open();
await user.click(await screen.findByRole('option', { name: 'alpha' }));
expect(onChange).toHaveBeenLastCalledWith(['a', 'c']);
});
});

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

@@ -13,6 +13,12 @@ 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;
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
axisColumnNames?: { x?: string; y?: string };
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -4,6 +4,8 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -22,9 +24,11 @@ import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -86,6 +90,20 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
// 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),
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>

View File

@@ -14,6 +14,7 @@ import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import HeatmapColorsField from './HeatmapColorsField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -39,8 +40,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, savedValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -122,6 +124,15 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
savedValue={savedValue?.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/utils/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;
savedValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
/** The column dots are sized by; unset draws every dot at one size. */
sizeColumnLabel?: string;
}
function PointsFields({
value,
savedValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, 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

@@ -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,30 @@
.section {
display: flex;
flex-direction: column;
gap: 18px;
}
.labelWithInfo {
display: inline-flex;
align-items: center;
gap: 6px;
}
.info {
display: inline-flex;
color: var(--l3-foreground);
cursor: help;
}
.infoTooltip {
max-width: 240px;
white-space: normal;
word-break: break-word;
}
.description {
margin: 0;
color: var(--l2-foreground);
font-size: 12px;
line-height: 17px;
}

View File

@@ -0,0 +1,117 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigMultiSelect from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { resolveAutoAxes } from '../../utils/scatterAxisColumns';
import styles from './DimensionsSection.module.scss';
import LabelWithInfo from './LabelWithInfo';
import {
buildColorKeyItems,
buildDimensionItems,
formatAutoOption,
formatColumnOption,
UNSET_DIMENSION,
} from './utils';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const FIELD_INFO = {
x: 'The value column that places each dot left to right. Auto takes the first value column.',
y: "The value column that places each dot bottom to top. Auto takes the first value column X doesn't use.",
sizeBy:
"Optional. Scales each dot's area by this value column; a group without a value draws at the default size.",
colorBy:
'One color and legend entry per combination of the selected keys. Leave it empty to color by every group key.',
};
/**
* 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,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({
value: column.key,
label: formatColumnOption(column),
})),
[tableColumns],
);
const autoAxes = resolveAutoAxes(tableColumns, {
x: value?.x,
y: value?.y,
});
const colorBy = value?.colorBy ?? [];
const colorItems = buildColorKeyItems(groupColumns, colorBy);
const fields = [
{ dimension: 'x', label: 'X axis', unsetLabel: formatAutoOption(autoAxes.x) },
{ dimension: 'y', label: 'Y axis', unsetLabel: formatAutoOption(autoAxes.y) },
{ dimension: 'sizeBy', label: 'Size by', unsetLabel: 'None' },
] as const;
return (
<div className={styles.section}>
{fields.map(({ dimension, label, unsetLabel }) => (
<ConfigField
key={dimension}
label={
<LabelWithInfo
label={label}
info={FIELD_INFO[dimension]}
testId={`panel-editor-v2-dimension-${dimension}-info`}
/>
}
>
<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={
<LabelWithInfo
label="Color by"
info={FIELD_INFO.colorBy}
testId="panel-editor-v2-dimension-colorBy-info"
/>
}
>
<ConfigMultiSelect
testId="panel-editor-v2-dimension-colorBy"
aria-label="Color by"
value={colorBy}
items={colorItems}
placeholder={
colorItems.length > 0 ? 'Every group key' : 'No group-by labels'
}
disabled={colorItems.length === 0}
onChange={(next): void => onChange({ ...value, colorBy: next })}
/>
</ConfigField>
<p className={styles.description}>
Each dot is one group from the query. X and Y place it, Size by scales it,
and Color by groups it in the legend.
</p>
</div>
);
}
export default DimensionsSection;

View File

@@ -0,0 +1,32 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DimensionsSection.module.scss';
interface LabelWithInfoProps {
label: string;
info: string;
testId: string;
}
function LabelWithInfo({
label,
info,
testId,
}: LabelWithInfoProps): JSX.Element {
return (
<span className={styles.labelWithInfo}>
{label}
<TooltipSimple
title={info}
tooltipContentProps={{ className: styles.infoTooltip }}
>
<span className={styles.info} data-testid={testId}>
<Info size={12} />
</span>
</TooltipSimple>
</span>
);
}
export default LabelWithInfo;

View File

@@ -0,0 +1,189 @@
import { render, screen, userEvent } from 'tests/test-utils';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B.p99(duration_nano)', label: 'B.p99 latency', name: 'p99 latency' },
{ key: 'C.count()', label: 'C.count()', name: 'count()' },
];
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('names the column each unset axis falls back to', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto (A · Request rate)',
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (B.p99(duration_nano) · p99 latency)',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-sizeBy'),
).toHaveTextContent('None');
});
it('falls Y back to a column other than the bound X', () => {
render(
<DimensionsSection
value={{ x: 'B.p99(duration_nano)' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (A · Request rate)',
);
});
it('shows a column by its key alone when the name only repeats it', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'C.count()');
expect(onChange).toHaveBeenCalledWith({ sizeBy: 'C.count()' });
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'B.p99(duration_nano) · p99 latency');
expect(onChange).toHaveBeenCalledWith({
x: 'A',
y: 'B.p99(duration_nano)',
});
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ sizeBy: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'None');
expect(onChange).toHaveBeenCalledWith({ sizeBy: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'D' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'D (not in results)',
);
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ colorBy: ['service.name'] }}
onChange={onChange}
groupColumns={['k8s.namespace.name', 'service.name']}
/>,
);
const trigger = screen.getByTestId('panel-editor-v2-dimension-colorBy');
await user.click(
trigger.querySelector('.ant-select-selector') as HTMLElement,
);
await user.click(
await screen.findByRole('option', { name: 'k8s.namespace.name' }),
);
expect(onChange).toHaveBeenLastCalledWith({
colorBy: ['k8s.namespace.name', 'service.name'],
});
});
it('explains every field from an info icon', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
['x', 'y', 'sizeBy', 'colorBy'].forEach((dimension) => {
expect(
screen.getByTestId(`panel-editor-v2-dimension-${dimension}-info`),
).toBeInTheDocument();
});
});
it('says what an empty colour selection means', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy-info'),
).toBeInTheDocument();
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ colorBy: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy'),
).toHaveTextContent('host.name (not in results)');
});
it('disables colour when the query has no group by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByText('No group-by labels')).toBeInTheDocument();
expect(
screen
.getByTestId('panel-editor-v2-dimension-colorBy')
.classList.contains('ant-select-disabled'),
).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import type { ConfigMultiSelectItem } from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/** `A · Request rate`, or the key alone when the name only repeats it (`A.count()`). */
export function formatColumnOption({
key,
name,
}: Pick<TableColumnOption, 'key' | 'name'>): string {
return key === name || key.endsWith(`.${name}`) ? key : `${key} · ${name}`;
}
export function formatAutoOption(
column: TableColumnOption | undefined,
): string {
return column ? `Auto (${formatColumnOption(column)})` : 'Auto';
}
/**
* 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[],
): ConfigMultiSelectItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}

View File

@@ -152,7 +152,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
@@ -166,7 +166,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A' }]}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,

View File

@@ -0,0 +1,10 @@
.axes {
display: flex;
flex-direction: column;
gap: 24px;
}
.axis + .axis {
padding-top: 24px;
border-top: 1px solid var(--l1-border);
}

View File

@@ -0,0 +1,43 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import ScatterAxisFields from './ScatterAxisFields';
import styles from './ScatterAxesSection.module.scss';
type ScatterAxesSectionProps = SectionEditorProps<SectionKind.ScatterAxes> &
Pick<SectionEditorContext, 'axisColumnNames'>;
/** Edits a Scatter Plot's `axes` slice: label, soft bounds and scale, per axis. */
function ScatterAxesSection({
value,
savedValue,
onChange,
axisColumnNames,
}: ScatterAxesSectionProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
return (
<div className={styles.axes}>
{(['x', 'y'] as const).map((axis) => (
<div key={axis} className={styles.axis}>
<ConfigField label={`${axis.toUpperCase()} axis`} {...reset(axis)}>
<ScatterAxisFields
axis={axis}
value={value?.[axis]}
columnName={axisColumnNames?.[axis]}
onChange={(next): void => onChange({ ...value, [axis]: next })}
/>
</ConfigField>
</div>
))}
</div>
);
}
export default ScatterAxesSection;

View File

@@ -0,0 +1,65 @@
import { Input } from 'antd';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
import AxisFields from '../AxesSection/AxisFields';
import AxisRangeField from '../AxesSection/AxisRangeField';
import AxisScaleField from '../AxesSection/AxisScaleField';
import { LOG_RANGE_IGNORED_HELP, SCALE_HELP, SCALE_OPTIONS } from './options';
import { hasBoundALogAxisDrops } from './utils';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
/** The name of the column the axis plots, shown until a label is typed. */
columnName?: string;
}
function ScatterAxisFields({
axis,
value,
onChange,
columnName,
}: ScatterAxisFieldsProps): JSX.Element {
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
const name = axis.toUpperCase();
return (
<AxisFields>
<ConfigInlineField label="Label">
<Input
data-testid={`panel-editor-v2-${axis}-label`}
aria-label={`${name}-axis label`}
placeholder={columnName ?? 'None'}
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigInlineField>
<AxisRangeField
testIdPrefix={`panel-editor-v2-${axis}`}
value={value}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
help={
hasBoundALogAxisDrops(scale, value) ? LOG_RANGE_IGNORED_HELP : undefined
}
helpTestId={`panel-editor-v2-${axis}-range-help`}
/>
<AxisScaleField
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
help={SCALE_HELP}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</AxisFields>
);
}
export default ScatterAxisFields;

View File

@@ -0,0 +1,171 @@
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('shows the plotted column as each label placeholder', () => {
render(
<ScatterAxesSection
value={undefined}
onChange={jest.fn()}
axisColumnNames={{ x: 'Request rate', y: 'p99 latency' }}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-label')).toHaveAttribute(
'placeholder',
'Request rate',
);
expect(screen.getByTestId('panel-editor-v2-y-label')).toHaveAttribute(
'placeholder',
'p99 latency',
);
});
it('resets one axis without touching the other', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, label: 'Throughput' },
y: { scale: DashboardtypesAxisScaleDTO.symlog },
}}
savedValue={{}}
onChange={onChange}
/>,
);
const [resetX] = screen.getAllByTestId('config-field-reset');
await user.click(resetX);
expect(onChange).toHaveBeenCalledWith({
x: undefined,
y: { scale: DashboardtypesAxisScaleDTO.symlog },
});
});
it('says a log axis ignores a bound at or below zero', () => {
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, softMin: -10 },
y: { scale: DashboardtypesAxisScaleDTO.symlog, softMin: -10 },
}}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"A log axis can't reach 0 or below",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
it('flags a soft min above the soft max on that axis only', () => {
render(
<ScatterAxesSection
value={{ x: { softMin: 23, softMax: 12 }, y: { softMin: 1, softMax: 2 } }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"Min can't be greater than Max.",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
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 saved value and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
savedValue={{ 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,23 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import {
pickScaleOptions,
SCALE_HELP as AXIS_SCALE_HELP,
} from '../AxesSection/options';
export const SCALE_OPTIONS = pickScaleOptions([
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
]);
export const LOG_RANGE_IGNORED_HELP =
"A log axis can't reach 0 or below, so that bound is ignored. Use Symlog to include it.";
/** A log axis leaves out groups it can't place, rather than drawing them elsewhere. */
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
...AXIS_SCALE_HELP,
[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.",
};

View File

@@ -0,0 +1,17 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
/** A log axis cannot place a soft bound at or below zero, so it drops it. */
export function hasBoundALogAxisDrops(
scale: DashboardtypesAxisScaleDTO,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return (
scale === DashboardtypesAxisScaleDTO.log &&
[axis?.softMin, axis?.softMax].some(
(bound) => typeof bound === 'number' && bound <= 0,
)
);
}

View File

@@ -0,0 +1,52 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getScatterAxisColumnNames } from '../scatterAxisColumns';
const COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B', label: 'B.p99 latency', name: 'p99 latency' },
{ key: 'C', label: 'C.Errors', name: 'Errors' },
];
const scatterSpec = (dimensions: {
x?: string;
y?: string;
}): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
describe('getScatterAxisColumnNames', () => {
it('names the first two value columns when nothing is bound', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), COLUMNS)).toStrictEqual({
x: 'Request rate',
y: 'p99 latency',
});
});
it('follows the bound columns, with Y avoiding the column X took', () => {
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'A', y: 'C' }), COLUMNS),
).toStrictEqual({ x: 'Request rate', y: 'Errors' });
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'B' }), COLUMNS),
).toStrictEqual({ x: 'p99 latency', y: 'Request rate' });
});
it('is empty before results load and for other kinds', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), [])).toStrictEqual({
x: undefined,
y: undefined,
});
expect(
getScatterAxisColumnNames(
{
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO,
COLUMNS,
),
).toStrictEqual({});
});
});

View File

@@ -0,0 +1,41 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getSizeColumnLabel } from '../sizeColumnLabel';
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
({
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { dimensions: { sizeBy: size } },
},
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
const COLUMNS = [{ key: 'B', label: 'B.count()', name: '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,33 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* The columns a Scatter Plot's axes plot, as the renderer picks them: a bound
* key, else X takes the first value column and Y the first one X did not take.
*/
export function resolveAutoAxes(
columns: TableColumnOption[],
bound: { x?: string; y?: string },
): { x?: TableColumnOption; y?: TableColumnOption } {
const x = columns.find((column) => column.key === bound.x) ?? columns[0];
const y =
columns.find((column) => column.key === bound.y) ??
columns.find((column) => column !== x);
return { x, y };
}
/** The name of the column each axis plots; unset for other kinds or before results load. */
export function getScatterAxisColumnNames(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): { x?: string; y?: string } {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return {};
}
const { x, y } = resolveAutoAxes(tableColumns, {
x: spec.plugin.spec.dimensions?.x,
y: spec.plugin.spec.dimensions?.y,
});
return { x: x?.name, y: y?.name };
}

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?.sizeBy;
if (!key) {
return undefined;
}
if (tableColumns.length === 0) {
return key;
}
return tableColumns.find((column) => column.key === key)?.label;
}

View File

@@ -24,6 +24,7 @@ import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDef
import { toPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
import { useSyncQueryBuilderFields } from './useSyncQueryBuilderFields';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
@@ -62,6 +63,7 @@ function PanelEditorQueryBuilder({
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {

View File

@@ -14,6 +14,9 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
jest.mock('../useSyncQueryBuilderFields', () => ({
useSyncQueryBuilderFields: jest.fn(),
}));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);

View File

@@ -0,0 +1,112 @@
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
import {
snapshotQueries,
syncQueryBuilderFields,
} from '../syncQueryBuilderFields';
const key = (name: string): BaseAutocompleteData =>
({ key: name }) as BaseAutocompleteData;
function query(
queryName: string,
{
groupBy = [],
limit = null,
dataSource = 'traces',
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
): IBuilderQuery {
return {
queryName,
groupBy: groupBy.map(key),
limit,
dataSource,
} as IBuilderQuery;
}
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
describe('syncQueryBuilderFields', () => {
it('copies an edited field to the other queries', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('gives a new query the existing values', () => {
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it("gives a query switched to another signal the others' values, not its reset ones", () => {
const previous = snapshotQueries([
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('leaves values that already differ until one is edited', () => {
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
expect(
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
).toStrictEqual([]);
});
it('syncs each field from its own source', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
previous,
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
),
).toStrictEqual([
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
]);
});
it('leaves fields it is not asked to sync', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { limit: 50 }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,62 @@
import { renderHook } from '@testing-library/react';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
const mockHandleSetQueryData = jest.fn();
let mockQueries: IBuilderQuery[] = [];
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: { builder: { queryData: mockQueries } },
handleSetQueryData: mockHandleSetQueryData,
}),
}));
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
({
queryName,
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
dataSource: 'traces',
}) as IBuilderQuery;
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
function setup(
fields: SyncedQueryBuilderField[] | undefined,
initial: IBuilderQuery[],
): (next: IBuilderQuery[]) => void {
mockQueries = initial;
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
return (next): void => {
mockQueries = next;
rerender();
};
}
describe('useSyncQueryBuilderFields', () => {
beforeEach(() => mockHandleSetQueryData.mockClear());
it('writes the synced value into the other queries', () => {
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
1,
query('B', ['service.name']),
);
});
it('does nothing when the kind syncs no fields', () => {
const update = setup(undefined, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,72 @@
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import { isEqual } from 'lodash-es';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
const QUERY_PROPERTY = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Having]: 'having',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.StepInterval]: 'stepInterval',
[QueryBuilderField.ReduceTo]: 'reduceTo',
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
export function snapshotQueries(
queries: IBuilderQuery[],
): Map<string, IBuilderQuery> {
return new Map(queries.map((query) => [query.queryName, query]));
}
/**
* The query each field should be copied from: the one whose value just
* changed, or any settled query when one was added or switched signal. A
* signal switch reloads that query's own state, so it is never the source.
*/
function findSource(
property: keyof IBuilderQuery,
settled: IBuilderQuery[],
hasNewcomer: boolean,
previous: Map<string, IBuilderQuery>,
): IBuilderQuery | undefined {
const changed = settled.find(
(query) =>
!isEqual(previous.get(query.queryName)?.[property], query[property]),
);
return changed ?? (hasNewcomer ? settled[0] : undefined);
}
/** The queries that need rewriting so every synced field matches its source. */
export function syncQueryBuilderFields(
queries: IBuilderQuery[],
previous: Map<string, IBuilderQuery>,
fields: SyncedQueryBuilderField[],
): { index: number; query: IBuilderQuery }[] {
const settled = queries.filter((query) => {
const before = previous.get(query.queryName);
return (
before?.dataSource === query.dataSource && before.source === query.source
);
});
const hasNewcomer = settled.length < queries.length;
const sources = fields
.map((field) => QUERY_PROPERTY[field])
.map((property) => ({
property,
source: findSource(property, settled, hasNewcomer, previous),
}));
return queries.flatMap((query, index) => {
const patch = sources.reduce<Partial<IBuilderQuery>>(
(acc, { property, source }) =>
source && !isEqual(query[property], source[property])
? { ...acc, [property]: source[property] }
: acc,
{},
);
return Object.keys(patch).length > 0
? [{ index, query: { ...query, ...patch } }]
: [];
});
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
import {
snapshotQueries,
syncQueryBuilderFields,
} from './syncQueryBuilderFields';
const NO_FIELDS: SyncedQueryBuilderField[] = [];
/**
* Keeps `fields` equal across builder queries: an edit to one query copies to
* the rest, and a query added or switched to another signal takes the others'
* values. Values that already differ when this starts are left alone until one
* of them is edited.
*/
export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const queries = currentQuery.builder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;
useEffect(() => {
const previous = previousRef.current;
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
if (!enabled || !previous || queries.length < 2) {
return;
}
syncQueryBuilderFields(queries, previous, fields).forEach(
({ index, query }) => handleSetQueryData(index, query),
);
}, [enabled, fields, queries, handleSetQueryData]);
}

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
@@ -326,6 +328,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,17 @@ const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const scatterPanel = (colorBy?: string[]): DashboardtypesPanelDTO =>
({
kind: 'Panel',
spec: {
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { dimensions: { colorBy } },
},
queries: [],
},
}) as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -133,6 +146,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

@@ -39,8 +39,8 @@ function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
}
const COLUMNS: TableColumnOption[] = [
{ key: 'A', label: 'A' },
{ key: 'B', label: 'B' },
{ key: 'A', label: 'A', name: 'A' },
{ key: 'B', label: 'B', name: 'B' },
];
const NO_COLUMNS: TableColumnOption[] = [];

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', name: 'p99', unit: undefined },
]);
});
it('returns [] when there is no scalar table with columns', () => {
mockPrepareScalarTables.mockReturnValue([{ columns: [], rows: [] }]);
@@ -73,7 +90,7 @@ describe('useTableColumns', () => {
const { result } = renderHook(() => useTableColumns(tablePanel(), DATA));
expect(result.current).toStrictEqual([
{ key: 'A', label: 'p99', unit: undefined },
{ key: 'A', label: 'p99', name: 'p99', unit: undefined },
]);
});

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';
@@ -16,6 +18,8 @@ export interface TableColumnOption {
key: string;
/** Display label shown in the editor — the resolved column name. */
label: string;
/** The column's own name (alias/legend/expression), without the query prefix. */
name: string;
/**
* The column's configured unit (`formatting.columnUnits[key]`), if any. The
* per-column threshold editor scopes its unit picker to this unit's category
@@ -28,14 +32,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 +57,15 @@ 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,
name: column.name,
unit: getColumnUnit(key, columnUnits),
};
});

View File

@@ -34,6 +34,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': [],
};
@@ -51,6 +52,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
@@ -112,7 +114,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,
@@ -120,6 +122,14 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/ScatterPlotPanel': {
requestType: scalar,
formatTableResultForUI: true,
bucketedStepInterval: false,
orderTiebreaker: false,
serverPaginated: false,
defaultRowLimit: 10_000,
},
// 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,227 @@
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 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 { findGroupByMismatch } from './utils/groupByMismatch';
import { getScatterPlotEmptyMessage } from './utils/messages';
import { resolvePointOpacity, resolvePointSize } from './utils/points';
import { prepareScatterPlotData } from './utils/prepareData';
import { getScatterTable } from './utils/scatterTable';
import styles from './Renderer.module.scss';
import { ScatterPlotDataStatus } from './types';
import { toScatterAxisOptions } from './utils/axisOptions';
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],
);
const table = useMemo(() => getScatterTable(data), [data]);
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 builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const groupByMismatch = useMemo(
() => findGroupByMismatch(builderQueries),
[builderQueries],
);
const emptyMessage = getScatterPlotEmptyMessage(scatterData, groupByMismatch);
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 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} />
)}
{/* Stays mounted through the empty states: the size observer is bound to this node. */}
<div
ref={graphRef}
className={styles.chart}
hidden={!readyData}
data-testid="scatter-plot-chart-area"
>
{readyData &&
config &&
chartData &&
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>
</div>
);
}
export default ScatterPlotPanelRenderer;

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