mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-30 21:11:11 +00:00
Compare commits
2 Commits
fix/scatte
...
docs/e2e-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ed0b0c62 | ||
|
|
9f04647e5b |
@@ -150,6 +150,18 @@ Note: no cleanup code (the `dashboards` driver deletes its seed), no raw locator
|
||||
- **Monaco editor swallows Escape.** Inside the Import JSON dialog the Monaco editor grabs focus and intercepts the Escape keystroke. Click the modal title first to blur Monaco; `dashboardList.dismissWithEscape()` does this.
|
||||
- **Empty zero-state hides controls.** With no dashboards in the workspace, the list controls and `new-dashboard-cta` are absent. Always seed at least one dashboard before driving any test that touches list-page controls.
|
||||
|
||||
# Timing traps
|
||||
|
||||
Each of these passes alone and flakes under parallel CI load. Handle them inside the driver action.
|
||||
|
||||
- **Wait for the request the action started.** A `waitForResponse` registered after the click, or matching any in-flight request, can resolve on an earlier one. Register `page.waitForRequest(...)` before the action, then await its `response()`.
|
||||
- **Let React commit before a shortcut.** A keyboard shortcut (e.g. Cmd+Enter) pressed right after typing can read the previous state. Wait two animation frames (`requestAnimationFrame` twice in `page.evaluate`) before pressing it.
|
||||
- **Typing before the first load is lost.** Pages that reset inputs when their initial data lands wipe early keystrokes. The `open` action waits for the page's first data request.
|
||||
- **A cache hit fires nothing.** When a page already fetched on an earlier edit, the next Run may be served from the React Query cache. After one Run, assert the page shows the expected result and the last request sent carries the edit. Don't wait for a fresh request that may not come, and don't click Run repeatedly: that hides a first click sending stale values.
|
||||
- **Open autocomplete explicitly.** Suggestions that appear on focus or typing arrive late under load. Trigger them (e.g. Ctrl+Space) and wait for the option.
|
||||
- **antd dropdowns render in a portal.** A Select's options are not inside the Select. Scope to that Select's own open dropdown, and assert the chosen value on `.ant-select-selection-item`.
|
||||
- **Split long scenarios.** A test chaining many reloads or edits nears the timeout under load. Split it.
|
||||
|
||||
# Quality bar
|
||||
|
||||
- Every test runs end-to-end against a fresh stack. If you can't run it green from a fresh `test_setup`, it's not done.
|
||||
|
||||
@@ -28,7 +28,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- `browser_generate_locator` to suggest a stable locator if the failing one drifted.
|
||||
4. **Root-cause.** Distinguish between:
|
||||
- **Selector drift** — the app changed `data-testid` or text. Fix the locator where it lives: in the driver under `tests/e2e/drivers/` when the spec uses one, so every spec sharing it heals at once. Prefer `data-testid` (grep `frontend/src/<feature-dir>/` for the new one).
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one.
|
||||
- **Timing** — the test races a load. Replace `waitForTimeout` with `await expect(locator).toBe…()` or `page.waitForResponse(...)` on the triggering action — inside the driver action when there is one. A test green alone but red with `CI=1` is usually one of the generator's [timing traps](playwright-test-generator.md#timing-traps).
|
||||
- **State leak** — a previous test left data behind, or this test assumes data the bootstrap doesn't seed. Seed through the driver (its `dispose` cleans up) or via API with `try / finally`. The bootstrap creates a fresh stack with **zero** dashboards / alerts and only the golden telemetry; parallel per-test seeds share the tenant, so an exact-count assertion on telemetry is a test bug — assert structure instead.
|
||||
- **Genuine app bug** — the app is broken, not the test. Mark the test with `test.fixme(...)` and add a one-line `// known: <description>` comment. Don't silently change the assertion to make it pass.
|
||||
5. **Fix.** Edit the spec or its driver. Preserve TC-NN titles, the driver / `authedPage` fixtures, cleanup, and serial mode if present. A driver change must keep every other spec importing it green — run them too. If you renumber, retitle, or `test.fixme(...)` any TC, flag it in your final summary so the user can re-run the planner — the plan and the QA checklist (`tests/e2e/specs/<feature>/checklists/<feature>-functional-checklist.md`) re-derive from the current `.spec.ts` and will otherwise drift.
|
||||
|
||||
@@ -4224,13 +4224,6 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -5257,7 +5250,6 @@ 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'
|
||||
@@ -5273,7 +5265,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -5287,7 +5278,6 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -5394,21 +5384,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
description: The panel kind.
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
description: The options for this panel kind. {} accepts every default.
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -5519,16 +5494,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the slices are computed over.
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -5783,161 +5748,6 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
description: The axis dimensions.x is plotted along.
|
||||
"y":
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
description: The axis dimensions.y is plotted along.
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none; the tooltip still names the column.
|
||||
type: string
|
||||
scale:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
description: auto (default) uses log, or symlog when values include zero
|
||||
or negatives, when that spreads the dots clearly more than linear does;
|
||||
otherwise linear. log cannot place a value at or below zero and leaves
|
||||
that dot out; symlog is log-like but places it.
|
||||
softMax:
|
||||
description: The highest value the axis always shows; data above it still
|
||||
extends the axis, as does a threshold on it. Null fits the data.
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
description: The lowest value the axis always shows; data below it still
|
||||
extends the axis, as does a threshold on it. Null fits the data. A log
|
||||
axis ignores a bound at or below zero.
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
description: Dot size and opacity.
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values color the dots and drive the legend. Empty colors by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales each dot's area between chartAppearance.points.minSize
|
||||
and maxSize. Empty draws every dot at chartAppearance.points.size, as
|
||||
does a dot with no value.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty, or a key the result
|
||||
lacks, uses the first value column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty, or a key the
|
||||
result lacks, uses the first value column x does not use.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
description: Title, bounds and scale of each axis.
|
||||
chartAppearance:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
description: Dot drawing options.
|
||||
dimensions:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
description: 'Which result columns place, size and color the dots. Each
|
||||
dot is one row of the joined scalar result: one group.'
|
||||
formatting:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
description: Per-column units and decimal precision. The x and y columns'
|
||||
units label their axes; every bound column's unit shows in the tooltip.
|
||||
legend:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesLegend'
|
||||
description: Legend placement and series colors. A series is one color group,
|
||||
labeled by its colorBy values joined with a comma, or All when nothing
|
||||
is grouped.
|
||||
thresholds:
|
||||
description: Reference lines drawn at fixed values across the x or y axis.
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
description: The time range the groups are computed over.
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
description: Diameter of the largest dot when dimensions.sizeBy is set,
|
||||
in CSS pixels from 2 to 40. Null means 24.
|
||||
minSize:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
description: Diameter of the smallest dot when dimensions.sizeBy is set,
|
||||
in CSS pixels from 2 to 40. Null means 4.
|
||||
opacity:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
description: Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
|
||||
size:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
description: Diameter of every dot when dimensions.sizeBy is unset, in CSS
|
||||
pixels from 2 to 40. Null means 6.
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
description: 'The axis the line marks: y (default) draws it horizontally,
|
||||
x vertically. Unit converts against that axis''s column unit, and the
|
||||
axis extends to keep the line in view.'
|
||||
color:
|
||||
description: 'The CSS color of the line, for example #F1575F, which matches
|
||||
the SigNoz red preset; CSS named colors such as red also work.'
|
||||
type: string
|
||||
label:
|
||||
description: The caption shown on the line.
|
||||
type: string
|
||||
unit:
|
||||
description: The unit id of value, such as ms or percent. Empty means the
|
||||
panel's unit, or the column's unit for a table threshold. Prefer the panel's
|
||||
unit; a different unit works only when it converts to it, such as s against
|
||||
a panel in ms.
|
||||
type: string
|
||||
value:
|
||||
description: The value the line is drawn at, in unit.
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -6104,11 +5914,6 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
|
||||
@@ -57,7 +57,6 @@ 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(
|
||||
@@ -71,7 +70,6 @@ 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;
|
||||
|
||||
|
||||
@@ -5584,12 +5584,6 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
|
||||
visualization?: DashboardtypesAreaChartVisualizationDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesAxisScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesBarChartVisualizationDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -6722,205 +6716,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesHeatmapPanelSpecDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel kind.
|
||||
*/
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
}
|
||||
export interface DashboardtypesScatterPlotAxisDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Axis title. Empty draws none; the tooltip still names the column.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @description auto (default) uses log, or symlog when values include zero or negatives, when that spreads the dots clearly more than linear does; otherwise linear. log cannot place a value at or below zero and leaves that dot out; symlog is log-like but places it.
|
||||
*/
|
||||
scale?: DashboardtypesAxisScaleDTO;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description The highest value the axis always shows; data above it still extends the axis, as does a threshold on it. Null fits the data.
|
||||
*/
|
||||
softMax?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
* @description The lowest value the axis always shows; data below it still extends the axis, as does a threshold on it. Null fits the data. A log axis ignores a bound at or below zero.
|
||||
*/
|
||||
softMin?: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardtypesScatterPlotAxesDTO {
|
||||
/**
|
||||
* @description The axis dimensions.x is plotted along.
|
||||
*/
|
||||
x?: DashboardtypesScatterPlotAxisDTO;
|
||||
/**
|
||||
* @description The axis dimensions.y is plotted along.
|
||||
*/
|
||||
y?: DashboardtypesScatterPlotAxisDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* @minimum 2
|
||||
* @maximum 40
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesPointDiameterDTO = number | null;
|
||||
|
||||
/**
|
||||
* @minimum 0.1
|
||||
* @maximum 1
|
||||
* @nullable
|
||||
*/
|
||||
export type DashboardtypesPointOpacityDTO = number | null;
|
||||
|
||||
/**
|
||||
* Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
|
||||
*/
|
||||
export type DashboardtypesScatterPlotPointsDTOMaxSize = DashboardtypesPointDiameterDTO | null;
|
||||
|
||||
/**
|
||||
* Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
|
||||
*/
|
||||
export type DashboardtypesScatterPlotPointsDTOMinSize = DashboardtypesPointDiameterDTO | null;
|
||||
|
||||
/**
|
||||
* Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
|
||||
*/
|
||||
export type DashboardtypesScatterPlotPointsDTOOpacity = DashboardtypesPointOpacityDTO | null;
|
||||
|
||||
/**
|
||||
* Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
|
||||
*/
|
||||
export type DashboardtypesScatterPlotPointsDTOSize = DashboardtypesPointDiameterDTO | null;
|
||||
|
||||
export interface DashboardtypesScatterPlotPointsDTO {
|
||||
/**
|
||||
* @description Diameter of the largest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 24.
|
||||
*/
|
||||
maxSize?: DashboardtypesScatterPlotPointsDTOMaxSize;
|
||||
/**
|
||||
* @description Diameter of the smallest dot when dimensions.sizeBy is set, in CSS pixels from 2 to 40. Null means 4.
|
||||
*/
|
||||
minSize?: DashboardtypesScatterPlotPointsDTOMinSize;
|
||||
/**
|
||||
* @description Fill opacity of every dot, from 0.1 to 1. Null means 0.7.
|
||||
*/
|
||||
opacity?: DashboardtypesScatterPlotPointsDTOOpacity;
|
||||
/**
|
||||
* @description Diameter of every dot when dimensions.sizeBy is unset, in CSS pixels from 2 to 40. Null means 6.
|
||||
*/
|
||||
size?: DashboardtypesScatterPlotPointsDTOSize;
|
||||
}
|
||||
|
||||
export interface DashboardtypesScatterPlotChartAppearanceDTO {
|
||||
/**
|
||||
* @description Dot size and opacity.
|
||||
*/
|
||||
points?: DashboardtypesScatterPlotPointsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesScatterPlotDimensionsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values color the dots and drive the legend. Empty colors by every group-by label.
|
||||
*/
|
||||
colorBy?: string[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @description Value column key that scales each dot's area between chartAppearance.points.minSize and maxSize. Empty draws every dot at chartAppearance.points.size, as does a dot with no value.
|
||||
*/
|
||||
sizeBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty, or a key the result lacks, uses the first value column.
|
||||
*/
|
||||
x?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Value column key plotted on the y axis. Empty, or a key the result lacks, uses the first value column x does not use.
|
||||
*/
|
||||
y?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesThresholdAxisDTO {
|
||||
x = 'x',
|
||||
y = 'y',
|
||||
}
|
||||
export interface DashboardtypesScatterPlotThresholdDTO {
|
||||
/**
|
||||
* @description The axis the line marks: y (default) draws it horizontally, x vertically. Unit converts against that axis's column unit, and the axis extends to keep the line in view.
|
||||
*/
|
||||
axis?: DashboardtypesThresholdAxisDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work.
|
||||
*/
|
||||
color: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The caption shown on the line.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms.
|
||||
*/
|
||||
unit?: string;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
* @description The value the line is drawn at, in unit.
|
||||
*/
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesScatterPlotPanelSpecDTO {
|
||||
/**
|
||||
* @description Title, bounds and scale of each axis.
|
||||
*/
|
||||
axes?: DashboardtypesScatterPlotAxesDTO;
|
||||
/**
|
||||
* @description Dot drawing options.
|
||||
*/
|
||||
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
|
||||
/**
|
||||
* @description Which result columns place, size and color the dots. Each dot is one row of the joined scalar result: one group.
|
||||
*/
|
||||
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
|
||||
/**
|
||||
* @description Per-column units and decimal precision. The x and y columns' units label their axes; every bound column's unit shows in the tooltip.
|
||||
*/
|
||||
formatting?: DashboardtypesTableFormattingDTO;
|
||||
/**
|
||||
* @description Legend placement and series colors. A series is one color group, labeled by its colorBy values joined with a comma, or All when nothing is grouped.
|
||||
*/
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
* @description Reference lines drawn at fixed values across the x or y axis.
|
||||
*/
|
||||
thresholds?: DashboardtypesScatterPlotThresholdDTO[] | null;
|
||||
/**
|
||||
* @description The time range the groups are computed over.
|
||||
*/
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/ScatterPlotPanel
|
||||
* @type string
|
||||
* @description The panel kind.
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
|
||||
/**
|
||||
* @description The options for this panel kind. {} accepts every default.
|
||||
*/
|
||||
spec: DashboardtypesScatterPlotPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -6931,8 +6726,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -8212,7 +8006,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
|
||||
}
|
||||
/**
|
||||
* The RFC 6902 operations, applied in order against the dashboard's postable shape.
|
||||
|
||||
@@ -21,7 +21,6 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
convertBuilderQueriesToV5,
|
||||
mapPanelTypeToRequestType,
|
||||
prepareQueryRangePayloadV5,
|
||||
} from './prepareQueryRangePayloadV5';
|
||||
|
||||
@@ -905,12 +904,6 @@ 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(
|
||||
|
||||
@@ -51,7 +51,6 @@ 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';
|
||||
@@ -275,7 +274,6 @@ export function createAggregation(
|
||||
queryData.dataSource === DataSource.METRICS &&
|
||||
panelType &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.SCATTER ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE);
|
||||
|
||||
|
||||
@@ -106,17 +106,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const traceOperatorField = useMemo(
|
||||
() =>
|
||||
resolveQueryBuilderField(QueryBuilderField.TraceOperator, resolvedConfig),
|
||||
[resolvedConfig],
|
||||
);
|
||||
|
||||
const canShowTraceOperator = showTraceOperator && !traceOperatorField.hidden;
|
||||
|
||||
const isMultiQueryAllowed = useMemo(
|
||||
() => !additionalQueries.hidden && (!isRawQuery || canShowTraceOperator),
|
||||
[additionalQueries.hidden, canShowTraceOperator, isRawQuery],
|
||||
() => !additionalQueries.hidden && (!isRawQuery || showTraceOperator),
|
||||
[additionalQueries.hidden, showTraceOperator, isRawQuery],
|
||||
);
|
||||
|
||||
const queryDataSources = useMemo(
|
||||
@@ -154,14 +146,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
);
|
||||
|
||||
const hasTraceOperator = useMemo(
|
||||
() =>
|
||||
canShowTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
|
||||
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
() => showTraceOperator && hasAtLeastOneTraceQuery && Boolean(traceOperator),
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
const showQueryList = useMemo(
|
||||
() => (!showOnlyWhereClause && !isRawQuery) || canShowTraceOperator,
|
||||
[isRawQuery, showOnlyWhereClause, canShowTraceOperator],
|
||||
() => (!showOnlyWhereClause && !isRawQuery) || showTraceOperator,
|
||||
[isRawQuery, showOnlyWhereClause, showTraceOperator],
|
||||
);
|
||||
|
||||
const showFormula = useMemo(() => {
|
||||
@@ -177,8 +168,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
}, [formula.hidden, isRawQuery, currentDataSource]);
|
||||
|
||||
const showAddTraceOperator = useMemo(
|
||||
() => canShowTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
|
||||
[canShowTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
() => showTraceOperator && !traceOperator && hasAtLeastOneTraceQuery,
|
||||
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
|
||||
);
|
||||
|
||||
// Nothing left to add means no footer at all, rather than an empty bar under the
|
||||
@@ -187,13 +178,13 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
() =>
|
||||
(!additionalQueries.hidden || showFormula || showAddTraceOperator) &&
|
||||
((!showOnlyWhereClause && !isRawQuery) ||
|
||||
(currentDataSource === DataSource.TRACES && canShowTraceOperator)),
|
||||
(currentDataSource === DataSource.TRACES && showTraceOperator)),
|
||||
[
|
||||
additionalQueries.hidden,
|
||||
showFormula,
|
||||
showAddTraceOperator,
|
||||
isRawQuery,
|
||||
canShowTraceOperator,
|
||||
showTraceOperator,
|
||||
showOnlyWhereClause,
|
||||
currentDataSource,
|
||||
],
|
||||
@@ -231,7 +222,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
fieldsConfig={fieldsConfig}
|
||||
allowedDataSources={queryDataSources}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
showTraceOperator={canShowTraceOperator}
|
||||
showTraceOperator={showTraceOperator}
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
version={version}
|
||||
isAvailableToDisable={false}
|
||||
@@ -256,7 +247,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
version={version}
|
||||
isMultiQueryAllowed={isMultiQueryAllowed}
|
||||
isAvailableToDisable={false}
|
||||
showTraceOperator={canShowTraceOperator}
|
||||
showTraceOperator={showTraceOperator}
|
||||
hasTraceOperator={hasTraceOperator}
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
|
||||
@@ -6,8 +6,7 @@ export type NonAddOnField =
|
||||
| QueryBuilderField.StepInterval
|
||||
| QueryBuilderField.Functions
|
||||
| QueryBuilderField.Formula
|
||||
| QueryBuilderField.AdditionalQueries
|
||||
| QueryBuilderField.TraceOperator;
|
||||
| QueryBuilderField.AdditionalQueries;
|
||||
|
||||
export type AddOnField = Exclude<QueryBuilderField, NonAddOnField>;
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
STEP_INTERVAL_PLACEHOLDER,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import { isScalarPanelType } from '../panelTypes';
|
||||
import QueryAggregationSelect from './QueryAggregationSelect';
|
||||
|
||||
import './QueryAggregation.styles.scss';
|
||||
@@ -46,7 +45,10 @@ function QueryAggregationOptions({
|
||||
const isNonMetricSource =
|
||||
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
|
||||
|
||||
if (isNonMetricSource && isScalarPanelType(panelType)) {
|
||||
if (
|
||||
isNonMetricSource &&
|
||||
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
|
||||
) {
|
||||
return { hidden: true, disabled: false, pinned: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
|
||||
import { QueryProps } from 'container/QueryBuilder/type';
|
||||
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
resolveQueryBuilderField,
|
||||
} from '../queryBuilderFields.utils';
|
||||
|
||||
import { isScalarPanelType } from './panelTypes';
|
||||
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
|
||||
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
|
||||
import QueryAddOns from './QueryAddOns/QueryAddOns';
|
||||
@@ -112,7 +112,11 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
}
|
||||
|
||||
const showReduceTo = useMemo(
|
||||
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
|
||||
() =>
|
||||
dataSource === DataSource.METRICS &&
|
||||
(panelType === PANEL_TYPES.TABLE ||
|
||||
panelType === PANEL_TYPES.PIE ||
|
||||
panelType === PANEL_TYPES.VALUE),
|
||||
[dataSource, panelType],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
/** Panels that reduce each query to one value per group over the whole window. */
|
||||
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
PANEL_TYPES.TABLE,
|
||||
PANEL_TYPES.SCATTER,
|
||||
PANEL_TYPES.PIE,
|
||||
PANEL_TYPES.VALUE,
|
||||
]);
|
||||
|
||||
export function isScalarPanelType(panelType?: string | null): boolean {
|
||||
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
|
||||
}
|
||||
@@ -14,17 +14,6 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
|
||||
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 placeholder', () => {
|
||||
it('shows the configured placeholder', () => {
|
||||
render(
|
||||
@@ -45,27 +34,14 @@ describe('QueryAggregationOptions step interval placeholder', () => {
|
||||
});
|
||||
|
||||
it('falls back to Auto', () => {
|
||||
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
|
||||
render(
|
||||
<QueryAggregationOptions
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
onAggregationIntervalChange={jest.fn()}
|
||||
queryData={queryData}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ export enum QueryBuilderField {
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
TraceOperator = 'trace_operator',
|
||||
}
|
||||
|
||||
/** Per-query builder fields that can be kept equal across a panel's queries. */
|
||||
|
||||
@@ -65,13 +65,13 @@ export function resolveQueryBuilderFields(
|
||||
|
||||
/**
|
||||
* The surface a raw-row builder starts from, layered under a caller's own config.
|
||||
* `AdditionalQueries` and `TraceOperator` are deliberately absent — a raw trace builder
|
||||
* still takes several queries, and their trace operator, when trace matching is on.
|
||||
* Omit rather than Partial, so a field added to the enum has to be placed on one side.
|
||||
* `AdditionalQueries` is deliberately absent — a raw trace builder still takes several
|
||||
* queries when trace matching is on. Omit rather than Partial, so a field added to the
|
||||
* enum has to be placed on one side.
|
||||
*/
|
||||
export const RAW_QUERY_FIELDS: Omit<
|
||||
Record<QueryBuilderField, QueryBuilderFieldRule>,
|
||||
QueryBuilderField.AdditionalQueries | QueryBuilderField.TraceOperator
|
||||
QueryBuilderField.AdditionalQueries
|
||||
> = {
|
||||
[QueryBuilderField.Aggregation]: { state: 'hidden' },
|
||||
[QueryBuilderField.StepInterval]: { state: 'hidden' },
|
||||
|
||||
@@ -36,7 +36,6 @@ 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -337,7 +337,6 @@ export enum PANEL_TYPES {
|
||||
TRACE = 'trace',
|
||||
BAR = 'bar',
|
||||
AREA = 'area',
|
||||
SCATTER = 'scatter',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
|
||||
@@ -28,7 +28,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
TRACE: false,
|
||||
BAR: true,
|
||||
AREA: true,
|
||||
SCATTER: false,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
|
||||
@@ -16,7 +16,6 @@ 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,
|
||||
|
||||
@@ -76,39 +76,3 @@ describe('handleQueryChange bucket options', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueryChange trace operator', () => {
|
||||
function queryWithTraceOperator(): Query {
|
||||
const base = initialQueriesMap[DataSource.TRACES];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryTraceOperator: [
|
||||
{ ...base.builder.queryData[0], queryName: 'T1', expression: 'A => B' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The API rejects a trace operator on a scatter plot, so the switch sheds it.
|
||||
it('drops the trace operator when switching to a scatter plot', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.SCATTER,
|
||||
queryWithTraceOperator(),
|
||||
PANEL_TYPES.TABLE,
|
||||
);
|
||||
|
||||
expect(result.builder.queryTraceOperator).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the trace operator when switching to a table', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TABLE,
|
||||
queryWithTraceOperator(),
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
|
||||
expect(result.builder.queryTraceOperator).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,7 +165,7 @@ export function handleQueryChange(
|
||||
newPanelType,
|
||||
),
|
||||
queryTraceOperator:
|
||||
newPanelType === PANEL_TYPES.LIST || newPanelType === PANEL_TYPES.SCATTER
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? []
|
||||
: supersetQuery.builder.queryTraceOperator,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@ 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';
|
||||
};
|
||||
@@ -151,7 +150,6 @@ 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),
|
||||
};
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
// Sections own the padding so the footer spans edge to edge.
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/** Own component: the shared Tooltip lists one row per series at a shared x. */
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
channels,
|
||||
resolvePointGroupValues,
|
||||
decimalPrecision,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: ScatterTooltipProps): JSX.Element | null {
|
||||
const hoveredPoint = useMemo(
|
||||
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
|
||||
[uPlotInstance, seriesIndex, dataIndexes],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
hoveredPoint
|
||||
? buildChannelRows(hoveredPoint, channels, decimalPrecision)
|
||||
: [],
|
||||
[hoveredPoint, channels, decimalPrecision],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
hoveredPoint
|
||||
? (resolvePointGroupValues?.(
|
||||
hoveredPoint.seriesIndex,
|
||||
hoveredPoint.dataIndex,
|
||||
) ?? [])
|
||||
: [],
|
||||
[hoveredPoint, resolvePointGroupValues],
|
||||
);
|
||||
|
||||
if (!hoveredPoint) {
|
||||
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: hoveredPoint.color,
|
||||
borderColor: hoveredPoint.color,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.title}
|
||||
title={hoveredPoint.label}
|
||||
testId="scatter-tooltip-title"
|
||||
>
|
||||
{hoveredPoint.label}
|
||||
</Typography.Text>
|
||||
{isPinned && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Pin size={12} />}
|
||||
testId="scatter-tooltip-status"
|
||||
>
|
||||
Pinned
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<Typography.Text as="span" className={Styles.rowLabel}>
|
||||
{row.label}
|
||||
</Typography.Text>
|
||||
<Typography.Text as="span" className={Styles.rowValue}>
|
||||
{row.value}
|
||||
</Typography.Text>
|
||||
</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"
|
||||
>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.rowLabel}
|
||||
title={label.key}
|
||||
>
|
||||
{label.key}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
as="span"
|
||||
className={Styles.rowValue}
|
||||
title={label.value}
|
||||
>
|
||||
{label.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -6,10 +6,6 @@ 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';
|
||||
|
||||
/**
|
||||
@@ -123,16 +119,6 @@ export interface HeatmapTooltipProps
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export interface ScatterTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
channels: ScatterChannels;
|
||||
/** The group values behind a point, e.g. `service.name` → `cart`. */
|
||||
resolvePointGroupValues?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -39,16 +39,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Styled like the grid. */
|
||||
private buildBorderConfig(
|
||||
grid: uPlot.Axis.Grid | undefined,
|
||||
): uPlot.Axis.Border | undefined {
|
||||
if (this.props.showBorder === false || !grid || grid.show === false) {
|
||||
return undefined;
|
||||
}
|
||||
return { show: true, stroke: grid.stroke, width: grid.width };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ticks configuration
|
||||
*/
|
||||
@@ -69,47 +59,31 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
/**
|
||||
* Build values formatter for X-axis (time)
|
||||
* A non-time x axis with a unit or precision formats like a value axis.
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
|
||||
const { isTimeAxis } = 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)
|
||||
* Blanks splits outside the scale; uPlot's asinh splits always include ±threshold.
|
||||
*/
|
||||
private buildValueAxisFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
|
||||
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision } = this.props;
|
||||
|
||||
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 (_, t): string[] =>
|
||||
t.map((v) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) {
|
||||
return '';
|
||||
}
|
||||
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
|
||||
return `${value}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,7 +101,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
return scaleKey === 'x'
|
||||
? this.buildXAxisValuesFormatter()
|
||||
: scaleKey === 'y'
|
||||
? this.buildValueAxisFormatter()
|
||||
? this.buildYAxisValuesFormatter()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -175,12 +149,10 @@ 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();
|
||||
@@ -205,9 +177,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (ticks) {
|
||||
axisConfig.ticks = ticks;
|
||||
}
|
||||
if (border) {
|
||||
axisConfig.border = border;
|
||||
}
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
@@ -220,9 +189,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (space !== undefined) {
|
||||
axisConfig.space = space;
|
||||
}
|
||||
if (filter) {
|
||||
axisConfig.filter = filter;
|
||||
}
|
||||
if (size) {
|
||||
axisConfig.size = size;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
@@ -67,8 +66,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private mode: PlotMode = PlotMode.Aligned;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -166,10 +163,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
setMode(mode: PlotMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -526,10 +519,6 @@ 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;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
range,
|
||||
thresholds,
|
||||
logBase = 10,
|
||||
asinhThreshold,
|
||||
padMinBy = 0,
|
||||
padMaxBy = 0.05,
|
||||
} = this.props;
|
||||
@@ -100,7 +99,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
});
|
||||
|
||||
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =
|
||||
|
||||
@@ -88,8 +88,6 @@ 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) {
|
||||
@@ -125,7 +123,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
return { paths: pathBuilder };
|
||||
}
|
||||
|
||||
if (isMarkerOnlyDrawStyle(drawStyle)) {
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
return { paths: (): null => null };
|
||||
}
|
||||
|
||||
@@ -197,10 +195,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -232,7 +226,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
show = true,
|
||||
metric,
|
||||
partialTimestamps,
|
||||
facets,
|
||||
} = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
@@ -262,7 +255,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(partialTimestamps?.size ? { partialTimestamps } : {}),
|
||||
...(facets && { facets }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -275,11 +267,6 @@ interface PathBuilders {
|
||||
[key: string]: Series.PathBuilder;
|
||||
}
|
||||
|
||||
/** Draws standalone markers, never a connecting line. */
|
||||
function isMarkerOnlyDrawStyle(drawStyle: DrawStyle): boolean {
|
||||
return drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
|
||||
@@ -120,41 +120,6 @@ 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('leaves the axis line off when the grid is hidden', () => {
|
||||
expect(
|
||||
new UPlotAxisBuilder(createAxisProps({ grid: { show: 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(
|
||||
@@ -411,51 +376,3 @@ 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', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -672,14 +667,3 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
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.getConfig().mode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -425,39 +425,3 @@ describe('UPlotSeriesBuilder', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,10 +80,6 @@ export interface AxisProps {
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
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. */
|
||||
@@ -94,7 +90,7 @@ 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`); a non-time x axis reads it too. */
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
yAxisUnit?: string;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
@@ -113,13 +109,6 @@ export interface AxisProps {
|
||||
export enum DistributionType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
/** arcsinh: linear within ±`asinhThreshold`, log beyond; places zero and negatives. */
|
||||
SymmetricLog = 'symlog',
|
||||
}
|
||||
|
||||
export enum PlotMode {
|
||||
Aligned = 1,
|
||||
Faceted = 2,
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
@@ -136,8 +125,6 @@ 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 {
|
||||
@@ -159,7 +146,6 @@ export enum DrawStyle {
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
Scatter = 'scatter',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -246,8 +232,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
metric?: { [key: string]: string };
|
||||
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
|
||||
partialTimestamps?: Set<number>;
|
||||
/** Mode 2 only: the scales the series' own x and y columns are read against. */
|
||||
facets?: Series.Facet[];
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { thresholdsDrawHook } from '../useThresholdsDrawHook';
|
||||
|
||||
function mockPlot(ori: 0 | 1): { plot: uPlot; ctx: Record<string, jest.Mock> } {
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 40 })),
|
||||
};
|
||||
const plot = {
|
||||
ctx: { ...ctx, canvas: { height: 300 } },
|
||||
bbox: { left: 10, top: 20, width: 400, height: 200 },
|
||||
scales: { s: { ori } },
|
||||
valToPos: (): number => 100,
|
||||
} as unknown as uPlot;
|
||||
return { plot, ctx };
|
||||
}
|
||||
|
||||
const draw = (plot: uPlot): void =>
|
||||
(
|
||||
thresholdsDrawHook({
|
||||
scaleKey: 's',
|
||||
thresholds: [{ thresholdValue: 5, thresholdLabel: 'SLO' }],
|
||||
}) as (u: uPlot) => void
|
||||
)(plot);
|
||||
|
||||
describe('thresholdsDrawHook', () => {
|
||||
it('draws a horizontal line across a vertical scale', () => {
|
||||
const { plot, ctx } = mockPlot(1);
|
||||
draw(plot);
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(10, 100);
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(410, 100);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith('SLO', 350, 85);
|
||||
});
|
||||
|
||||
it('draws a vertical line across a horizontal scale', () => {
|
||||
const { plot, ctx } = mockPlot(0);
|
||||
draw(plot);
|
||||
|
||||
expect(ctx.moveTo).toHaveBeenCalledWith(100, 20);
|
||||
expect(ctx.lineTo).toHaveBeenCalledWith(100, 220);
|
||||
expect(ctx.fillText).toHaveBeenCalledWith(
|
||||
'SLO',
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,42 +29,13 @@ export function thresholdsDrawHook(
|
||||
|
||||
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
// A horizontal scale (e.g. a scatter plot's x) takes a vertical line.
|
||||
if (u.scales[scaleKey]?.ori === 0) {
|
||||
const x = Math.round(scaleVal);
|
||||
const top = Math.round(u.bbox.top);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, Math.round(u.bbox.top + u.bbox.height));
|
||||
ctx.stroke();
|
||||
|
||||
if (threshold.thresholdLabel) {
|
||||
const gap = 6 * uPlot.pxRatio;
|
||||
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
|
||||
const fitsRight = x + gap + textWidth <= u.bbox.left + u.bbox.width;
|
||||
// uPlot leaves the last axis's text alignment on the context.
|
||||
ctx.save();
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(
|
||||
threshold.thresholdLabel,
|
||||
fitsRight ? x + gap : x - gap - textWidth,
|
||||
top + gap,
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const x0 = Math.round(u.bbox.left);
|
||||
const y0 = Math.round(scaleVal);
|
||||
const x1 = Math.round(u.bbox.left + u.bbox.width);
|
||||
const y1 = Math.round(scaleVal);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0, y0);
|
||||
ctx.lineTo(x1, y1);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
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();
|
||||
// Inside the disc's bounding square, outside the disc.
|
||||
expect(resolveHit(tree, 15.8, 15.8, 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);
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
let orient: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
orient = jest
|
||||
.spyOn(uPlot, 'orient')
|
||||
.mockImplementation(orientWithIdentityScales);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
orient.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(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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Size extent across all series, so equal values draw equal discs. */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diameter for a size-channel value. Grows the disc's area, not its width,
|
||||
* linearly with the value, so a value twice as large reads as twice the ink.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The disc under the cursor, counting `tolerance` px beyond its edge as a hit.
|
||||
* Where discs overlap, the one whose centre is closest wins; `null` if none.
|
||||
*/
|
||||
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 dx = cx - (hit.x + hit.w / 2);
|
||||
const dy = cy - (hit.y + hit.h / 2);
|
||||
const distance = dx * dx + dy * dy;
|
||||
const reach = hit.w / 2 + tolerance;
|
||||
if (distance > reach * reach) {
|
||||
return;
|
||||
}
|
||||
if (distance < bestDistance) {
|
||||
best = hit;
|
||||
bestDistance = distance;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return best;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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';
|
||||
|
||||
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 ceils a cursor point's left/top but not its size; snap so the ring stays centred. */
|
||||
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 {
|
||||
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;
|
||||
|
||||
// Spans every series; cached per dataset.
|
||||
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 };
|
||||
},
|
||||
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 holds only what the path builder drew, so force a rebuild.
|
||||
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);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** Parallel columns; a `null` size 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;
|
||||
}
|
||||
|
||||
export interface ScatterChannels {
|
||||
x: ScatterChannel;
|
||||
y: ScatterChannel;
|
||||
size?: ScatterChannel;
|
||||
}
|
||||
|
||||
export interface ScatterPointLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { buildClickData } from '../utils';
|
||||
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
|
||||
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
|
||||
}));
|
||||
|
||||
const event = {
|
||||
offsetX: 10,
|
||||
offsetY: 20,
|
||||
clientX: 110,
|
||||
clientY: 220,
|
||||
} as MouseEvent;
|
||||
|
||||
describe('buildClickData', () => {
|
||||
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
|
||||
const posToIdx = jest.fn(() => {
|
||||
throw new Error('faceted data[0] is null');
|
||||
});
|
||||
const plot = {
|
||||
data: [null],
|
||||
posToVal: (pos: number): number => pos * 2,
|
||||
posToIdx,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toStrictEqual({
|
||||
xValue: 20,
|
||||
yValue: 40,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: 20,
|
||||
mouseX: 10,
|
||||
mouseY: 20,
|
||||
absoluteMouseX: 110,
|
||||
absoluteMouseY: 220,
|
||||
});
|
||||
expect(posToIdx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
|
||||
const plot = {
|
||||
data: [[100, 200, 300]],
|
||||
posToVal: (pos: number): number => pos,
|
||||
posToIdx: (): number => 1,
|
||||
} as unknown as uPlot;
|
||||
|
||||
expect(buildClickData(event, plot)).toMatchObject({
|
||||
focusedSeries: { seriesIndex: 1 },
|
||||
clickedDataTimestamp: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -174,24 +174,6 @@ 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,
|
||||
};
|
||||
|
||||
// Faceted (scatter): no shared x, so `posToIdx` would throw.
|
||||
if (plot.data[0] == null) {
|
||||
return {
|
||||
xValue,
|
||||
yValue,
|
||||
focusedSeries: null,
|
||||
clickedDataTimestamp: xValue,
|
||||
...pointer,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
|
||||
|
||||
const dataIndex = plot.posToIdx(event.offsetX);
|
||||
@@ -211,6 +193,9 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
|
||||
yValue,
|
||||
focusedSeries,
|
||||
clickedDataTimestamp,
|
||||
...pointer,
|
||||
mouseX: event.offsetX,
|
||||
mouseY: event.offsetY,
|
||||
absoluteMouseX: event.clientX,
|
||||
absoluteMouseY: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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('steps evenly through a range too narrow for two 1/2/5 splits', () => {
|
||||
expect(splitsFor(7.6, 9.4)).toStrictEqual([8, 9]);
|
||||
expect(splitsFor(8.12, 8.19)).toStrictEqual([
|
||||
8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19,
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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);
|
||||
// A zero-size query exactly on both midlines.
|
||||
expect(collect(tree, 50, 50, 0, 0).has(99)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -195,22 +195,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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];
|
||||
|
||||
/** Decades finer than the range's own span that the fallback steps through. */
|
||||
const FALLBACK_STEP_DECADES = 3;
|
||||
|
||||
/** Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max]. */
|
||||
function roundSplit(value: number): number {
|
||||
return Number(value.toPrecision(12));
|
||||
}
|
||||
|
||||
/** Evenly stepped splits for a range too narrow to hold two 1/2/5 splits. */
|
||||
function narrowRangeSplits(min: number, max: number): number[] {
|
||||
const coarsest = Math.floor(Math.log10(max - min));
|
||||
for (
|
||||
let exponent = coarsest;
|
||||
exponent > coarsest - FALLBACK_STEP_DECADES;
|
||||
exponent--
|
||||
) {
|
||||
const step = 10 ** exponent;
|
||||
const first = Math.ceil(roundSplit(min / step));
|
||||
const last = Math.floor(roundSplit(max / step));
|
||||
if (last > first) {
|
||||
return Array.from({ length: last - first + 1 }, (_, index) =>
|
||||
roundSplit((first + index) * step),
|
||||
);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 1, 2, 5 × 10ⁿ in range; uPlot's log splits step from an unsnapped min (10.79, 21.58…). */
|
||||
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) => {
|
||||
const split = roundSplit(multiple * 10 ** exponent);
|
||||
if (split >= min && split <= max) {
|
||||
splits.push(split);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (splits.length >= 2) {
|
||||
return splits;
|
||||
}
|
||||
const narrow = narrowRangeSplits(min, max);
|
||||
return narrow.length > splits.length ? narrow : splits;
|
||||
};
|
||||
|
||||
/** Labels every decade, plus any 2 or 5 at least `space` px clear. */
|
||||
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));
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
export interface QuadtreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
/** Rect index for cursor hit-testing; items straddling quadrants are stored in each, so `get` may repeat them. */
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Inclusive, so a zero-size query on a midline still reaches a quadrant.
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,23 +58,18 @@ function normalizeLogLimit(
|
||||
return logBase ** exp;
|
||||
}
|
||||
|
||||
export const DEFAULT_ASINH_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function getDistributionConfig({
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
}: {
|
||||
time: ScaleProps['time'];
|
||||
distr?: DistributionType;
|
||||
logBase?: number;
|
||||
asinhThreshold?: number;
|
||||
}): Partial<Scale> {
|
||||
if (time) {
|
||||
return {};
|
||||
@@ -82,14 +77,6 @@ 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,
|
||||
|
||||
@@ -37,7 +37,6 @@ export default function ChartWrapper({
|
||||
syncKey,
|
||||
syncFilterMode,
|
||||
onDestroy = noop,
|
||||
plotRef,
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
@@ -124,7 +123,6 @@ export default function ChartWrapper({
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
}}
|
||||
onDestroy={(plot: uPlot): void => {
|
||||
plotInstanceRef.current = null;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { render } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import ChartWrapper from '../ChartWrapper';
|
||||
|
||||
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
|
||||
|
||||
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
|
||||
__esModule: true,
|
||||
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
|
||||
plotRef?.(FAKE_PLOT);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('ChartWrapper', () => {
|
||||
it('hands the plot instance to the caller', () => {
|
||||
const plotRef = jest.fn();
|
||||
render(
|
||||
<ChartWrapper
|
||||
config={new UPlotConfigBuilder({ id: 'chart' })}
|
||||
data={[[1], [2]]}
|
||||
width={400}
|
||||
height={300}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
showLegend={false}
|
||||
showTooltip={false}
|
||||
plotRef={plotRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import type { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
|
||||
import { ScatterAxisScale, type ScatterSeries } from './types';
|
||||
|
||||
const SERVICES = [
|
||||
'frontend',
|
||||
'cart',
|
||||
'checkout',
|
||||
'payment',
|
||||
'shipping',
|
||||
'currency',
|
||||
'email',
|
||||
'recommendation',
|
||||
'ads',
|
||||
'product-catalog',
|
||||
];
|
||||
|
||||
export type Shape = 'spread' | 'single' | 'sameX';
|
||||
|
||||
export 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;
|
||||
};
|
||||
}
|
||||
|
||||
export 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 };
|
||||
});
|
||||
}
|
||||
|
||||
export const Y_THRESHOLDS: Threshold[] = [
|
||||
{
|
||||
thresholdValue: 300,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdColor: '#E5484D',
|
||||
thresholdLabel: 'p99 SLO',
|
||||
},
|
||||
];
|
||||
|
||||
export const X_THRESHOLDS: Threshold[] = [
|
||||
{
|
||||
thresholdValue: 60,
|
||||
thresholdUnit: 'reqps',
|
||||
thresholdColor: '#3E63DD',
|
||||
thresholdLabel: 'Capacity',
|
||||
},
|
||||
];
|
||||
@@ -1,160 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
|
||||
import Scatter from './Scatter';
|
||||
import {
|
||||
buildSeries,
|
||||
type ScatterStoryProps,
|
||||
X_THRESHOLDS,
|
||||
Y_THRESHOLDS,
|
||||
} from './Scatter.stories.mocks';
|
||||
import { ScatterAxisScale } from './types';
|
||||
import { buildScatterConfig, prepareScatterChartData } from './utils';
|
||||
|
||||
function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
const {
|
||||
xScale,
|
||||
yScale,
|
||||
sized,
|
||||
thresholds,
|
||||
pointSize,
|
||||
fillOpacity,
|
||||
width,
|
||||
height,
|
||||
} = props;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const series = useMemo(() => buildSeries(props), [props]);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildScatterConfig({
|
||||
id: 'scatter-story',
|
||||
series,
|
||||
isDarkMode,
|
||||
x: {
|
||||
unit: 'reqps',
|
||||
scale: xScale,
|
||||
thresholds: thresholds ? X_THRESHOLDS : undefined,
|
||||
},
|
||||
y: {
|
||||
unit: 'ms',
|
||||
scale: yScale,
|
||||
thresholds: thresholds ? Y_THRESHOLDS : undefined,
|
||||
},
|
||||
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
|
||||
fillOpacity,
|
||||
}),
|
||||
[series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds],
|
||||
);
|
||||
|
||||
const data = useMemo(() => prepareScatterChartData(series), [series]);
|
||||
|
||||
const resolvePointGroupValues = (
|
||||
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' } }),
|
||||
}}
|
||||
resolvePointGroupValues={resolvePointGroupValues}
|
||||
canPinTooltip
|
||||
/>
|
||||
</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' },
|
||||
};
|
||||
|
||||
/** Labelled lines on both axes; each scale stretches to include its line. */
|
||||
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 },
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
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 { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getCursorHit } from './utils';
|
||||
|
||||
// Faceted uPlot can't mount without series; empty aligned data shows no-data instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
|
||||
export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointGroupValues,
|
||||
pinnedTooltipElement,
|
||||
onPointClick,
|
||||
plotRef,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const handlePlotRef = useCallback(
|
||||
(plot: uPlot | null): void => {
|
||||
plotInstanceRef.current = plot;
|
||||
plotRef?.(plot);
|
||||
},
|
||||
[plotRef],
|
||||
);
|
||||
|
||||
// Faceted plots have no shared x; take the hit from the cursor.
|
||||
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,
|
||||
resolvePointGroupValues,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <ScatterTooltip {...tooltipProps} />;
|
||||
},
|
||||
[
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointGroupValues,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { resolveAxisDistribution } from '../axisDistribution';
|
||||
import { ScatterAxisScale } from '../types';
|
||||
|
||||
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', () => {
|
||||
const SKEWED = [1, 2, 3, 5, 8, 10, 20, 50, 1000];
|
||||
|
||||
it.each([
|
||||
['skewed positive values', SKEWED, DistributionType.Logarithmic],
|
||||
[
|
||||
'values spread evenly across decades',
|
||||
[1, 250, 500, 750, 1000],
|
||||
DistributionType.Linear,
|
||||
],
|
||||
[
|
||||
'a single outlier among clustered values',
|
||||
[0.001, 40, 45, 50, 55, 60],
|
||||
DistributionType.Linear,
|
||||
],
|
||||
[
|
||||
'skewed values spanning under two decades',
|
||||
[10, 10, 10, 11, 50],
|
||||
DistributionType.Linear,
|
||||
],
|
||||
['no finite values', [Number.NaN], DistributionType.Linear],
|
||||
])('picks a scale for %s', (_, values, distribution) => {
|
||||
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
|
||||
{ distribution },
|
||||
);
|
||||
});
|
||||
|
||||
it('picks symlog for skewed values that include zero', () => {
|
||||
expect(
|
||||
resolveAxisDistribution(
|
||||
[0, 0, 1, 2, 5, 10, 50, 100, 10_000],
|
||||
ScatterAxisScale.Auto,
|
||||
),
|
||||
).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('picks symlog when a soft limit reaches zero or below', () => {
|
||||
expect(
|
||||
resolveAxisDistribution(SKEWED, ScatterAxisScale.Auto, [0]),
|
||||
).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
|
||||
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(SKEWED)).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { ScatterAxisScale, type ScatterSeries } from '../types';
|
||||
import { buildScatterConfig } 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('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({
|
||||
y: {
|
||||
unit: 'ms',
|
||||
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
|
||||
},
|
||||
}).getConfig();
|
||||
|
||||
expect(config.hooks?.draw).toHaveLength(1);
|
||||
expect(build().getConfig().hooks?.draw).toBeUndefined();
|
||||
});
|
||||
|
||||
it('registers one draw hook per axis with thresholds', () => {
|
||||
const config = build({
|
||||
x: { unit: 'reqps', thresholds: [{ thresholdValue: 15 }] },
|
||||
y: { unit: 'ms', thresholds: [{ thresholdValue: 300 }] },
|
||||
}).getConfig();
|
||||
|
||||
expect(config.hooks?.draw).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('stretches the x range to reach an x threshold', () => {
|
||||
const config = build({
|
||||
x: { unit: 'reqps', thresholds: [{ thresholdValue: 1000 }] },
|
||||
}).getConfig();
|
||||
const range = config.scales?.x?.range as uPlot.Range.Function;
|
||||
const plot = { bbox: { width: 0, height: 0 } } as uPlot;
|
||||
const [, max] = range(plot, 10, 30, 'x');
|
||||
|
||||
expect(max).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { createScatterRange } from '../scatterRange';
|
||||
import type { ScatterRangeOptions } from '../types';
|
||||
|
||||
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
|
||||
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
|
||||
|
||||
function rangeOf(
|
||||
options: Partial<ScatterRangeOptions>,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
): [number, number] {
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
...options,
|
||||
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
|
||||
return range(plot, dataMin, dataMax);
|
||||
}
|
||||
|
||||
describe('createScatterRange', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
|
||||
});
|
||||
|
||||
it('widens a linear range so the data sits marginPx inside each edge', () => {
|
||||
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
|
||||
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
|
||||
});
|
||||
|
||||
it('measures the margin along its own axis', () => {
|
||||
// 400px of data inside 420px.
|
||||
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
|
||||
});
|
||||
|
||||
it('pads a log range in decades, without snapping to the next power of ten', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic },
|
||||
1,
|
||||
10,
|
||||
);
|
||||
expect(Math.log10(min)).toBeCloseTo(-0.05);
|
||||
expect(Math.log10(max)).toBeCloseTo(1.05);
|
||||
});
|
||||
|
||||
it('pads a symmetric log range in its asinh space', () => {
|
||||
const [min, max] = rangeOf(
|
||||
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
|
||||
0,
|
||||
20,
|
||||
);
|
||||
const span = Math.asinh(10);
|
||||
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
|
||||
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
|
||||
});
|
||||
|
||||
it('stretches to the soft limits before padding', () => {
|
||||
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
|
||||
-10, 210,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores a soft min at or below zero on a log scale', () => {
|
||||
const [min] = rangeOf(
|
||||
{ distribution: DistributionType.Logarithmic, softMin: 0 },
|
||||
10,
|
||||
100,
|
||||
);
|
||||
expect(min).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('spreads a single value around itself', () => {
|
||||
const [min, max] = rangeOf({}, 5, 5);
|
||||
expect(min).toBeLessThan(5);
|
||||
expect(max).toBeGreaterThan(5);
|
||||
expect(5 - min).toBeCloseTo(max - 5);
|
||||
});
|
||||
|
||||
it('falls back to a unit range with no data', () => {
|
||||
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
|
||||
expect(
|
||||
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
|
||||
).toStrictEqual([1, 10]);
|
||||
});
|
||||
|
||||
it('skips the margin on a plot too small to hold it', () => {
|
||||
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
|
||||
const range = createScatterRange({
|
||||
dimension: 'y',
|
||||
marginPx: 10,
|
||||
distribution: DistributionType.Linear,
|
||||
}) as (u: uPlot, min: number, max: number) => [number, number];
|
||||
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { ScatterSeries } from '../types';
|
||||
import { getCursorHit, prepareScatterChartData } 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();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { getScaleTransform } from './scatterRange';
|
||||
import { type AxisDistribution, ScatterAxisScale } from './types';
|
||||
|
||||
const LINEAR_DISTRIBUTION: AxisDistribution = {
|
||||
distribution: DistributionType.Linear,
|
||||
};
|
||||
|
||||
/** Orders of magnitude `auto` needs before it considers leaving linear. */
|
||||
export const AUTO_MIN_DECADES = 2;
|
||||
|
||||
/** How many times more axis the middle half of the points must cover for `auto` to leave linear. */
|
||||
export const AUTO_SPREAD_GAIN = 2;
|
||||
|
||||
function getMagnitudeRange(values: number[]): {
|
||||
minMagnitude: number;
|
||||
maxMagnitude: number;
|
||||
hasNonPositive: boolean;
|
||||
} {
|
||||
let minMagnitude = Infinity;
|
||||
let maxMagnitude = -Infinity;
|
||||
let hasNonPositive = false;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
hasNonPositive = true;
|
||||
}
|
||||
if (value !== 0) {
|
||||
minMagnitude = Math.min(minMagnitude, Math.abs(value));
|
||||
maxMagnitude = Math.max(maxMagnitude, Math.abs(value));
|
||||
}
|
||||
}
|
||||
return { minMagnitude, maxMagnitude, hasNonPositive };
|
||||
}
|
||||
|
||||
/** The linear band ends at the smallest non-zero magnitude so small values still spread. */
|
||||
function symmetricLogDistribution(minMagnitude: number): AxisDistribution {
|
||||
const asinhThreshold = Number.isFinite(minMagnitude)
|
||||
? 10 ** Math.floor(Math.log10(minMagnitude))
|
||||
: 1;
|
||||
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
|
||||
}
|
||||
|
||||
function quantile(sorted: number[], q: number): number {
|
||||
const position = (sorted.length - 1) * q;
|
||||
const lower = Math.floor(position);
|
||||
const upper = Math.ceil(position);
|
||||
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
|
||||
}
|
||||
|
||||
/** Share of the axis the middle half of the points covers. */
|
||||
function middleHalfSpread(
|
||||
sorted: number[],
|
||||
[min, max]: [number, number],
|
||||
distribution: AxisDistribution,
|
||||
): number {
|
||||
const { forward } = getScaleTransform(distribution);
|
||||
const span = forward(max) - forward(min);
|
||||
return span > 0
|
||||
? (forward(quantile(sorted, 0.75)) - forward(quantile(sorted, 0.25))) / span
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log (symlog with zero or negatives) when it spreads the points out clearly
|
||||
* more than linear does; min/max alone would let one outlier decide.
|
||||
*/
|
||||
function resolveAutoDistribution(
|
||||
values: number[],
|
||||
softLimits: number[],
|
||||
): AxisDistribution {
|
||||
const sorted = values
|
||||
.filter((value) => Number.isFinite(value))
|
||||
.sort((a, b) => a - b);
|
||||
if (sorted.length < 2) {
|
||||
return LINEAR_DISTRIBUTION;
|
||||
}
|
||||
const { minMagnitude, maxMagnitude, hasNonPositive } = getMagnitudeRange([
|
||||
...sorted,
|
||||
...softLimits,
|
||||
]);
|
||||
if (!(Math.log10(maxMagnitude / minMagnitude) >= AUTO_MIN_DECADES)) {
|
||||
return LINEAR_DISTRIBUTION;
|
||||
}
|
||||
const candidate = hasNonPositive
|
||||
? symmetricLogDistribution(minMagnitude)
|
||||
: { distribution: DistributionType.Logarithmic };
|
||||
const extent: [number, number] = [
|
||||
Math.min(sorted[0], ...softLimits),
|
||||
Math.max(sorted[sorted.length - 1], ...softLimits),
|
||||
];
|
||||
const candidateSpread = middleHalfSpread(sorted, extent, candidate);
|
||||
const linearSpread = middleHalfSpread(sorted, extent, LINEAR_DISTRIBUTION);
|
||||
return candidateSpread > 0 &&
|
||||
candidateSpread >= AUTO_SPREAD_GAIN * linearSpread
|
||||
? candidate
|
||||
: LINEAR_DISTRIBUTION;
|
||||
}
|
||||
|
||||
/** `log` falls back to symlog rather than drop zero or negatives; callers filter first to drop them. */
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
scale: ScatterAxisScale = ScatterAxisScale.Auto,
|
||||
/** Soft limits and thresholds the axis must reach; only `auto` weighs them. */
|
||||
softLimits: Array<number | null | undefined> = [],
|
||||
): AxisDistribution {
|
||||
switch (scale) {
|
||||
case ScatterAxisScale.Linear:
|
||||
return LINEAR_DISTRIBUTION;
|
||||
case ScatterAxisScale.SymLog:
|
||||
return symmetricLogDistribution(getMagnitudeRange(values).minMagnitude);
|
||||
case ScatterAxisScale.Log: {
|
||||
const { minMagnitude, hasNonPositive } = getMagnitudeRange(values);
|
||||
return hasNonPositive
|
||||
? symmetricLogDistribution(minMagnitude)
|
||||
: { distribution: DistributionType.Logarithmic };
|
||||
}
|
||||
case ScatterAxisScale.Auto:
|
||||
default:
|
||||
return resolveAutoDistribution(
|
||||
values,
|
||||
softLimits.filter(
|
||||
(limit): limit is number => limit != null && Number.isFinite(limit),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
import { DEFAULT_ASINH_THRESHOLD } from 'lib/uPlotV2/utils/scale';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type {
|
||||
AxisDistribution,
|
||||
ScaleTransform,
|
||||
ScatterRangeOptions,
|
||||
} from './types';
|
||||
|
||||
export function getScaleTransform({
|
||||
distribution,
|
||||
asinhThreshold = DEFAULT_ASINH_THRESHOLD,
|
||||
}: 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Data extent padded by `marginPx` so edge dots draw whole; log stays unsnapped (snapping after the margin can add an empty decade). */
|
||||
export function createScatterRange({
|
||||
dimension,
|
||||
marginPx,
|
||||
softMin,
|
||||
softMax,
|
||||
...distribution
|
||||
}: ScatterRangeOptions): uPlot.Scale.Range {
|
||||
const transform = getScaleTransform(distribution);
|
||||
const isLog = distribution.distribution === DistributionType.Logarithmic;
|
||||
|
||||
// A log scale cannot place a soft limit at or below zero.
|
||||
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
|
||||
|
||||
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
|
||||
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
|
||||
if (!extent) {
|
||||
return isLog ? [1, 10] : [0, 1];
|
||||
}
|
||||
|
||||
let start = transform.forward(extent[0]);
|
||||
let end = transform.forward(extent[1]);
|
||||
if (start === end) {
|
||||
start -= SINGLE_VALUE_HALF_SPAN;
|
||||
end += SINGLE_VALUE_HALF_SPAN;
|
||||
}
|
||||
|
||||
const plotPx =
|
||||
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
|
||||
if (plotPx > 2 * marginPx) {
|
||||
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
|
||||
start -= pad;
|
||||
end += pad;
|
||||
}
|
||||
|
||||
return [transform.inverse(start), transform.inverse(end)];
|
||||
};
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import type {
|
||||
DistributionType,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import type { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import type { ScatterPointSize } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
|
||||
export interface ScatterSeries {
|
||||
label: string;
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
/** Optional third channel, in the caller's units. */
|
||||
sizes?: Array<number | null>;
|
||||
}
|
||||
|
||||
export enum ScatterAxisScale {
|
||||
/** Log or symlog when it spreads the points out more than linear does. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
Log = 'log',
|
||||
/** Log-like, but places zero and negatives. */
|
||||
SymLog = 'symlog',
|
||||
}
|
||||
|
||||
export interface ScatterAxisOptions {
|
||||
label?: string;
|
||||
unit?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
scale?: ScatterAxisScale;
|
||||
/** Lines drawn across the plot at values on this axis. */
|
||||
thresholds?: Threshold[];
|
||||
}
|
||||
|
||||
export interface BuildScatterConfigArgs {
|
||||
id: string;
|
||||
series: ScatterSeries[];
|
||||
isDarkMode: boolean;
|
||||
x: ScatterAxisOptions;
|
||||
y: ScatterAxisOptions;
|
||||
pointSize?: ScatterPointSize;
|
||||
/** 0–1. */
|
||||
fillOpacity?: number;
|
||||
colorMapping?: Record<string, string>;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
export interface AxisDistribution {
|
||||
distribution: DistributionType;
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
export interface ScaleTransform {
|
||||
forward: (value: number) => number;
|
||||
inverse: (position: number) => number;
|
||||
}
|
||||
|
||||
export interface ScatterRangeOptions extends AxisDistribution {
|
||||
dimension: 'x' | 'y';
|
||||
/** CSS pixels kept clear at each end so the largest dot draws whole. */
|
||||
marginPx: number;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import {
|
||||
type AxisProps,
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
import {
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterChartData,
|
||||
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 { resolveAxisDistribution } from './axisDistribution';
|
||||
import { createScatterRange } from './scatterRange';
|
||||
import type {
|
||||
AxisDistribution,
|
||||
BuildScatterConfigArgs,
|
||||
ScatterAxisOptions,
|
||||
ScatterSeries,
|
||||
} from './types';
|
||||
|
||||
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;
|
||||
|
||||
/** The plugin's `dataIdx` answers only for the hit series. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
: {};
|
||||
}
|
||||
|
||||
function getThresholdOptions(
|
||||
scaleKey: 'x' | 'y',
|
||||
{ thresholds, unit }: ScatterAxisOptions,
|
||||
): ThresholdsDrawHookOptions | undefined {
|
||||
return thresholds && thresholds.length > 0
|
||||
? { scaleKey, thresholds, yAxisUnit: unit }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
isDarkMode,
|
||||
x,
|
||||
y,
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
fillOpacity,
|
||||
colorMapping = {},
|
||||
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 xSoftLimits = adjustSoftLimitsWithThresholds(
|
||||
x.softMin ?? null,
|
||||
x.softMax ?? null,
|
||||
x.thresholds,
|
||||
x.unit,
|
||||
);
|
||||
const ySoftLimits = adjustSoftLimitsWithThresholds(
|
||||
y.softMin ?? null,
|
||||
y.softMax ?? null,
|
||||
y.thresholds,
|
||||
y.unit,
|
||||
);
|
||||
const xDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.xs),
|
||||
x.scale,
|
||||
[xSoftLimits.softMin, xSoftLimits.softMax],
|
||||
);
|
||||
const yDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.ys),
|
||||
y.scale,
|
||||
[ySoftLimits.softMin, ySoftLimits.softMax],
|
||||
);
|
||||
|
||||
const xThresholds = getThresholdOptions('x', x);
|
||||
const yThresholds = getThresholdOptions('y', y);
|
||||
|
||||
// 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,
|
||||
range: createScatterRange({
|
||||
dimension: 'x',
|
||||
marginPx,
|
||||
...xSoftLimits,
|
||||
...xDistribution,
|
||||
}),
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
[xThresholds, yThresholds].forEach((options) => {
|
||||
if (options) {
|
||||
builder.addThresholds(options);
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -16,10 +16,6 @@ import type {
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -92,26 +88,6 @@ 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;
|
||||
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;
|
||||
resolvePointGroupValues?: (
|
||||
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
|
||||
|
||||
@@ -40,8 +40,6 @@ interface ConfigPaneProps {
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Group-by columns of the joined scalar result. */
|
||||
groupColumns?: string[];
|
||||
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
|
||||
stepInterval?: number;
|
||||
/**
|
||||
@@ -69,7 +67,6 @@ function ConfigPane({
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
groupColumns,
|
||||
stepInterval,
|
||||
panel,
|
||||
panelId,
|
||||
@@ -136,7 +133,6 @@ function ConfigPane({
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
@@ -164,7 +160,6 @@ function ConfigPane({
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
|
||||
@@ -12,11 +12,6 @@ import type { SectionEditorContext } from '../sectionContext';
|
||||
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import { isDifferent } from '../utils/changes';
|
||||
import {
|
||||
getScatterAxisColumnNames,
|
||||
getScatterAxisColumnUnits,
|
||||
} from '../utils/scatterAxisColumns';
|
||||
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
type SectionSlotProps = {
|
||||
@@ -68,7 +63,6 @@ function SectionSlot({
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
groupColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
@@ -80,10 +74,7 @@ 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 ||
|
||||
config.kind === SectionKind.Dimensions
|
||||
) {
|
||||
if (config.kind === SectionKind.Visualization) {
|
||||
return true;
|
||||
}
|
||||
const value = editor?.get(spec);
|
||||
@@ -138,10 +129,6 @@ function SectionSlot({
|
||||
legendSeries,
|
||||
yAxisUnit,
|
||||
tableColumns,
|
||||
groupColumns,
|
||||
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
|
||||
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
|
||||
axisColumnUnits: getScatterAxisColumnUnits(spec, tableColumns),
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ComboboxSimple } from '@signozhq/ui/combobox';
|
||||
|
||||
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[];
|
||||
placeholder?: string;
|
||||
items: ConfigMultiSelectItem<T>[];
|
||||
onChange: (value: T[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Emits picks in items order, so the stored value is stable. */
|
||||
function ConfigMultiSelect<T extends string = string>({
|
||||
testId,
|
||||
value,
|
||||
placeholder,
|
||||
items,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ConfigMultiSelectProps<T>): JSX.Element {
|
||||
return (
|
||||
<ComboboxSimple
|
||||
multiple
|
||||
className={styles.select}
|
||||
testId={testId}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
items={items}
|
||||
onChange={(next): void => {
|
||||
const picked = Array.isArray(next) ? next : [];
|
||||
onChange(
|
||||
items
|
||||
.map((item) => item.value)
|
||||
.filter((candidate) => picked.includes(candidate)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigMultiSelect;
|
||||
@@ -1,48 +0,0 @@
|
||||
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'));
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
// Cancel the slider's half-thumb track inset so it aligns with the other controls.
|
||||
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);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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;
|
||||
formatValue?: (value: [number, number]) => string;
|
||||
onChange: (value: [number, number]) => void;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { render, screen, userEvent, within } 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(
|
||||
within(screen.getByTestId('range')).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] = within(screen.getByTestId('range')).getAllByRole(
|
||||
'slider',
|
||||
);
|
||||
maxThumb.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
|
||||
});
|
||||
});
|
||||
@@ -13,14 +13,6 @@ 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[];
|
||||
/** Scatter Plot's size column label, if bound. */
|
||||
sizeColumnLabel?: string;
|
||||
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
|
||||
axisColumnNames?: { x?: string; y?: string };
|
||||
/** Unit of the column each Scatter Plot axis plots. */
|
||||
axisColumnUnits?: { x?: string; y?: string };
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
|
||||
@@ -4,8 +4,6 @@ import type {
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesScatterPlotAxesDTO,
|
||||
DashboardtypesScatterPlotDimensionsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
@@ -24,11 +22,9 @@ 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';
|
||||
@@ -90,20 +86,6 @@ 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 =>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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,
|
||||
@@ -40,9 +39,8 @@ function ChartAppearanceSection({
|
||||
controls,
|
||||
onChange,
|
||||
stepInterval,
|
||||
sizeColumnLabel,
|
||||
}: SectionEditorProps<SectionKind.ChartAppearance> &
|
||||
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
|
||||
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
const interpolation = value?.lineInterpolation;
|
||||
|
||||
@@ -124,15 +122,6 @@ 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"
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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;
|
||||
sizeColumnLabel?: string;
|
||||
}
|
||||
|
||||
function PointsFields({
|
||||
value,
|
||||
savedValue,
|
||||
onChange,
|
||||
sizeColumnLabel,
|
||||
}: PointsFieldsProps): JSX.Element {
|
||||
const reset = createFieldResetter(value, savedValue, onChange);
|
||||
// Renderer defaults, so thumbs start 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;
|
||||
@@ -1,117 +0,0 @@
|
||||
import { render, screen, userEvent, within } 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 = within(
|
||||
screen.getByTestId('panel-editor-v2-point-size'),
|
||||
).getByRole('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 opacityThumb = within(
|
||||
screen.getByTestId('panel-editor-v2-point-opacity'),
|
||||
).getByRole('slider');
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,3 @@ 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`;
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.labelWithInfo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex-shrink: 0;
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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 LabelWithInfo from './LabelWithInfo';
|
||||
import {
|
||||
buildColorKeyItems,
|
||||
buildDimensionItems,
|
||||
formatAutoOption,
|
||||
formatColumnOption,
|
||||
UNSET_DIMENSION,
|
||||
} from './utils';
|
||||
|
||||
import styles from './DimensionsSection.module.scss';
|
||||
|
||||
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.',
|
||||
};
|
||||
|
||||
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"
|
||||
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;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { Tooltip } 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}
|
||||
<Tooltip title={info}>
|
||||
<Info size={12} className={styles.info} data-testid={testId} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelWithInfo;
|
||||
@@ -1,184 +0,0 @@
|
||||
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']}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-dimension-colorBy'));
|
||||
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'),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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';
|
||||
}
|
||||
|
||||
/** Keeps a bound key the result lacks, 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;
|
||||
}
|
||||
|
||||
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)` })),
|
||||
];
|
||||
}
|
||||
@@ -152,7 +152,7 @@ describe('FormattingSection', () => {
|
||||
<FormattingSection
|
||||
value={{ columnUnits: { A: 'By' } }}
|
||||
controls={{ columnUnits: true }}
|
||||
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
|
||||
tableColumns={[{ key: 'A', label: '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', name: 'A' }]}
|
||||
tableColumns={[{ key: 'A', label: 'A' }]}
|
||||
metricUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.axes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.axis + .axis {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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'>;
|
||||
|
||||
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;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
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;
|
||||
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
|
||||
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;
|
||||
@@ -1,171 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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.";
|
||||
|
||||
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
|
||||
...AXIS_SCALE_HELP,
|
||||
[DashboardtypesAxisScaleDTO.auto]:
|
||||
'Logarithmic (Symlog with zero or negatives) when that spreads the points out more than linear does; otherwise linear.',
|
||||
[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.",
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
DashboardtypesAxisScaleDTO,
|
||||
type DashboardtypesScatterPlotAxisDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export function hasBoundALogAxisDrops(
|
||||
scale: DashboardtypesAxisScaleDTO,
|
||||
axis: DashboardtypesScatterPlotAxisDTO | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
scale === DashboardtypesAxisScaleDTO.log &&
|
||||
[axis?.softMin, axis?.softMax].some(
|
||||
(bound) => typeof bound === 'number' && bound <= 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,9 @@ import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
type DashboardtypesScatterPlotThresholdDTO,
|
||||
type DashboardtypesTableThresholdDTO,
|
||||
DashboardtypesThresholdAxisDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AnyThreshold,
|
||||
@@ -45,7 +44,6 @@ const ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
function defaultThreshold(
|
||||
variant: ThresholdVariant,
|
||||
tableColumns: TableColumnOption[],
|
||||
withAxis: boolean,
|
||||
): AnyThreshold {
|
||||
switch (variant) {
|
||||
case 'comparison':
|
||||
@@ -64,23 +62,18 @@ function defaultThreshold(
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
value: 0,
|
||||
color: DEFAULT_THRESHOLD_COLOR,
|
||||
label: '',
|
||||
...(withAxis && { axis: DashboardtypesThresholdAxisDTO.y }),
|
||||
};
|
||||
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
|
||||
}
|
||||
}
|
||||
|
||||
type ThresholdsSectionProps = {
|
||||
value: AnyThreshold[] | undefined;
|
||||
/** `variant` picks the row editor + element shape; defaults to `label`. */
|
||||
controls?: { variant?: ThresholdVariant; axis?: boolean };
|
||||
controls?: { variant?: ThresholdVariant };
|
||||
onChange: (next: AnyThreshold[]) => void;
|
||||
} & Pick<
|
||||
SectionEditorContext,
|
||||
'yAxisUnit' | 'tableColumns' | 'axisColumnUnits' | 'registerHeaderAction'
|
||||
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -95,11 +88,9 @@ function ThresholdsSection({
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
axisColumnUnits,
|
||||
registerHeaderAction,
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? ThresholdVariant.LABEL;
|
||||
const withAxis = !!controls?.axis;
|
||||
const thresholds = value ?? [];
|
||||
// Which row is being edited, and whether it was just added (so Discard removes it).
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -116,10 +107,10 @@ function ThresholdsSection({
|
||||
|
||||
const addThreshold = useCallback((): void => {
|
||||
const current = value ?? [];
|
||||
onChange([...current, defaultThreshold(variant, tableColumns, withAxis)]);
|
||||
onChange([...current, defaultThreshold(variant, tableColumns)]);
|
||||
setEditingIndex(current.length);
|
||||
setUnsavedIndex(current.length);
|
||||
}, [value, onChange, variant, tableColumns, withAxis]);
|
||||
}, [value, onChange, variant, tableColumns]);
|
||||
|
||||
useEffect(() => {
|
||||
registerHeaderAction?.(addThreshold);
|
||||
@@ -198,9 +189,7 @@ function ThresholdsSection({
|
||||
<LabelThresholdRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
threshold={threshold as DashboardtypesScatterPlotThresholdDTO}
|
||||
showAxis={withAxis}
|
||||
axisUnits={axisColumnUnits}
|
||||
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -173,52 +173,3 @@ describe('ThresholdsSection', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThresholdsSection with an axis picker', () => {
|
||||
function AxisHarness({
|
||||
onChange,
|
||||
}: {
|
||||
onChange: (next: AnyThreshold[]) => void;
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
return (
|
||||
<ThresholdsSection
|
||||
value={value}
|
||||
controls={{ axis: true }}
|
||||
axisColumnUnits={{ x: 'reqps', y: 'ms' }}
|
||||
onChange={(next): void => {
|
||||
setValue(next);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it('adds a y threshold and saves the picked axis', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<AxisHarness onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ axis: 'y' }),
|
||||
]);
|
||||
|
||||
await user.click(screen.getByTestId('threshold-axis-0-x'));
|
||||
await user.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ axis: 'x' }),
|
||||
]);
|
||||
expect(screen.getByText('X axis')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the axis picker without the control', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
|
||||
expect(screen.queryByTestId('threshold-axis-0')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
type DashboardtypesScatterPlotThresholdDTO,
|
||||
DashboardtypesThresholdAxisDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatPanelValue } from 'pages/DashboardPage/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import ThresholdAmountField from './shared/ThresholdAmountField';
|
||||
import ThresholdAxisField from './shared/ThresholdAxisField';
|
||||
import ThresholdColorField from './shared/ThresholdColorField';
|
||||
import ThresholdMarker from './shared/ThresholdMarker';
|
||||
import ThresholdRowShell from './shared/ThresholdRowShell';
|
||||
@@ -16,22 +12,16 @@ import { useThresholdDraft } from './shared/useThresholdDraft';
|
||||
|
||||
import styles from '../ThresholdsSection.module.scss';
|
||||
|
||||
/** `axis` is set only where the row picks one (Scatter Plot). */
|
||||
type LabelThreshold = DashboardtypesScatterPlotThresholdDTO;
|
||||
|
||||
interface LabelThresholdRowProps {
|
||||
index: number;
|
||||
threshold: LabelThreshold;
|
||||
threshold: DashboardtypesThresholdWithLabelDTO;
|
||||
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
|
||||
yAxisUnit?: string;
|
||||
/** Shows the axis picker; the unit picker then scopes to the picked axis's unit. */
|
||||
showAxis?: boolean;
|
||||
axisUnits?: { x?: string; y?: string };
|
||||
isEditing: boolean;
|
||||
isNew: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: LabelThreshold) => void;
|
||||
onLiveChange: (next: LabelThreshold) => void;
|
||||
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
|
||||
onLiveChange: (next: DashboardtypesThresholdWithLabelDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
@@ -41,8 +31,6 @@ function LabelThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
yAxisUnit,
|
||||
showAxis,
|
||||
axisUnits,
|
||||
isEditing,
|
||||
isNew,
|
||||
onEdit,
|
||||
@@ -62,17 +50,6 @@ function LabelThresholdRow({
|
||||
onSave({ ...draft, label: draft.label ?? '' });
|
||||
}, [onSave, draft]);
|
||||
|
||||
const draftAxis = draft.axis ?? DashboardtypesThresholdAxisDTO.y;
|
||||
const scopeUnit = showAxis ? axisUnits?.[draftAxis] : yAxisUnit;
|
||||
const subtitle = showAxis
|
||||
? [
|
||||
`${(threshold.axis ?? DashboardtypesThresholdAxisDTO.y).toUpperCase()} axis`,
|
||||
threshold.label,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: threshold.label;
|
||||
|
||||
return (
|
||||
<ThresholdRowShell
|
||||
index={index}
|
||||
@@ -81,19 +58,12 @@ function LabelThresholdRow({
|
||||
isEditing={isEditing}
|
||||
isNew={isNew}
|
||||
title={`Line at ${formatPanelValue(threshold.value, threshold.unit)}`}
|
||||
subtitle={subtitle}
|
||||
subtitle={threshold.label}
|
||||
onEdit={onEdit}
|
||||
onSave={handleSave}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
{showAxis && (
|
||||
<ThresholdAxisField
|
||||
testId={`threshold-axis-${index}`}
|
||||
value={draftAxis}
|
||||
onChange={(axis): void => setDraft((d) => ({ ...d, axis }))}
|
||||
/>
|
||||
)}
|
||||
<ThresholdAmountField
|
||||
label="Draw a line at"
|
||||
testIdPrefix="threshold"
|
||||
@@ -101,8 +71,8 @@ function LabelThresholdRow({
|
||||
value={draft.value}
|
||||
onValueChange={setValue}
|
||||
unit={draft.unit}
|
||||
scopeUnit={scopeUnit}
|
||||
scopeLabel={`${showAxis ? draftAxis : 'y'}-axis unit`}
|
||||
scopeUnit={yAxisUnit}
|
||||
scopeLabel="y-axis unit"
|
||||
onUnitChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
|
||||
/>
|
||||
<ThresholdColorField
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesThresholdAxisDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigTiles, {
|
||||
type ConfigTileItem,
|
||||
} from '../../../../controls/ConfigTiles/ConfigTiles';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
const AXIS_TILES: ConfigTileItem<DashboardtypesThresholdAxisDTO>[] = [
|
||||
{ value: DashboardtypesThresholdAxisDTO.x, label: 'X axis' },
|
||||
{ value: DashboardtypesThresholdAxisDTO.y, label: 'Y axis' },
|
||||
];
|
||||
|
||||
interface ThresholdAxisFieldProps {
|
||||
testId: string;
|
||||
value: DashboardtypesThresholdAxisDTO;
|
||||
onChange: (axis: DashboardtypesThresholdAxisDTO) => void;
|
||||
}
|
||||
|
||||
function ThresholdAxisField({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: ThresholdAxisFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Axis</Typography.Text>
|
||||
<ConfigTiles
|
||||
compact
|
||||
testId={testId}
|
||||
aria-label="Axis"
|
||||
value={value}
|
||||
items={AXIS_TILES}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdAxisField;
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
getScatterAxisColumnNames,
|
||||
getScatterAxisColumnUnits,
|
||||
} from '../scatterAxisColumns';
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
|
||||
{ key: 'B', label: 'B.p99 latency', name: 'p99 latency', unit: 'ms' },
|
||||
{ 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({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScatterAxisColumnUnits', () => {
|
||||
it('reads the unit of the column each axis plots', () => {
|
||||
expect(
|
||||
getScatterAxisColumnUnits(scatterSpec({ x: 'B', y: 'A' }), COLUMNS),
|
||||
).toStrictEqual({ x: 'ms', y: undefined });
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
|
||||
/** Mirrors the renderer: bound key, else X = first value column, Y = first other. */
|
||||
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 };
|
||||
}
|
||||
|
||||
function resolveScatterAxisColumns(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
tableColumns: TableColumnOption[],
|
||||
): { x?: TableColumnOption; y?: TableColumnOption } | null {
|
||||
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
|
||||
return null;
|
||||
}
|
||||
return resolveAutoAxes(tableColumns, {
|
||||
x: spec.plugin.spec.dimensions?.x,
|
||||
y: spec.plugin.spec.dimensions?.y,
|
||||
});
|
||||
}
|
||||
|
||||
export function getScatterAxisColumnNames(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
tableColumns: TableColumnOption[] = [],
|
||||
): { x?: string; y?: string } {
|
||||
const axes = resolveScatterAxisColumns(spec, tableColumns);
|
||||
return axes ? { x: axes.x?.name, y: axes.y?.name } : {};
|
||||
}
|
||||
|
||||
export function getScatterAxisColumnUnits(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
tableColumns: TableColumnOption[] = [],
|
||||
): { x?: string; y?: string } {
|
||||
const axes = resolveScatterAxisColumns(spec, tableColumns);
|
||||
return axes ? { x: axes.x?.unit, y: axes.y?.unit } : {};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import PanelEditorLayout, {
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { useGroupColumns } from './hooks/useGroupColumns';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
@@ -200,7 +199,6 @@ 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
|
||||
@@ -332,7 +330,6 @@ function QueryEditorBody({
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
groupColumns={groupColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
|
||||
import type {
|
||||
PanelQueryData,
|
||||
PanelTable,
|
||||
} from 'pages/DashboardPage/DashboardContainer/queryV5/types';
|
||||
import { renderHook } from 'tests/test-utils';
|
||||
|
||||
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 = jest.mocked(prepareScalarTables);
|
||||
|
||||
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', () => {
|
||||
const empty: PanelTable = {
|
||||
queryName: 'A',
|
||||
legend: '',
|
||||
columns: [],
|
||||
rows: [],
|
||||
};
|
||||
mockPrepareScalarTables.mockReturnValue([
|
||||
empty,
|
||||
{
|
||||
...empty,
|
||||
columns: [
|
||||
{
|
||||
id: 'k8s.namespace.name',
|
||||
name: 'k8s.namespace.name',
|
||||
queryName: '',
|
||||
isValueColumn: false,
|
||||
},
|
||||
{ id: 'A', name: 'cpu', queryName: 'A', isValueColumn: true },
|
||||
{
|
||||
id: 'k8s.pod.name',
|
||||
name: 'k8s.pod.name',
|
||||
queryName: '',
|
||||
isValueColumn: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
|
||||
);
|
||||
|
||||
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user