mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-19 18:00:41 +01:00
Compare commits
174 Commits
qf-scroll-
...
feat/heatm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ec5756645 | ||
|
|
939a244a18 | ||
|
|
688dba6e13 | ||
|
|
b051e14643 | ||
|
|
b72456f935 | ||
|
|
a10be0d95d | ||
|
|
73373f654f | ||
|
|
30bc4411d5 | ||
|
|
90a55ce72d | ||
|
|
67afca0016 | ||
|
|
4f04962e73 | ||
|
|
ff8ec64c2e | ||
|
|
02069e0a5e | ||
|
|
a09d5338b3 | ||
|
|
0241b8081c | ||
|
|
4260363b45 | ||
|
|
19c4aed27c | ||
|
|
f7b68ee687 | ||
|
|
232122ecda | ||
|
|
42fdcdefef | ||
|
|
30ad2a303d | ||
|
|
d39938d462 | ||
|
|
e81e9a2fd3 | ||
|
|
00718c0662 | ||
|
|
55a0c8dd35 | ||
|
|
9f1b1476f4 | ||
|
|
0bea671a2d | ||
|
|
0b1695c9c2 | ||
|
|
b4f42cf388 | ||
|
|
65a8a58600 | ||
|
|
a3c90ab132 | ||
|
|
da4c5578d3 | ||
|
|
bf01867c3c | ||
|
|
41187a5fca | ||
|
|
d04dc6f7db | ||
|
|
71badcf65c | ||
|
|
68fe15d5fc | ||
|
|
f8aded8c8e | ||
|
|
f6a6793e96 | ||
|
|
452046d70c | ||
|
|
2e9c9067f3 | ||
|
|
2ddd45cb8c | ||
|
|
8809d8a7ac | ||
|
|
21edb05f2b | ||
|
|
f659205866 | ||
|
|
215f6f8e0f | ||
|
|
802f7e4a3d | ||
|
|
81f1105fbc | ||
|
|
575aa57426 | ||
|
|
f75d3d8724 | ||
|
|
43538b92db | ||
|
|
ef65c2c08c | ||
|
|
8e41d3d30d | ||
|
|
b249ab8275 | ||
|
|
61bca035a3 | ||
|
|
9983b3344b | ||
|
|
1313a289b6 | ||
|
|
82a789c19e | ||
|
|
ec09873573 | ||
|
|
00cc773792 | ||
|
|
cf5dd79216 | ||
|
|
6546a0abc6 | ||
|
|
3874f3a6ec | ||
|
|
86db0fd26b | ||
|
|
f382a958db | ||
|
|
7c3481b230 | ||
|
|
24f3116527 | ||
|
|
17f0f49398 | ||
|
|
f681bacdcb | ||
|
|
ded4ed3d76 | ||
|
|
1f84f9c166 | ||
|
|
87127b580c | ||
|
|
fc4bd13fce | ||
|
|
075a4b164d | ||
|
|
2170189d14 | ||
|
|
b7720b655e | ||
|
|
f34973b897 | ||
|
|
d9d041a8fa | ||
|
|
a0e66e3592 | ||
|
|
a0dff26fbc | ||
|
|
db0b1b2b2a | ||
|
|
fd8ed18592 | ||
|
|
392b5826fd | ||
|
|
4b4023d706 | ||
|
|
f8d803d787 | ||
|
|
a33320c665 | ||
|
|
695a481989 | ||
|
|
3fc763b95b | ||
|
|
027b65574d | ||
|
|
43ec98b309 | ||
|
|
a196a436be | ||
|
|
3f456b6602 | ||
|
|
f777e068c3 | ||
|
|
5a690695e6 | ||
|
|
bf362d15db | ||
|
|
7e0f70ffa7 | ||
|
|
590046adf3 | ||
|
|
106cb9f6c2 | ||
|
|
f95699b1f7 | ||
|
|
1e75b66379 | ||
|
|
b80013d075 | ||
|
|
4a6e25708d | ||
|
|
3b8f9a7830 | ||
|
|
68bff5b053 | ||
|
|
732c8ae18a | ||
|
|
6911630c2a | ||
|
|
2d78d8add9 | ||
|
|
8e47c7d337 | ||
|
|
991e734c33 | ||
|
|
40c881b8f5 | ||
|
|
a2c8dd5275 | ||
|
|
32ae7a26d5 | ||
|
|
4067b04fa2 | ||
|
|
70409f5ee3 | ||
|
|
5d9953bb7e | ||
|
|
edfff0ee72 | ||
|
|
44556060a3 | ||
|
|
545e0342d2 | ||
|
|
2f559f34d3 | ||
|
|
e4d90b91b7 | ||
|
|
f33616a598 | ||
|
|
2ac94fc2fc | ||
|
|
7292a6946b | ||
|
|
dff49a4135 | ||
|
|
690c207519 | ||
|
|
d9b6d1f425 | ||
|
|
1eb974d91a | ||
|
|
1141f6bf14 | ||
|
|
a3fb926726 | ||
|
|
fe1e86f385 | ||
|
|
e30d9a1a14 | ||
|
|
c9a345713c | ||
|
|
22f8eba44a | ||
|
|
e8ad9044d5 | ||
|
|
576cd5052a | ||
|
|
4214ccf93d | ||
|
|
63f5c17238 | ||
|
|
fd6abd67dd | ||
|
|
a78c35d61c | ||
|
|
b2c4916a86 | ||
|
|
feff1a0179 | ||
|
|
eafac1be69 | ||
|
|
510d6985d0 | ||
|
|
758aacb065 | ||
|
|
5599c352d0 | ||
|
|
96d0a2340c | ||
|
|
c6b415ad04 | ||
|
|
9b922f297f | ||
|
|
e81a4c4046 | ||
|
|
9ba13848ea | ||
|
|
2578f25dd4 | ||
|
|
82352973bb | ||
|
|
3eb55692f1 | ||
|
|
61ceb6d300 | ||
|
|
e33fc20579 | ||
|
|
b130a61049 | ||
|
|
132e9bb740 | ||
|
|
9d577731e1 | ||
|
|
4d54568fe1 | ||
|
|
6f82624523 | ||
|
|
da0bc4f438 | ||
|
|
2dcb764632 | ||
|
|
1bc25e16b8 | ||
|
|
eebc6b400d | ||
|
|
30c4508113 | ||
|
|
c4fe619920 | ||
|
|
d597adddda | ||
|
|
9a05f33e7a | ||
|
|
3812937c6f | ||
|
|
34aa719cba | ||
|
|
cdfce4c4ab | ||
|
|
d8ef672f83 | ||
|
|
00efffc127 | ||
|
|
dec922a83f |
@@ -3552,6 +3552,79 @@ components:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHeatmapAxes:
|
||||
properties:
|
||||
yScale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
|
||||
type: object
|
||||
DashboardtypesHeatmapChartAppearance:
|
||||
properties:
|
||||
colors:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
|
||||
type: object
|
||||
DashboardtypesHeatmapColorMode:
|
||||
enum:
|
||||
- palette
|
||||
- opacity
|
||||
type: string
|
||||
DashboardtypesHeatmapColorScale:
|
||||
enum:
|
||||
- log
|
||||
- sqrt
|
||||
- linear
|
||||
type: string
|
||||
DashboardtypesHeatmapColors:
|
||||
properties:
|
||||
fill:
|
||||
type: string
|
||||
maxCount:
|
||||
nullable: true
|
||||
type: number
|
||||
minCount:
|
||||
nullable: true
|
||||
type: number
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
|
||||
palette:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
|
||||
steps:
|
||||
type: integer
|
||||
type: object
|
||||
DashboardtypesHeatmapPalette:
|
||||
enum:
|
||||
- ice
|
||||
- moss
|
||||
- rust
|
||||
- graphite
|
||||
- ember
|
||||
- lagoon
|
||||
- orchid
|
||||
- verdant
|
||||
- lava
|
||||
- beacon
|
||||
type: string
|
||||
DashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesHeatmapYScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3904,6 +3977,7 @@ components:
|
||||
discriminator:
|
||||
mapping:
|
||||
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
|
||||
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
@@ -3921,6 +3995,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3932,6 +4007,7 @@ components:
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3945,6 +4021,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/HeatmapPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
// Mock for uplot library used in tests
|
||||
export interface MockUPlotInstance {
|
||||
/** Consumers read `root.parentElement` to detect a re-mounted container. */
|
||||
root: HTMLDivElement;
|
||||
setData: jest.Mock;
|
||||
setSize: jest.Mock;
|
||||
destroy: jest.Mock;
|
||||
@@ -17,13 +19,20 @@ export interface MockUPlotPaths {
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
|
||||
const root = document.createElement('div');
|
||||
// Real uPlot mounts its root inside the target; without it a re-render reads
|
||||
// `root.parentElement` off undefined and throws.
|
||||
target?.appendChild(root);
|
||||
return {
|
||||
root,
|
||||
setData: jest.fn(),
|
||||
setSize: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
setSeries: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
@@ -53,14 +62,16 @@ const mockTzDate = jest.fn(
|
||||
function MockUPlot(
|
||||
_options: unknown,
|
||||
_data: unknown,
|
||||
_target: HTMLElement,
|
||||
target: HTMLElement,
|
||||
): MockUPlotInstance {
|
||||
return createMockUPlotInstance();
|
||||
return createMockUPlotInstance(target);
|
||||
}
|
||||
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -5072,6 +5072,83 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
export enum DashboardtypesHeatmapYScaleDTO {
|
||||
auto = 'auto',
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
symlog = 'symlog',
|
||||
}
|
||||
export interface DashboardtypesHeatmapAxesDTO {
|
||||
yScale?: DashboardtypesHeatmapYScaleDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesHeatmapColorModeDTO {
|
||||
palette = 'palette',
|
||||
opacity = 'opacity',
|
||||
}
|
||||
export enum DashboardtypesHeatmapPaletteDTO {
|
||||
ice = 'ice',
|
||||
moss = 'moss',
|
||||
rust = 'rust',
|
||||
graphite = 'graphite',
|
||||
ember = 'ember',
|
||||
lagoon = 'lagoon',
|
||||
orchid = 'orchid',
|
||||
verdant = 'verdant',
|
||||
lava = 'lava',
|
||||
beacon = 'beacon',
|
||||
}
|
||||
export enum DashboardtypesHeatmapColorScaleDTO {
|
||||
log = 'log',
|
||||
sqrt = 'sqrt',
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface DashboardtypesHeatmapColorsDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fill?: string;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
maxCount?: number | null;
|
||||
/**
|
||||
* @type number,null
|
||||
*/
|
||||
minCount?: number | null;
|
||||
mode?: DashboardtypesHeatmapColorModeDTO;
|
||||
palette?: DashboardtypesHeatmapPaletteDTO;
|
||||
scale?: DashboardtypesHeatmapColorScaleDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapChartAppearanceDTO {
|
||||
colors?: DashboardtypesHeatmapColorsDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesHeatmapPanelSpecDTO {
|
||||
axes?: DashboardtypesHeatmapAxesDTO;
|
||||
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
|
||||
formatting?: DashboardtypesPanelFormattingDTO;
|
||||
legend?: DashboardtypesLegendDTO;
|
||||
visualization?: DashboardtypesBasicVisualizationDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/HeatmapPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
|
||||
spec: DashboardtypesHeatmapPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -5080,7 +5157,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -6005,6 +6083,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
|
||||
@@ -198,6 +198,10 @@ function createBaseSpec(
|
||||
: undefined,
|
||||
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
|
||||
having: normalizeHaving(queryData.having),
|
||||
// Heatmap only. Every other request type rejects an axis, and
|
||||
// `panelTypeDataSourceFormValuesMap` is what keeps one from being carried onto a
|
||||
// query the panel type switched away from.
|
||||
bucketOptions: queryData.bucketOptions,
|
||||
functions: isEmpty(queryData.functions)
|
||||
? undefined
|
||||
: queryData.functions.map((func: QueryFunction): QueryFunction => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Borrows the query builder's control metrics rather than the component defaults the
|
||||
* toggle group and number input ship with: 36px tall, 2px radius, and the
|
||||
* `--query-builder-v2-*` surface the selects above this row already paint on.
|
||||
*/
|
||||
.bucketOptions {
|
||||
--toggle-group-radius: var(--radius-1);
|
||||
--toggle-group-item-size: 36px;
|
||||
--toggle-group-item-font-size: 13px;
|
||||
--toggle-group-item-padding-left: var(--spacing-6);
|
||||
--toggle-group-item-padding-right: var(--spacing-6);
|
||||
|
||||
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
|
||||
// formula, which has no add-ons ancestor to pick the toggle palette up from.
|
||||
--toggle-group-secondary-bg: var(
|
||||
--query-builder-v2-toggle-group-background-color,
|
||||
var(--l1-background-hover)
|
||||
);
|
||||
--toggle-group-secondary-border: var(
|
||||
--query-builder-v2-toggle-group-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--toggle-group-secondary-active-bg: var(
|
||||
--query-builder-v2-toggle-group-active-background-color,
|
||||
var(--l1-background)
|
||||
);
|
||||
--toggle-group-secondary-bg-hover: var(
|
||||
--query-builder-v2-toggle-group-background-color-hover,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
--input-height: 36px;
|
||||
--input-font-size: 13px;
|
||||
--input-border-radius: var(--radius-1);
|
||||
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
--input-background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
|
||||
padding: var(--spacing-4);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l3-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle group exposes a font-size token but no family, so each item's label carries
|
||||
* the query builder's value type itself.
|
||||
*/
|
||||
.toggleLabel {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
|
||||
.numberInput {
|
||||
width: 104px;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
/** Bounds read as a sequence of values, so they take the value type in a flat pill. */
|
||||
.bound {
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: var(--radius-1);
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overflowBound {
|
||||
background: transparent;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
max-width: 72ch;
|
||||
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.closeBtn {
|
||||
margin-left: auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { InputNumber } from '@signozhq/ui/input-number';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { ChevronUp } from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
BUCKET_KIND_HINTS,
|
||||
BUCKET_KIND_OPTIONS,
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
LOG_BANDS_OPTIONS,
|
||||
MAX_NUM_BUCKETS,
|
||||
} from './constants';
|
||||
import {
|
||||
bandsPerDoublingFromScale,
|
||||
BucketKindOption,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
isLinearBuckets,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
logScaleOf,
|
||||
previewUpperBounds,
|
||||
scaleFromBandsPerDoubling,
|
||||
} from './utils';
|
||||
|
||||
import styles from './BucketOptions.module.scss';
|
||||
|
||||
function BucketOptions({
|
||||
bucketOptions,
|
||||
unit,
|
||||
onChange,
|
||||
onClose,
|
||||
}: {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
|
||||
unit?: string;
|
||||
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
|
||||
/** Omitted where the section isn't dismissable, as on a formula. */
|
||||
onClose?: () => void;
|
||||
}): JSX.Element {
|
||||
// A linear axis has no bounds to describe until it has a max value, so the picked
|
||||
// kind is held here rather than read back off the emitted options: it has to survive
|
||||
// the gap between choosing Linear and filling the field in.
|
||||
const [kind, setKind] = useState<BucketKindOption>(
|
||||
kindOptionOf(bucketOptions),
|
||||
);
|
||||
const linearSpec =
|
||||
bucketOptions && isLinearBuckets(bucketOptions)
|
||||
? bucketOptions.spec
|
||||
: undefined;
|
||||
const [logScale, setLogScale] = useState<number>(logScaleOf(bucketOptions));
|
||||
const [maxValue, setMaxValue] = useState<number | null>(
|
||||
linearSpec?.maxValue ?? null,
|
||||
);
|
||||
const [numBuckets, setNumBuckets] = useState<number | null>(
|
||||
linearSpec?.numBuckets ?? null,
|
||||
);
|
||||
|
||||
const emitLinear = useCallback(
|
||||
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
|
||||
// An incomplete linear axis is sent as no axis at all rather than as a spec the
|
||||
// request would reject.
|
||||
if (nextMaxValue === null || nextMaxValue <= 0) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKindChange = useCallback(
|
||||
(value: string): void => {
|
||||
// Radix clears the value when the active item is clicked again; a bucket axis is
|
||||
// always one of the three, so keep the current pick instead.
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextKind = value as BucketKindOption;
|
||||
setKind(nextKind);
|
||||
|
||||
if (nextKind === 'auto') {
|
||||
onChange(undefined);
|
||||
} else if (nextKind === 'log') {
|
||||
onChange(logBuckets(logScale));
|
||||
} else {
|
||||
emitLinear(maxValue, numBuckets);
|
||||
}
|
||||
},
|
||||
[emitLinear, logScale, maxValue, numBuckets, onChange],
|
||||
);
|
||||
|
||||
const handleBandsChange = useCallback(
|
||||
(value: string): void => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScale = scaleFromBandsPerDoubling(Number(value));
|
||||
setLogScale(nextScale);
|
||||
onChange(logBuckets(nextScale));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleMaxValueChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setMaxValue(next);
|
||||
emitLinear(next, numBuckets);
|
||||
},
|
||||
[emitLinear, numBuckets],
|
||||
);
|
||||
|
||||
const handleNumBucketsChange = useCallback(
|
||||
(value: number | string | null): void => {
|
||||
const next = value === null || value === '' ? null : Number(value);
|
||||
setNumBuckets(next);
|
||||
emitLinear(maxValue, next);
|
||||
},
|
||||
[emitLinear, maxValue],
|
||||
);
|
||||
|
||||
// The toggle group takes a ReactNode label, which is how each item picks up the
|
||||
// query builder's value type — the component exposes no font-family token.
|
||||
const kindItems = useMemo(
|
||||
() =>
|
||||
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const bandItems = useMemo(
|
||||
() =>
|
||||
LOG_BANDS_OPTIONS.map(({ value, label }) => ({
|
||||
value,
|
||||
label: <span className={styles.toggleLabel}>{label}</span>,
|
||||
'aria-label': label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
// The kind toggle can be ahead of what has been emitted, so the preview describes
|
||||
// the picked kind rather than the emitted options.
|
||||
const previewedOptions = useMemo(():
|
||||
| Querybuildertypesv5BucketOptionsDTO
|
||||
| undefined => {
|
||||
if (kind === 'log') {
|
||||
return logBuckets(logScale);
|
||||
}
|
||||
if (kind === 'linear' && maxValue !== null) {
|
||||
return linearBuckets(maxValue, numBuckets);
|
||||
}
|
||||
return undefined;
|
||||
}, [kind, logScale, maxValue, numBuckets]);
|
||||
|
||||
const bounds =
|
||||
kind === 'linear' && !previewedOptions
|
||||
? undefined
|
||||
: previewUpperBounds(previewedOptions);
|
||||
|
||||
return (
|
||||
<div className={styles.bucketOptions} data-testid="bucket-options">
|
||||
<div className={styles.controls}>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Bucket by</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={kind}
|
||||
items={kindItems}
|
||||
onChange={handleKindChange}
|
||||
testId="bucket-options-kind"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{kind === 'log' && (
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Bands per doubling</span>
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
value={String(bandsPerDoublingFromScale(logScale))}
|
||||
items={bandItems}
|
||||
onChange={handleBandsChange}
|
||||
testId="bucket-options-bands"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'linear' && (
|
||||
<>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Max value</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={0}
|
||||
value={maxValue}
|
||||
onChange={handleMaxValueChange}
|
||||
placeholder="Required"
|
||||
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
|
||||
data-testid="bucket-options-max-value"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<span className={styles.label}>Buckets</span>
|
||||
<InputNumber
|
||||
className={styles.numberInput}
|
||||
min={1}
|
||||
max={MAX_NUM_BUCKETS}
|
||||
precision={0}
|
||||
value={numBuckets}
|
||||
onChange={handleNumBucketsChange}
|
||||
placeholder={String(DEFAULT_NUM_BUCKETS)}
|
||||
data-testid="bucket-options-num-buckets"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
className={cx('periscope-btn', 'ghost', styles.closeBtn)}
|
||||
icon={<ChevronUp size={16} />}
|
||||
onClick={onClose}
|
||||
data-testid="bucket-options-close"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.bounds} data-testid="bucket-options-bounds">
|
||||
<span className={styles.label}>Bounds</span>
|
||||
{bounds ? (
|
||||
<>
|
||||
{bounds.map((bound) => (
|
||||
<span className={styles.bound} key={bound}>
|
||||
{formatUpperBound(bound, unit)}
|
||||
</span>
|
||||
))}
|
||||
{hasBoundsBeyondPreview(previewedOptions) && (
|
||||
<span className={styles.muted}>…</span>
|
||||
)}
|
||||
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.muted}>Set a max value to see the bounds</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={styles.hint}>{BUCKET_KIND_HINTS[kind]}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BucketOptions.defaultProps = {
|
||||
bucketOptions: undefined,
|
||||
unit: undefined,
|
||||
onClose: undefined,
|
||||
};
|
||||
|
||||
export default BucketOptions;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MAX_LOG_SCALE } from '../constants';
|
||||
import {
|
||||
bandsPerDoublingFromScale,
|
||||
formatUpperBound,
|
||||
hasBoundsBeyondPreview,
|
||||
kindOptionOf,
|
||||
linearBuckets,
|
||||
logBuckets,
|
||||
previewUpperBounds,
|
||||
scaleFromBandsPerDoubling,
|
||||
} from '../utils';
|
||||
|
||||
describe('bucket option scales', () => {
|
||||
it.each([
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[2, 4],
|
||||
[3, 8],
|
||||
[4, 16],
|
||||
])('scale %i is %i bands per doubling', (scale, bands) => {
|
||||
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
|
||||
expect(scaleFromBandsPerDoubling(bands)).toBe(scale);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kindOptionOf', () => {
|
||||
it('reads no options as auto', () => {
|
||||
expect(kindOptionOf(undefined)).toBe('auto');
|
||||
});
|
||||
|
||||
it('reads the kind off the options', () => {
|
||||
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
|
||||
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewUpperBounds', () => {
|
||||
it('doubles at one band per doubling', () => {
|
||||
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
|
||||
1, 2, 4, 8, 16, 32, 64, 128,
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the finest log axis when no options are set', () => {
|
||||
const bounds = previewUpperBounds(undefined);
|
||||
|
||||
expect(bounds).toHaveLength(8);
|
||||
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
|
||||
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
|
||||
});
|
||||
|
||||
it('spaces a linear axis evenly by bucket width', () => {
|
||||
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
|
||||
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
|
||||
);
|
||||
});
|
||||
|
||||
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
|
||||
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
|
||||
});
|
||||
|
||||
it('has no bounds to preview for a linear axis without a usable max value', () => {
|
||||
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
|
||||
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBoundsBeyondPreview', () => {
|
||||
it('is always true for a log axis, which has no top', () => {
|
||||
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
|
||||
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks whether a linear axis runs past the preview', () => {
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
|
||||
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpperBound', () => {
|
||||
it('reads a bound in the panel unit when one is set', () => {
|
||||
expect(formatUpperBound(1, 'ms')).toContain('ms');
|
||||
});
|
||||
|
||||
it('keeps three significant digits when unitless', () => {
|
||||
expect(formatUpperBound(128, undefined)).toBe('128');
|
||||
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
|
||||
* builder keeps its own copy so an out-of-range axis is refused before the request
|
||||
* rather than after it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
|
||||
* resolution ClickHouse buckets at, so it is both the finest available and what an
|
||||
* absent `bucketOptions` resolves to.
|
||||
*/
|
||||
export const MAX_LOG_SCALE = 4;
|
||||
|
||||
export const MAX_NUM_BUCKETS = 512;
|
||||
|
||||
export const DEFAULT_NUM_BUCKETS = 60;
|
||||
|
||||
/**
|
||||
* The bands-per-doubling the toggle offers, coarsest first. Each is 2^scale for a
|
||||
* scale in [0, MAX_LOG_SCALE]: a negative scale is a whole number of doublings per
|
||||
* band instead, which has no bands-per-doubling label.
|
||||
*/
|
||||
export const LOG_BANDS_PER_DOUBLING = [1, 2, 4, 8, 16] as const;
|
||||
|
||||
/** How many leading upper bounds the bounds strip previews before eliding. */
|
||||
export const PREVIEW_BOUND_COUNT = 8;
|
||||
|
||||
/** The kind toggle's options. `auto` sends no options and lets the server choose. */
|
||||
export const BUCKET_KIND_OPTIONS = [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'log', label: 'Log' },
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
];
|
||||
|
||||
export const LOG_BANDS_OPTIONS = LOG_BANDS_PER_DOUBLING.map((bands) => ({
|
||||
value: String(bands),
|
||||
label: String(bands),
|
||||
}));
|
||||
|
||||
export const BUCKET_KIND_HINTS = {
|
||||
auto:
|
||||
'Bounds are picked for you: a log axis at 16 bands per doubling, the finest the query can return.',
|
||||
log: 'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. Fewer bands per doubling means fewer, coarser bands.',
|
||||
linear:
|
||||
'Bounds are spaced evenly from 0 up to the max value, so a band covers the same width wherever it sits. Everything above the max value lands in a single overflow band.',
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Querybuildertypesv5BucketOptionsDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTO,
|
||||
Querybuildertypesv5BucketOptionsLinearDTOKind,
|
||||
Querybuildertypesv5BucketOptionsLogDTO,
|
||||
Querybuildertypesv5BucketOptionsLogDTOKind,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import {
|
||||
DEFAULT_NUM_BUCKETS,
|
||||
MAX_LOG_SCALE,
|
||||
PREVIEW_BOUND_COUNT,
|
||||
} from './constants';
|
||||
|
||||
/**
|
||||
* The toggle's own vocabulary: the two kinds the request takes, plus `auto` for
|
||||
* sending no options at all and letting the server pick the axis.
|
||||
*/
|
||||
export type BucketKindOption = 'auto' | 'log' | 'linear';
|
||||
|
||||
export const isLinearBuckets = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
|
||||
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
|
||||
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
|
||||
|
||||
export const linearBuckets = (
|
||||
maxValue: number,
|
||||
numBuckets: number | null,
|
||||
): Querybuildertypesv5BucketOptionsLinearDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
|
||||
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
|
||||
});
|
||||
|
||||
export const logBuckets = (
|
||||
scale: number,
|
||||
): Querybuildertypesv5BucketOptionsLogDTO => ({
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale },
|
||||
});
|
||||
|
||||
/**
|
||||
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
|
||||
* no scale, so it reads as the default too.
|
||||
*/
|
||||
export const logScaleOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number =>
|
||||
bucketOptions && !isLinearBuckets(bucketOptions)
|
||||
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
|
||||
: MAX_LOG_SCALE;
|
||||
|
||||
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
|
||||
|
||||
export const scaleFromBandsPerDoubling = (bands: number): number =>
|
||||
Math.round(Math.log2(bands));
|
||||
|
||||
export const kindOptionOf = (
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): BucketKindOption => {
|
||||
if (!bucketOptions) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
return isLinearBuckets(bucketOptions) ? 'linear' : 'log';
|
||||
};
|
||||
|
||||
/**
|
||||
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
|
||||
* index 0's boundary — and a linear one at the top of its first band; both continue
|
||||
* past what the strip shows, and everything above the last one lands in the overflow
|
||||
* band the UI labels separately.
|
||||
*
|
||||
* `undefined` for a linear axis with no max value yet: without a top there is nothing
|
||||
* to divide.
|
||||
*/
|
||||
export function previewUpperBounds(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): number[] | undefined {
|
||||
if (bucketOptions && isLinearBuckets(bucketOptions)) {
|
||||
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
|
||||
|
||||
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const width = maxValue / numBuckets;
|
||||
|
||||
return Array.from(
|
||||
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
|
||||
(_, index) => (index + 1) * width,
|
||||
);
|
||||
}
|
||||
|
||||
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
|
||||
|
||||
return Array.from(
|
||||
{ length: PREVIEW_BOUND_COUNT },
|
||||
(_, index) => 2 ** (index / bands),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
|
||||
* buckets than the strip holds ends where the strip does.
|
||||
*/
|
||||
export function hasBoundsBeyondPreview(
|
||||
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
|
||||
): boolean {
|
||||
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
|
||||
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
|
||||
* log axis distinguishable without printing the float in full.
|
||||
*/
|
||||
export function formatUpperBound(bound: number, unit?: string): string {
|
||||
if (unit) {
|
||||
return getYAxisFormattedValue(String(bound), unit);
|
||||
}
|
||||
|
||||
return Number(bound.toPrecision(3)).toString();
|
||||
}
|
||||
@@ -3,14 +3,21 @@ import { Button, Tooltip } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
|
||||
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
|
||||
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { get, isEmpty } from 'lodash-es';
|
||||
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Grid3X3,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
resolveQueryBuilderFields,
|
||||
} from '../../queryBuilderFields.utils';
|
||||
|
||||
import BucketOptions from './BucketOptions/BucketOptions';
|
||||
import HavingFilter from './HavingFilter/HavingFilter';
|
||||
import { buildDefaultLegendFromGroupBy } from './utils';
|
||||
|
||||
@@ -57,6 +65,7 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
|
||||
[QueryBuilderField.Limit]: 'limit',
|
||||
[QueryBuilderField.Legend]: 'legend',
|
||||
[QueryBuilderField.ReduceTo]: 'reduceTo',
|
||||
[QueryBuilderField.BucketOptions]: 'bucketOptions',
|
||||
};
|
||||
|
||||
const ADD_ONS: AddOn[] = [
|
||||
@@ -116,6 +125,22 @@ const REDUCE_TO: AddOn = {
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
};
|
||||
|
||||
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
|
||||
// against, and no other panel type sends one. A histogram brings its own buckets.
|
||||
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
|
||||
ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
|
||||
]);
|
||||
|
||||
const BUCKET_OPTIONS: AddOn = {
|
||||
icon: <Grid3X3 size={14} />,
|
||||
label: 'Bucket by',
|
||||
key: QueryBuilderField.BucketOptions,
|
||||
description:
|
||||
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
|
||||
|
||||
@@ -196,7 +221,7 @@ function QueryAddOns({
|
||||
isForTraceOperator,
|
||||
});
|
||||
|
||||
const { handleSetQueryData } = useQueryBuilder();
|
||||
const { handleSetQueryData, currentQuery } = useQueryBuilder();
|
||||
|
||||
const supportedAddOns = useMemo((): AddOn[] => {
|
||||
let addOns: AddOn[];
|
||||
@@ -210,8 +235,25 @@ function QueryAddOns({
|
||||
addOns = [...ADD_ONS];
|
||||
}
|
||||
|
||||
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
|
||||
}, [panelType, query.dataSource, showReduceTo]);
|
||||
if (showReduceTo) {
|
||||
addOns = [...addOns, REDUCE_TO];
|
||||
}
|
||||
|
||||
if (
|
||||
panelType === PANEL_TYPES.HEATMAP &&
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
|
||||
) {
|
||||
addOns = [...addOns, BUCKET_OPTIONS];
|
||||
}
|
||||
|
||||
return addOns;
|
||||
}, [
|
||||
panelType,
|
||||
query.dataSource,
|
||||
query.aggregateAttribute?.type,
|
||||
showReduceTo,
|
||||
]);
|
||||
|
||||
const resolvedFields = useMemo(
|
||||
() =>
|
||||
@@ -392,6 +434,13 @@ function QueryAddOns({
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
|
||||
handleChangeQueryData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeQueryData],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="query-add-ons" data-testid="query-add-ons">
|
||||
{selectedViews.length > 0 && (
|
||||
@@ -559,6 +608,19 @@ function QueryAddOns({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedViews.find(
|
||||
(view) => view.key === QueryBuilderField.BucketOptions,
|
||||
) && (
|
||||
<div className="add-on-content" data-testid="bucket-options-content">
|
||||
<BucketOptions
|
||||
bucketOptions={query.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -26,8 +26,10 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): {
|
||||
handleSetQueryData: typeof mockHandleSetQueryData;
|
||||
currentQuery: { unit: string | undefined };
|
||||
} => ({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -330,4 +332,158 @@ describe('QueryAddOns', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
describe('bucket options', () => {
|
||||
function renderHeatmap(overrides: Partial<any> = {}): void {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it('is offered on a metrics heatmap only', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('query-add-on-bucket_options'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on other panel types', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.METRICS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.TIME_SERIES}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not offered on a heatmap over another signal', () => {
|
||||
render(
|
||||
<QueryAddOns
|
||||
query={baseQuery({ dataSource: DataSource.LOGS })}
|
||||
version="v5"
|
||||
isRawQuery={false}
|
||||
showReduceTo={false}
|
||||
panelType={PANEL_TYPES.HEATMAP}
|
||||
index={0}
|
||||
isForTraceOperator={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
|
||||
'is not offered for a %s metric, which carries its own buckets',
|
||||
(type) => {
|
||||
renderHeatmap({
|
||||
aggregateAttribute: { key: 'http_duration_bucket', type },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('query-add-on-bucket_options'),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it("auto-opens on the query's own kind", () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('sends no options for Auto', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Auto' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the scale the picked bands per doubling resolve to', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '1' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it('previews the bounds the picked axis will carry', () => {
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
const bounds = within(screen.getByTestId('bucket-options-bounds'));
|
||||
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
|
||||
expect(bounds.getByText(bound)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for a linear axis until it has a max value', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Set a max value to see the bounds'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends the linear axis once a max value is filled in', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Linear' }));
|
||||
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
|
||||
'bucketOptions',
|
||||
{ kind: 'linear', spec: { maxValue: 500 } },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('closes back to the toggle bar', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
|
||||
|
||||
await user.click(screen.getByTestId('bucket-options-close'));
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('bucket-options-content'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum QueryBuilderField {
|
||||
Limit = 'limit',
|
||||
Legend = 'legend_format',
|
||||
ReduceTo = 'reduce_to',
|
||||
BucketOptions = 'bucket_options',
|
||||
// Builder level
|
||||
Formula = 'formula',
|
||||
AdditionalQueries = 'additional_queries',
|
||||
|
||||
@@ -76,6 +76,7 @@ export const RAW_QUERY_FIELDS: Omit<
|
||||
[QueryBuilderField.Limit]: { state: 'hidden' },
|
||||
[QueryBuilderField.Legend]: { state: 'hidden' },
|
||||
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
|
||||
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
|
||||
[QueryBuilderField.Formula]: { state: 'hidden' },
|
||||
[QueryBuilderField.OrderBy]: { state: 'pinned' },
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { PartialOptions } from 'overlayscrollbars';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
const CONTENT_SCROLLBAR_OPTIONS: PartialOptions = {
|
||||
overflow: { x: 'hidden' },
|
||||
};
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar options={CONTENT_SCROLLBAR_OPTIONS}>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -1,79 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,11 +5,6 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -79,36 +74,6 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,32 +5,20 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -62,16 +50,11 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
children: <Component />,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
|
||||
@@ -338,6 +338,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
HEATMAP = 'heatmap',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
@@ -527,6 +527,21 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
|
||||
* count already draws. Sum is also what the statement builder forces on a histogram
|
||||
* heatmap whatever is asked for, so it is the only honest option to offer.
|
||||
*/
|
||||
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
},
|
||||
];
|
||||
|
||||
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
|
||||
string,
|
||||
string
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -153,6 +161,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -19,21 +20,20 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<DomainList />
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('Footer utils', () => {
|
||||
timeAggregation: 'avg',
|
||||
},
|
||||
],
|
||||
bucketOptions: undefined,
|
||||
disabled: false,
|
||||
filter: {
|
||||
expression: '',
|
||||
|
||||
@@ -65,6 +65,8 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -73,8 +75,32 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
@@ -251,20 +253,25 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
signal: SignalType.TRACES,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -356,7 +363,7 @@ function Explorer(): JSX.Element {
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
// Clearance for the fixed ExplorerOptions bar.
|
||||
padding-bottom: 80px;
|
||||
width: 100%;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
@@ -72,6 +83,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
|
||||
@@ -3,8 +3,9 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -120,21 +121,29 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
@@ -187,7 +196,7 @@ function Explorer(): JSX.Element {
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DataSourceDropdown } from '../DataSourceDropdown';
|
||||
|
||||
const TEST_ID = 'query-data-source-selector';
|
||||
|
||||
async function openDropdown(): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(TEST_ID);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
}
|
||||
|
||||
describe('DataSourceDropdown', () => {
|
||||
// antd's virtual list renders only the first couple of options into jsdom, so
|
||||
// each case asserts what the restriction admits and excludes, not the full list.
|
||||
it('offers the signals beyond the current one when nothing restricts it', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only the signals the caller can visualize', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.METRICS}
|
||||
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Metrics' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Logs' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Traces' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops a signal that is not a data source a query can be built against', async () => {
|
||||
render(
|
||||
<DataSourceDropdown
|
||||
data-testid={TEST_ID}
|
||||
value={DataSource.LOGS}
|
||||
allowedDataSources={[
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO[''],
|
||||
]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await openDropdown();
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: 'Logs' }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: 'Metrics' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Col, Input, Row, Select } from 'antd';
|
||||
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
|
||||
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
|
||||
import { LEGEND } from 'constants/global';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
// ** Hooks
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
@@ -24,8 +26,12 @@ export function Formula({
|
||||
query,
|
||||
isQBV2,
|
||||
}: FormulaProps): JSX.Element {
|
||||
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
|
||||
useQueryBuilder();
|
||||
const {
|
||||
removeQueryBuilderEntityByIndex,
|
||||
handleSetFormulaData,
|
||||
panelType,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const { handleChangeFormulaData } = useQueryOperations({
|
||||
index,
|
||||
@@ -73,6 +79,13 @@ export function Formula({
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleChangeBucketOptions = useCallback(
|
||||
(value: IBuilderFormula['bucketOptions']) => {
|
||||
handleChangeFormulaData('bucketOptions', value);
|
||||
},
|
||||
[handleChangeFormulaData],
|
||||
);
|
||||
|
||||
const handleQBV2OrderByChange = useCallback(
|
||||
(value: string) => {
|
||||
const [columnName, order] = value.split(' ');
|
||||
@@ -163,6 +176,17 @@ export function Formula({
|
||||
</div>
|
||||
</Col>
|
||||
)}
|
||||
{/* A heatmap draws its one enabled query, which is the formula when its
|
||||
inputs are disabled — so the formula states its own bucket axis. */}
|
||||
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
|
||||
<Col span={24}>
|
||||
<BucketOptions
|
||||
bucketOptions={formula.bucketOptions}
|
||||
unit={currentQuery.unit}
|
||||
onChange={handleChangeBucketOptions}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { Formula } from '../Formula';
|
||||
|
||||
const mockHandleChangeFormulaData = jest.fn();
|
||||
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): Record<string, unknown> => ({
|
||||
removeQueryBuilderEntityByIndex: jest.fn(),
|
||||
handleSetFormulaData: jest.fn(),
|
||||
panelType: mockPanelType,
|
||||
currentQuery: { unit: undefined },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
|
||||
useQueryOperations: (): Record<string, unknown> => ({
|
||||
handleChangeFormulaData: mockHandleChangeFormulaData,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
|
||||
}));
|
||||
|
||||
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
|
||||
return {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
|
||||
render(
|
||||
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Formula bucket options', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPanelType = PANEL_TYPES.HEATMAP;
|
||||
});
|
||||
|
||||
it('offers a bucket axis on a heatmap', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers no bucket axis on other panel types', () => {
|
||||
mockPanelType = PANEL_TYPES.TIME_SERIES;
|
||||
renderFormula();
|
||||
|
||||
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens on the axis the formula already carries', () => {
|
||||
renderFormula({
|
||||
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('writes the picked axis onto the formula', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFormula();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Log' }));
|
||||
|
||||
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
|
||||
kind: 'log',
|
||||
spec: { scale: 4 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
HEATMAP: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -20,4 +20,6 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
|
||||
// V2-only kind; it renders through the V2 panel registry.
|
||||
[PANEL_TYPES.HEATMAP]: null,
|
||||
};
|
||||
|
||||
@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
legend: '',
|
||||
};
|
||||
|
||||
const setupMockQueryBuilder = (): void => {
|
||||
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
|
||||
(useQueryBuilder as jest.Mock).mockReturnValue({
|
||||
handleSetQueryData: mockHandleSetQueryData,
|
||||
handleSetFormulaData: mockHandleSetFormulaData,
|
||||
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
|
||||
setLastUsedQuery: mockSetLastUsedQuery,
|
||||
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
|
||||
panelType: 'time_series',
|
||||
panelType,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [defaultMockQuery, defaultMockQuery],
|
||||
@@ -332,4 +332,105 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spaceAggregationOptions for a histogram metric', () => {
|
||||
const histogramQuery: IBuilderQuery = {
|
||||
...defaultMockQuery,
|
||||
aggregateAttribute: {
|
||||
key: 'signoz_latency',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
} as BaseAutocompleteData,
|
||||
};
|
||||
|
||||
it('offers the percentiles on a time series panel', () => {
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([
|
||||
MetricAggregateOperator.P50,
|
||||
MetricAggregateOperator.P75,
|
||||
MetricAggregateOperator.P90,
|
||||
MetricAggregateOperator.P95,
|
||||
MetricAggregateOperator.P99,
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ query: histogramQuery });
|
||||
|
||||
expect(
|
||||
result.current.spaceAggregationOptions.map((o) => o.value),
|
||||
).toStrictEqual([MetricAggregateOperator.COUNT]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('picking a histogram metric', () => {
|
||||
const histogramAttribute: BaseAutocompleteData = {
|
||||
key: 'http.client.duration.bucket',
|
||||
dataType: DataTypes.Float64,
|
||||
type: ATTRIBUTE_TYPES.HISTOGRAM,
|
||||
};
|
||||
|
||||
it('defaults the spatial aggregation to p90 on a time series panel', () => {
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({
|
||||
aggregations: [
|
||||
expect.objectContaining({
|
||||
spaceAggregation: MetricAggregateOperator.COUNT,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
|
||||
setupMockQueryBuilder('heatmap');
|
||||
|
||||
const result = renderHookWithProps({
|
||||
entityVersion: ENTITY_VERSION_V5,
|
||||
query: {
|
||||
...defaultMockQuery,
|
||||
bucketOptions: { kind: 'log', spec: { scale: 2 } },
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleChangeAggregatorAttribute(histogramAttribute);
|
||||
});
|
||||
|
||||
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
|
||||
0,
|
||||
expect.objectContaining({ bucketOptions: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
metricsGaugeSpaceAggregateOperatorOptions,
|
||||
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
|
||||
metricsHistogramSpaceAggregateOperatorOptions,
|
||||
metricsSumSpaceAggregateOperatorOptions,
|
||||
metricsUnknownSpaceAggregateOperatorOptions,
|
||||
@@ -176,6 +177,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
|
||||
});
|
||||
|
||||
const histogramSpaceAggregationOptions =
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
|
||||
: metricsHistogramSpaceAggregateOperatorOptions;
|
||||
|
||||
switch (aggregateAttribute?.type) {
|
||||
case ATTRIBUTE_TYPES.SUM:
|
||||
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
|
||||
@@ -185,11 +191,11 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
|
||||
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
|
||||
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
|
||||
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
|
||||
break;
|
||||
default:
|
||||
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
|
||||
@@ -298,10 +304,17 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
timeAggregation: '',
|
||||
metricName: newQuery.aggregateAttribute?.key || '',
|
||||
temporality: '',
|
||||
spaceAggregation: MetricAggregateOperator.P90,
|
||||
// A heatmap cell holds a count of observations per `le` band, which is
|
||||
// the one option the kind offers — a percentile default would sit in the
|
||||
// selector with nothing behind it.
|
||||
spaceAggregation:
|
||||
panelType === PANEL_TYPES.HEATMAP
|
||||
? MetricAggregateOperator.COUNT
|
||||
: MetricAggregateOperator.P90,
|
||||
reduceTo: ReduceOperators.AVG,
|
||||
},
|
||||
];
|
||||
newQuery.bucketOptions = undefined;
|
||||
} else {
|
||||
newQuery.aggregations = [
|
||||
{
|
||||
@@ -388,6 +401,7 @@ export const useQueryOperations: UseQueryOperations = ({
|
||||
index,
|
||||
handleMetricAggregateAtributeTypes,
|
||||
previousMetricInfo,
|
||||
panelType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -222,6 +222,31 @@ describe('useGetYAxisUnit', () => {
|
||||
expect(result.current.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves the unit on the first render, without a settling pass', () => {
|
||||
// The real `useGetMetrics` rebuilds its array on every render; a hook that
|
||||
// stored the unit would need an extra render to settle, and would schedule one
|
||||
// after every render of the panel editor.
|
||||
mockUseGetMetrics.mockImplementation(() => ({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
metrics: [MOCK_METRIC_1],
|
||||
}));
|
||||
|
||||
let renderCount = 0;
|
||||
const { result, rerender } = renderHook(() => {
|
||||
renderCount += 1;
|
||||
return useGetYAxisUnit();
|
||||
});
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(1);
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
|
||||
expect(renderCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return undefined when metrics have different units', async () => {
|
||||
mockUseGetMetrics.mockReturnValueOnce({
|
||||
isLoading: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getMetricUnits,
|
||||
useGetMetrics,
|
||||
@@ -46,7 +46,6 @@ function useGetYAxisUnit(
|
||||
},
|
||||
): UseGetYAxisUnitResult {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
|
||||
|
||||
const metricNames: string[] | null = useMemo(() => {
|
||||
// If the query type is not QUERY_BUILDER, return null
|
||||
@@ -95,27 +94,16 @@ function useGetYAxisUnit(
|
||||
[units],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// If there are no metrics, set the y-axis unit to undefined
|
||||
if (units.length === 0) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
|
||||
} else if (units.length === 1 && units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
// If all metrics have the same non-empty unit, set the y-axis unit to it
|
||||
} else if (areAllMetricUnitsSame) {
|
||||
if (units[0] !== '') {
|
||||
setYAxisUnit(units[0]);
|
||||
} else {
|
||||
setYAxisUnit(undefined);
|
||||
}
|
||||
// If there is more than one metric and they have different units, set the y-axis unit to undefined
|
||||
} else if (units.length > 1 && !areAllMetricUnitsSame) {
|
||||
setYAxisUnit(undefined);
|
||||
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
|
||||
} else if (units.length === 1 && units[0] === '') {
|
||||
setYAxisUnit(undefined);
|
||||
// Derived, not stored: `useGetMetrics` rebuilds its array on every render, so a
|
||||
// state-and-effect version schedules an update after every render — the shape
|
||||
// React reports as "Maximum update depth exceeded".
|
||||
const yAxisUnit = useMemo(() => {
|
||||
// A single shared unit is the only thing a single axis can carry; metrics that
|
||||
// disagree, or that carry no unit at all, leave the axis unitless.
|
||||
if (units.length === 0 || !areAllMetricUnitsSame) {
|
||||
return undefined;
|
||||
}
|
||||
return units[0] || undefined;
|
||||
}, [units, areAllMetricUnitsSame]);
|
||||
|
||||
return { yAxisUnit, isLoading, isError };
|
||||
|
||||
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
78
frontend/src/lib/query/__tests__/handleQueryChange.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderFormula,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { handleQueryChange } from '../panelQuery';
|
||||
|
||||
const bucketOptions = {
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
|
||||
spec: { scale: 0 },
|
||||
};
|
||||
|
||||
function queryWithFormulaAxis(): Query {
|
||||
const base = initialQueriesMap[DataSource.METRICS];
|
||||
const formula: IBuilderFormula = {
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
legend: '',
|
||||
disabled: false,
|
||||
bucketOptions,
|
||||
};
|
||||
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
|
||||
queryFormulas: [formula],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('handleQueryChange bucket options', () => {
|
||||
it('keeps both axes when the panel stays a heatmap', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.HEATMAP,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
|
||||
bucketOptions,
|
||||
);
|
||||
});
|
||||
|
||||
// The request rejects an axis on any type but heatmap, so a switch has to shed it
|
||||
// from the query (via the field allowlist) and from the formula (by hand).
|
||||
it('drops both axes when switching to another panel type', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
|
||||
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the rest of the formula intact', () => {
|
||||
const result = handleQueryChange(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
queryWithFormulaAxis(),
|
||||
PANEL_TYPES.HEATMAP,
|
||||
);
|
||||
|
||||
expect(result.builder.queryFormulas[0]).toMatchObject({
|
||||
queryName: 'F1',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
IBuilderFormula,
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Asks "would saving the current panel change the persisted widget spec?".
|
||||
@@ -99,6 +103,7 @@ export type PartialPanelTypes = {
|
||||
[PANEL_TYPES.VALUE]: 'value';
|
||||
[PANEL_TYPES.PIE]: 'pie';
|
||||
[PANEL_TYPES.HISTOGRAM]: 'histogram';
|
||||
[PANEL_TYPES.HEATMAP]: 'heatmap';
|
||||
};
|
||||
|
||||
export const panelTypeDataSourceFormValuesMap: Record<
|
||||
@@ -306,6 +311,75 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
// `functions` and `having` are dropped rather than carried: the heatmap request
|
||||
// rejects both. Every signal is listed because the map is keyed by the query's
|
||||
// own, which a switch can still be holding.
|
||||
[PANEL_TYPES.HEATMAP]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.METRICS]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
'bucketOptions',
|
||||
],
|
||||
},
|
||||
},
|
||||
[DataSource.TRACES]: {
|
||||
builder: {
|
||||
queryData: [
|
||||
'aggregateAttribute',
|
||||
'aggregateOperator',
|
||||
'timeAggregation',
|
||||
'filters',
|
||||
'filter',
|
||||
'spaceAggregation',
|
||||
'groupBy',
|
||||
'limit',
|
||||
'orderBy',
|
||||
'stepInterval',
|
||||
'legend',
|
||||
'queryName',
|
||||
'disabled',
|
||||
'expression',
|
||||
'aggregations',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
[PANEL_TYPES.TABLE]: {
|
||||
[DataSource.LOGS]: {
|
||||
builder: {
|
||||
@@ -538,6 +612,24 @@ export const panelTypeDataSourceFormValuesMap: Record<
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Formulas are carried across a panel-type switch whole, not rebuilt from
|
||||
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
|
||||
* dropped by hand. The request rejects one on any type but heatmap.
|
||||
*/
|
||||
const withoutNonHeatmapBucketOptions = (
|
||||
formulas: IBuilderFormula[],
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
): IBuilderFormula[] => {
|
||||
if (newPanelType === PANEL_TYPES.HEATMAP) {
|
||||
return formulas;
|
||||
}
|
||||
|
||||
return (formulas ?? []).map(
|
||||
({ bucketOptions: _bucketOptions, ...rest }) => rest,
|
||||
);
|
||||
};
|
||||
|
||||
export function handleQueryChange(
|
||||
newPanelType: keyof PartialPanelTypes,
|
||||
supersetQuery: Query,
|
||||
@@ -581,6 +673,10 @@ export function handleQueryChange(
|
||||
|
||||
return tempQuery;
|
||||
}),
|
||||
queryFormulas: withoutNonHeatmapBucketOptions(
|
||||
supersetQuery.builder.queryFormulas,
|
||||
newPanelType,
|
||||
),
|
||||
queryTraceOperator:
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? []
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 4px 12px 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
transform: translateX(-1px);
|
||||
// Reads against the panel through the 3px it overhangs the track at either end,
|
||||
// which is what carries it where the ramp happens to match it.
|
||||
background: var(--popover-foreground);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.caption {
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.swatch,
|
||||
.hatchSwatch {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
|
||||
// strokes in the theme's own direction — light on dark, dark on light.
|
||||
.hatchSwatch {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent 0 2px,
|
||||
var(--muted-foreground) 2px 3px
|
||||
);
|
||||
}
|
||||
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
81
frontend/src/lib/uPlotV2/components/ColorBar/ColorBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Styles from './ColorBar.module.scss';
|
||||
|
||||
export interface ColorBarProps {
|
||||
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
|
||||
* colours as the cells. */
|
||||
ramp: string[];
|
||||
minLabel: string;
|
||||
maxLabel: string;
|
||||
/** 0..1. `null` hides the marker. */
|
||||
markerPosition?: number | null;
|
||||
/** What the colour encodes, e.g. "count". */
|
||||
label?: string;
|
||||
/** Keys for the two states a ramp cannot express: a hatched data gap, and a
|
||||
* genuine zero at the bottom. Without them the difference is guesswork. */
|
||||
showStateKeys?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/** What a colour means, plus a marker for the value under the cursor. */
|
||||
export default function ColorBar({
|
||||
ramp,
|
||||
minLabel,
|
||||
maxLabel,
|
||||
markerPosition = null,
|
||||
label,
|
||||
showStateKeys = true,
|
||||
'data-testid': testId = 'color-bar',
|
||||
}: ColorBarProps): JSX.Element | null {
|
||||
const gradient = useMemo(() => {
|
||||
if (ramp.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => {
|
||||
const from = (index / ramp.length) * 100;
|
||||
const to = ((index + 1) / ramp.length) * 100;
|
||||
return [`${color} ${from}%`, `${color} ${to}%`];
|
||||
});
|
||||
return `linear-gradient(to right, ${stops.join(', ')})`;
|
||||
}, [ramp]);
|
||||
|
||||
if (gradient === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clampedMarker =
|
||||
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
|
||||
|
||||
return (
|
||||
<div className={Styles.container} data-testid={testId}>
|
||||
{label && <span className={Styles.caption}>{label}</span>}
|
||||
<span className={Styles.label}>{minLabel}</span>
|
||||
<div className={Styles.track} style={{ background: gradient }}>
|
||||
{clampedMarker !== null && (
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ left: `${clampedMarker * 100}%` }}
|
||||
data-testid={`${testId}-marker`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className={Styles.label}>{maxLabel}</span>
|
||||
{showStateKeys && (
|
||||
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.hatchSwatch} />
|
||||
no data
|
||||
</span>
|
||||
<span className={Styles.key}>
|
||||
<span className={Styles.swatch} style={{ background: ramp[0] }} />
|
||||
count 0
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import ColorBar from '../ColorBar';
|
||||
|
||||
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
|
||||
|
||||
describe('ColorBar', () => {
|
||||
it('renders the domain labels', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without a ramp', () => {
|
||||
const { container } = render(
|
||||
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the marker when nothing is hovered', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('positions the marker at the hovered value', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
|
||||
});
|
||||
|
||||
it('clamps a marker outside the ramp to its ends', () => {
|
||||
const { rerender } = render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
|
||||
|
||||
rerender(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
|
||||
);
|
||||
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
|
||||
});
|
||||
|
||||
it('keys the two states a colour ramp cannot express', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('no data')).toBeInTheDocument();
|
||||
expect(screen.getByText('count 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws the count-0 key with the bottom of the ramp', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
|
||||
|
||||
expect(screen.getByText('count 0').firstChild).toHaveStyle({
|
||||
background: RAMP[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the state keys when asked', () => {
|
||||
render(
|
||||
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showStateKeys={false} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('no data')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('captions what the colour encodes', () => {
|
||||
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
|
||||
|
||||
expect(screen.getByText('count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hard-edged segments so the bar matches the drawn cells', () => {
|
||||
render(
|
||||
<ColorBar
|
||||
ramp={['#111111', '#dddddd']}
|
||||
minLabel="0"
|
||||
maxLabel="10"
|
||||
data-testid="scale"
|
||||
/>,
|
||||
);
|
||||
|
||||
const track = screen.getByTestId('scale').querySelector('div');
|
||||
expect(track).toHaveStyle({
|
||||
background:
|
||||
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import cx from 'classnames';
|
||||
|
||||
import { formatCount, HeatmapBucketRow } from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** The buckets either side of the hovered one, so a mode reads as a shape rather
|
||||
* than a single number. */
|
||||
export default function HeatmapBucketList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapBucketRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
|
||||
{rows.map((bucket) => (
|
||||
<div
|
||||
key={bucket.row}
|
||||
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
|
||||
data-hovered={bucket.isHovered}
|
||||
data-testid="heatmap-tooltip-bucket-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{bucket.label}</span>
|
||||
<span className={Styles.rowSeparator} />
|
||||
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { formatCount, HeatmapContributionRow } from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/** Only shown when the cell sums more than one group. */
|
||||
export default function HeatmapContributionList({
|
||||
rows,
|
||||
}: {
|
||||
rows: HeatmapContributionRow[];
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="heatmap-tooltip-contribution-row"
|
||||
>
|
||||
<span
|
||||
className={Styles.marker}
|
||||
style={{ borderColor: row.color, backgroundColor: row.color }}
|
||||
data-is-legend-marker={true}
|
||||
/>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
|
||||
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
|
||||
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
|
||||
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
|
||||
// as white-on-white in light mode.
|
||||
//
|
||||
// Padding lives on the sections rather than here, also matching the shared
|
||||
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
|
||||
// corner radius, so it has to reach the container edges.
|
||||
.container {
|
||||
// The list pays this less each row's own inset, so its text lands on it too.
|
||||
--gutter: 14px;
|
||||
--row-inset: 6px;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Separates the cell identity from whichever question the second block answers.
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-6);
|
||||
padding: var(--spacing-8) var(--gutter) 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-3) var(--gutter) 0;
|
||||
}
|
||||
|
||||
// Names the group the grid is under, as the legend draws a shown series; it is
|
||||
// not a colour key.
|
||||
.filterMarker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border: 1.5px solid currentColor;
|
||||
background: currentColor;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) var(--gutter);
|
||||
}
|
||||
|
||||
.titleBucket,
|
||||
.titleCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
|
||||
.titleCount {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
|
||||
var(--spacing-6);
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--row-inset);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
|
||||
.rowHovered {
|
||||
background: var(--l3-background);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.rowLabel,
|
||||
.rowValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius);
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rowSeparator,
|
||||
.titleSeparator {
|
||||
flex: 1;
|
||||
border-width: 0.5px;
|
||||
border-style: dashed;
|
||||
border-color: currentColor;
|
||||
min-width: 24px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
resolveColumnIndex,
|
||||
resolveRowIndex,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import { HeatmapTooltipProps } from '../../../types';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
import HeatmapBucketList from './HeatmapBucketList';
|
||||
import HeatmapContributionList from './HeatmapContributionList';
|
||||
import {
|
||||
buildBucketRows,
|
||||
buildContributionRows,
|
||||
formatBucketLabel,
|
||||
formatColumnRange,
|
||||
formatCount,
|
||||
HeatmapTooltipBody,
|
||||
resolveTooltipBody,
|
||||
} from './heatmapTooltipContent';
|
||||
|
||||
import Styles from './HeatmapTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* The cell identity is the same in every state; the second block answers whichever
|
||||
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, which renders a flat list of
|
||||
* series values — none of these states is that shape.
|
||||
*
|
||||
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
|
||||
* the nearest timestamp, so half of every column would report its neighbour.
|
||||
*/
|
||||
export default function HeatmapTooltip({
|
||||
uPlotInstance,
|
||||
yAxis,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
groupColor,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: HeatmapTooltipProps): JSX.Element | null {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
|
||||
// Read outside the memo: uPlot mutates the same instance on every move, so
|
||||
// keying off the instance alone would freeze the cell.
|
||||
const { left = -10, top = -10 } = uPlotInstance.cursor;
|
||||
|
||||
const cell = useMemo(() => {
|
||||
if (left < 0 || top < 0) {
|
||||
return null;
|
||||
}
|
||||
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
|
||||
const column = resolveColumnIndex(
|
||||
timestamps,
|
||||
uPlotInstance.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
row,
|
||||
column,
|
||||
timestamp: timestamps[column],
|
||||
count:
|
||||
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
] ?? null,
|
||||
};
|
||||
}, [left, top, uPlotInstance, yAxis, step]);
|
||||
|
||||
// The cell sums the enabled groups, so those are what a breakdown must cover.
|
||||
const visible = useMemo(
|
||||
() => series.filter((entry) => visibleGroups.includes(entry.label)),
|
||||
[series, visibleGroups],
|
||||
);
|
||||
const body = resolveTooltipBody(visible.length);
|
||||
|
||||
const bucketRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Buckets) {
|
||||
return [];
|
||||
}
|
||||
return buildBucketRows({
|
||||
counts: uPlotInstance.data.slice(1) as Array<
|
||||
ArrayLike<number | null> | undefined
|
||||
>,
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
column: cell.column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
});
|
||||
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
|
||||
|
||||
const contributionRows = useMemo(() => {
|
||||
if (!cell || body !== HeatmapTooltipBody.Contribution) {
|
||||
return [];
|
||||
}
|
||||
return buildContributionRows({
|
||||
series: visible,
|
||||
timestamp: cell.timestamp,
|
||||
row: cell.row,
|
||||
color: groupColor,
|
||||
});
|
||||
}, [cell, body, visible, groupColor]);
|
||||
|
||||
if (!cell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A single enabled group out of several means the legend has isolated it.
|
||||
const isolated =
|
||||
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
|
||||
const filterLabel = isolated?.label ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="heatmap-tooltip"
|
||||
>
|
||||
<div className={Styles.identity}>
|
||||
<div className={Styles.header}>
|
||||
<span data-testid="heatmap-tooltip-range">
|
||||
{formatColumnRange({
|
||||
start: cell.timestamp,
|
||||
step,
|
||||
timezone: resolvedTimezone,
|
||||
})}
|
||||
</span>
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
|
||||
{filterLabel && (
|
||||
<div
|
||||
className={Styles.filter}
|
||||
style={{ color: groupColor }}
|
||||
data-testid="heatmap-tooltip-filter"
|
||||
>
|
||||
<span className={Styles.filterMarker} />
|
||||
<span className={Styles.filterLabel}>{filterLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={Styles.title}>
|
||||
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
|
||||
{formatBucketLabel({
|
||||
yAxis,
|
||||
row: cell.row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
})}
|
||||
</span>
|
||||
<span className={Styles.titleSeparator} />
|
||||
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
|
||||
{formatCount(cell.count)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
|
||||
|
||||
{body === HeatmapTooltipBody.Contribution ? (
|
||||
<HeatmapContributionList rows={contributionRows} />
|
||||
) : (
|
||||
<HeatmapBucketList rows={bucketRows} />
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { render, RenderResult, screen } from 'tests/test-utils';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import HeatmapTooltip from '../HeatmapTooltip';
|
||||
|
||||
const BOUNDS = [100, 500, 1000, 2500];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
|
||||
const STEP = 300;
|
||||
const PLOT_SIZE = 500;
|
||||
const ROW_COUNT = BOUNDS.length + 1;
|
||||
|
||||
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
|
||||
const HOVERED_ROW = 2;
|
||||
|
||||
function seriesFor(
|
||||
group: string,
|
||||
countsAtHoveredRow: [number, number],
|
||||
): HeatmapSeries {
|
||||
return {
|
||||
label: `{service.name="${group}"}`,
|
||||
points: TIMESTAMPS.map((timestamp, column) => ({
|
||||
timestamp,
|
||||
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
|
||||
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const GROUPED: HeatmapSeries[] = [
|
||||
seriesFor('checkout', [355, 300]),
|
||||
seriesFor('frontend', [86, 80]),
|
||||
seriesFor('cart', [14, 10]),
|
||||
seriesFor('payments', [0, 0]),
|
||||
];
|
||||
|
||||
/** Grid counts, matching what the renderer would have been handed. */
|
||||
function gridData(rowTotals: number[]): uPlot.AlignedData {
|
||||
return [
|
||||
TIMESTAMPS,
|
||||
...Array.from({ length: ROW_COUNT }, (_, row) => [
|
||||
rowTotals[row] ?? row * 40,
|
||||
rowTotals[row] ?? row * 40,
|
||||
]),
|
||||
] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
|
||||
const ROW_TOTALS = [10, 269, 455, 92, 2];
|
||||
|
||||
function createFakePlot(): uPlot {
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
// Aim the cursor at the middle of the hovered row, first column.
|
||||
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
|
||||
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
|
||||
|
||||
return {
|
||||
data: gridData(ROW_TOTALS),
|
||||
cursor: { left: PLOT_SIZE * 0.25, top },
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
function renderTooltip(
|
||||
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
|
||||
): RenderResult {
|
||||
return render(
|
||||
<HeatmapTooltip
|
||||
id="panel-1"
|
||||
uPlotInstance={createFakePlot()}
|
||||
dataIndexes={[]}
|
||||
seriesIndex={null}
|
||||
isPinned={false}
|
||||
dismiss={jest.fn()}
|
||||
viaSync={false}
|
||||
yAxis={Y_AXIS}
|
||||
step={STEP}
|
||||
series={GROUPED}
|
||||
visibleGroups={GROUPED.map((entry) => entry.label)}
|
||||
groupColor="#fcfdbf"
|
||||
yAxisUnit="ms"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('HeatmapTooltip — cell identity', () => {
|
||||
it('heads with the time span the column covers, not a single instant', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
|
||||
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it('names the hovered bucket and its count', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
|
||||
'500 ms – 1 s',
|
||||
);
|
||||
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
|
||||
});
|
||||
|
||||
it('marks the surface as pinned so the border picks up the ring', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
|
||||
renderTooltip({ isPinned: true });
|
||||
|
||||
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the header unbadged while unpinned', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is unpinned by default', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
|
||||
'data-pinned',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('separates the cell identity from the block below it', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a footer when the panel supplies one', () => {
|
||||
renderTooltip({
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
|
||||
});
|
||||
|
||||
it('tells the footer when the tooltip is pinned', () => {
|
||||
renderTooltip({
|
||||
isPinned: true,
|
||||
renderTooltipFooter: ({ isPinned }): JSX.Element => (
|
||||
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
|
||||
});
|
||||
|
||||
it('renders nothing when the cursor is off the plot', () => {
|
||||
const plot = createFakePlot();
|
||||
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
|
||||
|
||||
const { container } = renderTooltip({ uPlotInstance: plot });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, nothing selected', () => {
|
||||
it('breaks the cell down by group instead of showing neighbours', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('heatmap-tooltip-contribution'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-buckets'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names each row as the legend does and orders by contribution', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-contribution-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
expect(rows[0]).toContain('checkout');
|
||||
expect(rows[0]).toContain('355');
|
||||
expect(rows[1]).toContain('frontend');
|
||||
expect(rows[2]).toContain('cart');
|
||||
});
|
||||
|
||||
it('still lists a group that contributed nothing', () => {
|
||||
renderTooltip();
|
||||
|
||||
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
|
||||
expect(rows).toHaveLength(GROUPED.length);
|
||||
expect(rows[3]).toHaveTextContent('payments');
|
||||
expect(rows[3]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
it('does not name a filter when every group is enabled', () => {
|
||||
renderTooltip();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-filter'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — grouped, one enabled', () => {
|
||||
const selected = { visibleGroups: ['{service.name="checkout"}'] };
|
||||
|
||||
it('returns to neighbouring buckets, since contribution is already answered', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the active filter', () => {
|
||||
renderTooltip(selected);
|
||||
|
||||
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
|
||||
'{service.name="checkout"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HeatmapTooltip — no grouping', () => {
|
||||
const ungrouped = {
|
||||
series: [{ label: '', points: GROUPED[0].points }],
|
||||
visibleGroups: [''],
|
||||
};
|
||||
|
||||
it('shows neighbouring buckets, highest first', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const rows = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.map((row) => row.textContent);
|
||||
|
||||
// Two buckets either side of 500ms – 1s, reading down the y axis.
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows[0]).toContain('> 2.5 s');
|
||||
expect(rows[2]).toContain('500 ms – 1 s');
|
||||
expect(rows[4]).toContain('≤ 100 ms');
|
||||
});
|
||||
|
||||
it('marks the hovered bucket among its neighbours', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
const hovered = screen
|
||||
.getAllByTestId('heatmap-tooltip-bucket-row')
|
||||
.filter((row) => row.dataset.hovered === 'true');
|
||||
|
||||
expect(hovered).toHaveLength(1);
|
||||
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
|
||||
});
|
||||
|
||||
it('never breaks down a single series', () => {
|
||||
renderTooltip(ungrouped);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('heatmap-tooltip-contribution'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
|
||||
|
||||
const TIMEZONE = 'UTC';
|
||||
/** 2026-09-02T05:30:00Z. */
|
||||
const START = 1_788_327_000;
|
||||
|
||||
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
|
||||
const CLOSE_BOUNDS = [
|
||||
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
|
||||
0.0681567332915786,
|
||||
];
|
||||
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
|
||||
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
|
||||
|
||||
describe('formatColumnRange', () => {
|
||||
it('dates the column once while it stays inside a day', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 14:30');
|
||||
});
|
||||
|
||||
it('carries the date across a column that spans days', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30 → 09/04 05:30');
|
||||
});
|
||||
|
||||
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
|
||||
expect(
|
||||
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
|
||||
).toBe('09/02 05:30:00 → 05:30:30');
|
||||
});
|
||||
|
||||
it('reads the day in the panel timezone, not UTC', () => {
|
||||
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
|
||||
// crossing a date boundary there and not in UTC.
|
||||
expect(
|
||||
formatColumnRange({
|
||||
start: START,
|
||||
step: 9 * 3600,
|
||||
timezone: 'America/Los_Angeles',
|
||||
}),
|
||||
).toBe('09/01 22:30 → 09/02 07:30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBucketRows', () => {
|
||||
it('identifies a row by its place on the axis, which its label cannot', () => {
|
||||
const rows = buildBucketRows({
|
||||
counts: COUNTS,
|
||||
yAxis: CLOSE_Y_AXIS,
|
||||
row: 3,
|
||||
column: 0,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
});
|
||||
|
||||
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
|
||||
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import timezonePlugin from 'dayjs/plugin/timezone';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
HeatmapSeries,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezonePlugin);
|
||||
|
||||
/** Rows shown either side of the hovered one. */
|
||||
const NEIGHBOUR_SPAN = 2;
|
||||
/** Below this, the header needs seconds to distinguish columns. */
|
||||
const SUB_MINUTE_STEP = 60;
|
||||
|
||||
export const NO_DATA_LABEL = 'no data';
|
||||
|
||||
/**
|
||||
* Which question the second block answers. A cell summed across several groups begs
|
||||
* "which group?"; a cell that is already one series begs "how does this bucket
|
||||
* compare with its neighbours?".
|
||||
*/
|
||||
export enum HeatmapTooltipBody {
|
||||
Buckets = 'buckets',
|
||||
Contribution = 'contribution',
|
||||
}
|
||||
|
||||
export interface HeatmapBucketRow {
|
||||
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
|
||||
* round to the same text — so this is what identifies a row. */
|
||||
row: number;
|
||||
label: string;
|
||||
count: number | null;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export interface HeatmapContributionRow {
|
||||
label: string;
|
||||
color: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
|
||||
// One enabled group contributes the whole cell, so there is nothing to break
|
||||
// down — whether the query is ungrouped or the legend has isolated a group.
|
||||
return visibleCount > 1
|
||||
? HeatmapTooltipBody.Contribution
|
||||
: HeatmapTooltipBody.Buckets;
|
||||
}
|
||||
|
||||
/** A cell is an interval, so a single instant would misreport what it contains.
|
||||
* The start carries the date — the x axis prints one only where the day turns
|
||||
* over — and the end repeats it only across midnight. */
|
||||
export function formatColumnRange({
|
||||
start,
|
||||
step,
|
||||
timezone,
|
||||
}: {
|
||||
/** Column start, in seconds. */
|
||||
start: number;
|
||||
/** Column width, in seconds. */
|
||||
step: number;
|
||||
timezone: string;
|
||||
}): string {
|
||||
const time =
|
||||
step < SUB_MINUTE_STEP
|
||||
? DATE_TIME_FORMATS.TIME_SECONDS
|
||||
: DATE_TIME_FORMATS.TIME;
|
||||
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
|
||||
const from = dayjs(start * 1000).tz(timezone);
|
||||
const to = dayjs((start + step) * 1000).tz(timezone);
|
||||
const toFormat = to.isSame(from, 'day') ? time : dated;
|
||||
return `${from.format(dated)} → ${to.format(toFormat)}`;
|
||||
}
|
||||
|
||||
/** Formatted with the panel's unit. */
|
||||
export function formatBucketLabel({
|
||||
yAxis,
|
||||
row,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): string {
|
||||
const bucket = yAxis.rows[row];
|
||||
if (!bucket) {
|
||||
return '';
|
||||
}
|
||||
return formatRowLabel(bucket, (value) =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCount(count: number | null): string {
|
||||
return count === null ? NO_DATA_LABEL : count.toLocaleString();
|
||||
}
|
||||
|
||||
/** Highest first, so the list reads in the same direction as the y axis. */
|
||||
export function buildBucketRows({
|
||||
counts,
|
||||
yAxis,
|
||||
row,
|
||||
column,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
/** Row-major, as the renderer draws them. */
|
||||
counts: Array<ArrayLike<number | null> | undefined>;
|
||||
yAxis: HeatmapYAxis;
|
||||
row: number;
|
||||
column: number;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): HeatmapBucketRow[] {
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const rows: HeatmapBucketRow[] = [];
|
||||
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
|
||||
const index = row + offset;
|
||||
const bucket = yAxis.rows[index];
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
row: index,
|
||||
label: formatRowLabel(bucket, formatBucketValue),
|
||||
count: counts[index]?.[column] ?? null,
|
||||
isHovered: offset === 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest first. Groups that contributed nothing are still listed — that is an
|
||||
* answer, and dropping the row makes the list look truncated.
|
||||
*/
|
||||
export function buildContributionRows({
|
||||
series,
|
||||
timestamp,
|
||||
row,
|
||||
color,
|
||||
}: {
|
||||
/** Only the groups the legend has enabled — they are what the cell sums. */
|
||||
series: HeatmapSeries[];
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
row: number;
|
||||
/** The grid's one colour; there is no per-series hue. */
|
||||
color: string;
|
||||
}): HeatmapContributionRow[] {
|
||||
return series
|
||||
.map((entry) => {
|
||||
const point = entry.points.find((item) => item.timestamp === timestamp);
|
||||
return {
|
||||
label: entry.label,
|
||||
color,
|
||||
// Absent or null contributed nothing to the sum this breaks down.
|
||||
count: point?.counts[row] ?? 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
@@ -16,15 +16,3 @@
|
||||
.pinnedItem {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
|
||||
|
||||
import Styles from './TooltipHeader.module.scss';
|
||||
|
||||
@@ -65,14 +65,7 @@ export default function TooltipHeader({
|
||||
{showTooltipHeader && headerTitle && (
|
||||
<div className={cx(Styles.headerRow, headerRowClassName)}>
|
||||
<span>{headerTitle}</span>
|
||||
{isPinned && (
|
||||
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
|
||||
<>
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{isPinned && <TooltipPinnedBadge />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--callout-primary-title);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import Styles from './TooltipPinnedBadge.module.scss';
|
||||
|
||||
export default function TooltipPinnedBadge(): JSX.Element {
|
||||
return (
|
||||
<div className={Styles.status} data-testid="uplot-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,21 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it renders its own container, since none of its
|
||||
* states is the flat series list the shared `Tooltip` draws. */
|
||||
export interface HeatmapTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Needed to break a summed cell down by contribution. */
|
||||
series: HeatmapSeries[];
|
||||
/** Groups the legend has enabled; the cell sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
/** Same colour the legend and the densest cells use. */
|
||||
groupColor: string;
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -148,6 +148,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
side = 2, // bottom by default
|
||||
space,
|
||||
splits,
|
||||
gap = 5, // default gap is 5
|
||||
} = this.props;
|
||||
|
||||
@@ -179,6 +180,9 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
if (values) {
|
||||
axisConfig.values = values;
|
||||
}
|
||||
if (splits) {
|
||||
axisConfig.splits = splits;
|
||||
}
|
||||
if (gap !== undefined) {
|
||||
axisConfig.gap = gap;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,13 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
|
||||
// Special handling for time scales (X axis)
|
||||
if (time) {
|
||||
// An explicit range wins: the alignment below trims the tail of the window
|
||||
// to whole minutes, which is right for point-based series but drops the
|
||||
// final column of any chart whose marks span an interval.
|
||||
if (range) {
|
||||
return { [scaleKey]: { time: true, auto: false, range } };
|
||||
}
|
||||
|
||||
let minTime = this.min ?? 0;
|
||||
let maxTime = this.max ?? 0;
|
||||
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface AxisProps {
|
||||
};
|
||||
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
|
||||
values?: uPlot.Axis.Values;
|
||||
/** Explicit axis splits, overriding the default tick calculation. */
|
||||
splits?: uPlot.Axis.Splits;
|
||||
/** 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. */
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
clampColorSteps,
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
getMaxCount,
|
||||
getSmallestPositiveCount,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_OPACITY_ALPHA,
|
||||
normalizeCount,
|
||||
resolveCountDomain,
|
||||
} from '../colorScale';
|
||||
import { HeatmapColorMode, HeatmapColorScale } from '../types';
|
||||
|
||||
const SERIES_COLOR = '#4e74f8';
|
||||
|
||||
describe('getMaxCount', () => {
|
||||
it('ignores null cells', () => {
|
||||
expect(
|
||||
getMaxCount([
|
||||
[1, null, 9],
|
||||
[null, 4],
|
||||
]),
|
||||
).toBe(9);
|
||||
});
|
||||
|
||||
it('returns 0 for an empty or all-null grid', () => {
|
||||
expect(getMaxCount([])).toBe(0);
|
||||
expect(getMaxCount([[null, null]])).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores non-finite counts', () => {
|
||||
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSmallestPositiveCount', () => {
|
||||
it('ignores nulls, zeros and non-finite counts', () => {
|
||||
expect(
|
||||
getSmallestPositiveCount([
|
||||
[0, null, 4],
|
||||
[Number.NaN, 2, -3],
|
||||
]),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('returns null when nothing is above zero', () => {
|
||||
expect(getSmallestPositiveCount([[0, null]])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveCountDomain', () => {
|
||||
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 0,
|
||||
max: 20,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('honours explicit clamps', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
|
||||
).toStrictEqual({
|
||||
min: 10,
|
||||
max: 100,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a max at or below min', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
|
||||
).toStrictEqual({
|
||||
min: 50,
|
||||
max: 50,
|
||||
logFloor: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the log floor from the smallest positive count, below 1 included', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[0, 0.02, 0.8]])
|
||||
.logFloor,
|
||||
).toBeCloseTo(0.02, 6);
|
||||
});
|
||||
|
||||
it('keeps the log floor within MAX_LOG_DECADES of the max', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[1, 1e9]]).logFloor,
|
||||
).toBe(1e3);
|
||||
});
|
||||
|
||||
it('falls back to a floor of 1 for a grid without a positive count', () => {
|
||||
expect(
|
||||
resolveCountDomain({ minCount: null, maxCount: null }, [[null, 0]]).logFloor,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeCount', () => {
|
||||
const domain = { min: 0, max: 1000, logFloor: 1 };
|
||||
|
||||
it('spreads low counts on a log scale where a linear one washes them out', () => {
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
|
||||
|
||||
expect(log(10)).toBeCloseTo(1 / 3, 5);
|
||||
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
|
||||
expect(
|
||||
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
|
||||
).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
|
||||
it('puts 0 and 1 at the bottom of a log scale', () => {
|
||||
expect(
|
||||
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
expect(
|
||||
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reaches the top of the scale at max on every scale', () => {
|
||||
[
|
||||
HeatmapColorScale.Log,
|
||||
HeatmapColorScale.Sqrt,
|
||||
HeatmapColorScale.Linear,
|
||||
].forEach((scale) => {
|
||||
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
it('takes the square root of the linear position on a sqrt scale', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 250,
|
||||
domain: { min: 0, max: 1000, logFloor: 1 },
|
||||
scale: HeatmapColorScale.Sqrt,
|
||||
}),
|
||||
).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it('clamps counts outside the domain', () => {
|
||||
const scale = HeatmapColorScale.Linear;
|
||||
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
|
||||
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
|
||||
});
|
||||
|
||||
it('returns the bottom of the scale when min equals max', () => {
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 7,
|
||||
domain: { min: 7, max: 7, logFloor: 1 },
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('spreads a log domain that sits entirely below a count of 1', () => {
|
||||
const fractional = { min: 0, max: 1, logFloor: 0.001 };
|
||||
const log = (count: number): number =>
|
||||
normalizeCount({
|
||||
count,
|
||||
domain: fractional,
|
||||
scale: HeatmapColorScale.Log,
|
||||
});
|
||||
|
||||
expect(log(0.001)).toBe(0);
|
||||
expect(log(0.1)).toBeCloseTo(2 / 3, 5);
|
||||
expect(log(1)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('reads a log scale linearly when the floor reaches the top of the domain', () => {
|
||||
const domainAtFloor = { min: 0, max: 1, logFloor: 1 };
|
||||
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 1,
|
||||
domain: domainAtFloor,
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(
|
||||
normalizeCount({
|
||||
count: 0.5,
|
||||
domain: domainAtFloor,
|
||||
scale: HeatmapColorScale.Log,
|
||||
}),
|
||||
).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampColorSteps', () => {
|
||||
it('clamps to the supported range', () => {
|
||||
expect(clampColorSteps(1)).toBe(2);
|
||||
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
|
||||
expect(clampColorSteps(32)).toBe(32);
|
||||
});
|
||||
|
||||
it('falls back to the default for a non-finite value', () => {
|
||||
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHeatmapColorResolver', () => {
|
||||
const build = (
|
||||
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
|
||||
isDarkMode = true,
|
||||
): ReturnType<typeof createHeatmapColorResolver> =>
|
||||
createHeatmapColorResolver({
|
||||
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
|
||||
domain: { min: 0, max: 1000, logFloor: 1 },
|
||||
isDarkMode,
|
||||
seriesColor: SERIES_COLOR,
|
||||
});
|
||||
|
||||
it('leaves null cells uncoloured so they can be hatched', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(null)).toBeNull();
|
||||
expect(resolver.positionOf(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom colour, not the null treatment', () => {
|
||||
const resolver = build();
|
||||
|
||||
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
|
||||
});
|
||||
|
||||
it('emits one ramp entry per step', () => {
|
||||
expect(build({ steps: 8 }).ramp).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('maps the max count to the top of the ramp', () => {
|
||||
const resolver = build({ steps: 8 });
|
||||
|
||||
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
|
||||
});
|
||||
|
||||
it('picks different stops per theme so low counts stay near the surface', () => {
|
||||
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
|
||||
});
|
||||
|
||||
it('varies alpha in opacity mode, never below the visibility floor', () => {
|
||||
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
|
||||
|
||||
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
|
||||
// `color` drops the alpha channel from the string once it reaches 1.
|
||||
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
|
||||
});
|
||||
|
||||
it('prefers an explicit opacity fill over the series colour', () => {
|
||||
const resolver = build({
|
||||
mode: HeatmapColorMode.Opacity,
|
||||
fill: '#e5484d',
|
||||
steps: 2,
|
||||
});
|
||||
|
||||
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
|
||||
});
|
||||
|
||||
it('reports the domain it applied', () => {
|
||||
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,649 @@
|
||||
import {
|
||||
canUseLogAxis,
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnAlignedSplits,
|
||||
resolveColumnIndex,
|
||||
resolveHeatmapYAxis,
|
||||
resolveRowIndex,
|
||||
} from '../geometry';
|
||||
import { HeatmapAxisScale } from '../types';
|
||||
|
||||
const BOUNDS = [128, 256, 1024, 4096];
|
||||
|
||||
describe('canUseLogAxis', () => {
|
||||
it('accepts strictly positive bounds', () => {
|
||||
expect(canUseLogAxis(BOUNDS)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a zero or negative bound', () => {
|
||||
expect(canUseLogAxis([0, 128])).toBe(false);
|
||||
expect(canUseLogAxis([-1, 128])).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty bounds', () => {
|
||||
expect(canUseLogAxis([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis', () => {
|
||||
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(BOUNDS.length + 1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
upper: 128,
|
||||
isUnderflow: true,
|
||||
isOverflow: false,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
|
||||
expect(rows[4]).toMatchObject({
|
||||
lower: 4096,
|
||||
isOverflow: true,
|
||||
isUnderflow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes one edge per row boundary, ascending', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(edges).toHaveLength(rows.length + 1);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('places bounds in log space so row heights are log-proportional', () => {
|
||||
const { splits, min, max } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
|
||||
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
|
||||
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
|
||||
});
|
||||
|
||||
it('keeps bounds in value space on a linear axis', () => {
|
||||
const { splits, min } = resolveHeatmapYAxis(
|
||||
[10, 20, 30],
|
||||
HeatmapAxisScale.Linear,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([10, 20, 30]);
|
||||
// Mean gap is 10, and the underflow edge never crosses zero.
|
||||
expect(min).toBe(0);
|
||||
});
|
||||
|
||||
it('sorts and de-duplicates bounds', () => {
|
||||
const { rows, splits } = resolveHeatmapYAxis(
|
||||
[256, 128, 256, Number.NaN],
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
|
||||
expect(rows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('gives a single bound an underflow and an overflow row', () => {
|
||||
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].isUnderflow).toBe(true);
|
||||
expect(rows[1].isOverflow).toBe(true);
|
||||
expect(edges).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('degrades to an empty axis with no bounds', () => {
|
||||
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
|
||||
const { overflowSplit, edges } = resolveHeatmapYAxis(
|
||||
BOUNDS,
|
||||
HeatmapAxisScale.Log,
|
||||
);
|
||||
|
||||
// A full row above the last boundary tick, so the two labels cannot collide.
|
||||
expect(overflowSplit).toBe(edges[edges.length - 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRowIndex', () => {
|
||||
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
|
||||
it('finds the row containing a value', () => {
|
||||
expect(resolveRowIndex(edges, 200)).toBe(1);
|
||||
expect(resolveRowIndex(edges, 2000)).toBe(3);
|
||||
});
|
||||
|
||||
it('assigns a boundary to the row it opens', () => {
|
||||
expect(resolveRowIndex(edges, 256)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last row on the top edge', () => {
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
|
||||
edges.length - 2,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null outside the grid', () => {
|
||||
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
|
||||
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without at least one row', () => {
|
||||
expect(resolveRowIndex([5], 5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnIndex', () => {
|
||||
const timestamps = [100, 160, 220, 280];
|
||||
const step = 60;
|
||||
|
||||
it('resolves by containment, not proximity', () => {
|
||||
// 155 is nearer to 160, but the observations at 155 belong to column 0.
|
||||
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
|
||||
});
|
||||
|
||||
it('includes the column start and excludes its end', () => {
|
||||
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
|
||||
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
|
||||
});
|
||||
|
||||
it('covers the trailing column using the step, not the next timestamp', () => {
|
||||
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
|
||||
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null before the first column', () => {
|
||||
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null with no columns', () => {
|
||||
expect(resolveColumnIndex([], 100, step)).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves the last column open when the step is unknown', () => {
|
||||
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRowLabel', () => {
|
||||
const format = (value: number): string => `${value}ms`;
|
||||
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
it('labels the underflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
|
||||
});
|
||||
|
||||
it('labels the overflow row by its only real bound', () => {
|
||||
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
|
||||
});
|
||||
|
||||
it('labels an interior row as a range', () => {
|
||||
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimateAxisSplits', () => {
|
||||
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const domain = { min: 0, max: 10 };
|
||||
|
||||
it('keeps every tick when they all fit', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
|
||||
it('thins to whatever fits at the available height', () => {
|
||||
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
|
||||
).toStrictEqual([0, 2, 4, 6, 8, 10]);
|
||||
});
|
||||
|
||||
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 40,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect(thinned[thinned.length - 1]).toBe(10);
|
||||
});
|
||||
|
||||
it('returns ascending positions', () => {
|
||||
const thinned = decimateAxisSplits({
|
||||
...domain,
|
||||
splits,
|
||||
plotHeight: 60,
|
||||
minGapPx: 18,
|
||||
});
|
||||
|
||||
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
|
||||
});
|
||||
|
||||
it('thins by pixel distance, not index, so uneven rows are handled', () => {
|
||||
// Three boundaries bunched at the bottom of a wide linear domain: only the
|
||||
// first and the far-away last are far enough apart to both get labels.
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits: [1, 2, 3, 1000],
|
||||
min: 0,
|
||||
max: 1000,
|
||||
plotHeight: 200,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual([3, 1000]);
|
||||
});
|
||||
|
||||
it('leaves the tick set alone when it cannot measure', () => {
|
||||
expect(
|
||||
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
|
||||
).toStrictEqual(splits);
|
||||
expect(
|
||||
decimateAxisSplits({
|
||||
splits,
|
||||
min: 5,
|
||||
max: 5,
|
||||
plotHeight: 400,
|
||||
minGapPx: 18,
|
||||
}),
|
||||
).toStrictEqual(splits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — the scale auto picks', () => {
|
||||
// The OTel SDK default explicit bucket boundaries, which start at zero.
|
||||
const OTEL = [
|
||||
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
|
||||
];
|
||||
// Clock skew in ms — a logs/traces field that straddles zero.
|
||||
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
|
||||
|
||||
const PLOT_HEIGHT = 250;
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[]): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
|
||||
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
const span = edges[edges.length - 1] - edges[0];
|
||||
const heights = edges
|
||||
.slice(1)
|
||||
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
|
||||
return Math.min(...heights);
|
||||
}
|
||||
|
||||
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Auto);
|
||||
|
||||
// A linear fallback would leave the boundaries untransformed.
|
||||
expect(splits).not.toStrictEqual([0, 5, 10]);
|
||||
});
|
||||
|
||||
it('is what an all-positive layout does NOT get — that stays a plain log', () => {
|
||||
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Auto);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
});
|
||||
|
||||
it('gives every row a usable height for the OTel default boundaries', () => {
|
||||
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
|
||||
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('gives the row above zero one bucket of height, not a whole decade', () => {
|
||||
const heights = rowHeights(OTEL);
|
||||
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto);
|
||||
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
|
||||
const positive = OTEL.filter((bound) => bound > 0);
|
||||
const bucket =
|
||||
(Math.log10(positive[positive.length - 1]) - Math.log10(positive[0])) /
|
||||
(positive.length - 1);
|
||||
|
||||
expect(heights[nearZero]).toBeCloseTo(bucket, 6);
|
||||
});
|
||||
|
||||
it('reads a lone zero boundary as a log layout, not a zero-crossing one', () => {
|
||||
expect(resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto).edges).toStrictEqual(
|
||||
resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log).edges,
|
||||
);
|
||||
});
|
||||
|
||||
it('turns to symlog once a boundary reaches below zero', () => {
|
||||
expect(resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto).edges).toStrictEqual(
|
||||
resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Symlog).edges,
|
||||
);
|
||||
});
|
||||
|
||||
it('places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('keeps negative boundaries ascending', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('round-trips a boundary back to its bucket value', () => {
|
||||
const { splits, toBucketValue } = resolveHeatmapYAxis(
|
||||
SKEW,
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
|
||||
expect(
|
||||
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
|
||||
).toStrictEqual(SKEW);
|
||||
});
|
||||
|
||||
it('derives the linear threshold from the smallest non-zero boundary', () => {
|
||||
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
|
||||
const { edges, rows } = resolveHeatmapYAxis(
|
||||
[-100, -10, 0, 10, 100],
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
const crossing = rows.findIndex(
|
||||
(row) => row.lower === -10 && row.upper === 0,
|
||||
);
|
||||
|
||||
expect(edges[crossing]).toBeCloseTo(-1, 6);
|
||||
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('leaves an all-positive layout on a plain log axis', () => {
|
||||
const { splits } = resolveHeatmapYAxis(
|
||||
[128, 256, 1024],
|
||||
HeatmapAxisScale.Auto,
|
||||
);
|
||||
|
||||
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('falls back to linear when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Auto);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
|
||||
// The low end of the OTel SDK defaults: a zero bucket, then positive bounds.
|
||||
const ZERO_HEAD = [0, 5, 10, 25];
|
||||
// Clock skew in ms — a field that straddles zero.
|
||||
const SKEW = [-100, -10, 0, 10, 100];
|
||||
|
||||
/** Row heights in axis units, which map linearly to pixels. */
|
||||
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
|
||||
const { edges } = resolveHeatmapYAxis(bounds, scale);
|
||||
return edges.slice(1).map((edge, index) => edge - edges[index]);
|
||||
}
|
||||
|
||||
it('log stays a plain log10 for a positive layout', () => {
|
||||
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
|
||||
});
|
||||
|
||||
it('log keeps its own answer for a zero bucket rather than becoming a symlog', () => {
|
||||
const { splits } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
|
||||
// One bucket below the smallest positive bound, where a symlog would put it
|
||||
// a whole decade below.
|
||||
const gap = (Math.log10(25) - Math.log10(5)) / 2;
|
||||
|
||||
expect(splits[0]).toBeCloseTo(Math.log10(5) - gap, 6);
|
||||
expect(splits.slice(1)).toStrictEqual([5, 10, 25].map((b) => Math.log10(b)));
|
||||
});
|
||||
|
||||
it('log spends a bucket on the zero-crossing row where symlog spends a decade', () => {
|
||||
const gap = (Math.log10(25) - Math.log10(5)) / 2;
|
||||
|
||||
// Row 1 is (0, 5] — the row above the zero bucket.
|
||||
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Log)[1]).toBeCloseTo(gap, 6);
|
||||
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Symlog)[1]).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('log leaves every edge ascending with a zero bucket in the layout', () => {
|
||||
const { edges } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
|
||||
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('log squashes several non-positive boundaries onto one edge — what symlog is for', () => {
|
||||
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
|
||||
|
||||
// -100, -10 and 0 have no logarithm and share the floor.
|
||||
expect(edges[1]).toBe(edges[2]);
|
||||
expect(edges[2]).toBe(edges[3]);
|
||||
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
|
||||
});
|
||||
|
||||
it('log has nothing to compress without a positive boundary and stays linear', () => {
|
||||
const { splits } = resolveHeatmapYAxis([-5, 0], HeatmapAxisScale.Log);
|
||||
|
||||
expect(splits).toStrictEqual([-5, 0]);
|
||||
});
|
||||
|
||||
it('symlog is a choice for a positive layout too, and is not the log axis', () => {
|
||||
const positive = [1, 10, 100];
|
||||
|
||||
expect(
|
||||
resolveHeatmapYAxis(positive, HeatmapAxisScale.Log).splits,
|
||||
).toStrictEqual([0, 1, 2]);
|
||||
// Threshold 1: the boundaries sit a decade apart, one unit above the linear band.
|
||||
expect(
|
||||
resolveHeatmapYAxis(positive, HeatmapAxisScale.Symlog).splits,
|
||||
).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('symlog places boundaries either side of zero symmetrically', () => {
|
||||
const heights = rowHeights(SKEW, HeatmapAxisScale.Symlog);
|
||||
|
||||
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('symlog has no magnitude to scale against when every boundary is zero', () => {
|
||||
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Symlog);
|
||||
|
||||
expect(splits).toStrictEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColumnAlignedSplits', () => {
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
|
||||
/** uPlot's `tzDate` for a fixed-offset zone: the returned date's local fields
|
||||
* read as that zone's wall clock, whatever the machine's own zone is. */
|
||||
const zoneAt =
|
||||
(offsetSeconds: number) =>
|
||||
(timestamp: number): Date => {
|
||||
const browserOffset =
|
||||
-new Date(timestamp * 1e3).getTimezoneOffset() * MINUTE;
|
||||
return new Date((timestamp + offsetSeconds - browserOffset) * 1e3);
|
||||
};
|
||||
|
||||
const UTC = zoneAt(0);
|
||||
/** IST, whose half-hour offset is what pulls ticks off round local times. */
|
||||
const IST = zoneAt(5.5 * HOUR);
|
||||
/** 2024-03-11T00:00:00Z, a Monday. */
|
||||
const MIDNIGHT_UTC = 1_710_115_200;
|
||||
|
||||
it('lands every tick on a column edge', () => {
|
||||
const step = 90;
|
||||
const anchor = 1_700_000_010;
|
||||
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor,
|
||||
step,
|
||||
incr: 5 * MINUTE,
|
||||
min: anchor,
|
||||
max: anchor + 40 * step,
|
||||
});
|
||||
|
||||
expect(splits.length).toBeGreaterThan(1);
|
||||
splits.forEach((split) => {
|
||||
expect((split - anchor) % step).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rounds the increment up to a whole number of columns', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: 90,
|
||||
incr: 5 * MINUTE,
|
||||
min: 0,
|
||||
max: HOUR,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
// 300s asked for, 360s is the next multiple of the 90s column.
|
||||
expect(splits[1] - splits[0]).toBe(360);
|
||||
});
|
||||
|
||||
it('covers the visible range without overshooting it', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: 1000,
|
||||
step: 100,
|
||||
incr: 200,
|
||||
min: 1050,
|
||||
max: 1650,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
expect(splits[0]).toBeGreaterThanOrEqual(1050);
|
||||
expect(splits[splits.length - 1]).toBeLessThanOrEqual(1650);
|
||||
expect(splits[0] - 200).toBeLessThan(1050);
|
||||
});
|
||||
|
||||
it("starts hourly ticks on the timezone's own hour, not the epoch's", () => {
|
||||
const args = {
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 5 * MINUTE,
|
||||
incr: HOUR,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 3 * HOUR,
|
||||
};
|
||||
|
||||
// 05:30 IST is midnight UTC, so the two disagree by the half hour.
|
||||
expect(resolveColumnAlignedSplits({ ...args, toDate: UTC })).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + HOUR,
|
||||
MIDNIGHT_UTC + 2 * HOUR,
|
||||
MIDNIGHT_UTC + 3 * HOUR,
|
||||
]);
|
||||
expect(resolveColumnAlignedSplits({ ...args, toDate: IST })).toStrictEqual([
|
||||
MIDNIGHT_UTC + 0.5 * HOUR,
|
||||
MIDNIGHT_UTC + 1.5 * HOUR,
|
||||
MIDNIGHT_UTC + 2.5 * HOUR,
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives up the round local time when no column edge carries one', () => {
|
||||
// Hour-wide columns start on the UTC hour, so 00:00 IST is mid-cell and
|
||||
// the nearest edge — 00:30 IST — is as close as the grid gets.
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: HOUR,
|
||||
incr: HOUR,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 2 * HOUR,
|
||||
toDate: IST,
|
||||
});
|
||||
|
||||
expect(splits).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + HOUR,
|
||||
MIDNIGHT_UTC + 2 * HOUR,
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts a daily tick on the local day boundary', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 15 * MINUTE,
|
||||
incr: DAY,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 3 * DAY,
|
||||
toDate: IST,
|
||||
});
|
||||
|
||||
// 18:30 UTC the previous day is IST midnight, and 15m columns carry it.
|
||||
expect(splits).toHaveLength(3);
|
||||
splits.forEach((split) => {
|
||||
expect(IST(split).getHours()).toBe(0);
|
||||
expect(IST(split).getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('walks month ticks as calendar dates, snapped to column edges', () => {
|
||||
const splits = resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: DAY,
|
||||
incr: 28 * DAY,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 120 * DAY,
|
||||
toDate: UTC,
|
||||
});
|
||||
|
||||
// Month starts, not a drifting 28-day cadence that repeats a month name.
|
||||
expect(
|
||||
splits.map((split) => new Date(split * 1e3).toISOString()),
|
||||
).toStrictEqual([
|
||||
'2024-04-01T00:00:00.000Z',
|
||||
'2024-05-01T00:00:00.000Z',
|
||||
'2024-06-01T00:00:00.000Z',
|
||||
'2024-07-01T00:00:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to uPlot's own increment without a column width", () => {
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: MIDNIGHT_UTC,
|
||||
step: 0,
|
||||
incr: 5 * MINUTE,
|
||||
min: MIDNIGHT_UTC,
|
||||
max: MIDNIGHT_UTC + 15 * MINUTE,
|
||||
toDate: UTC,
|
||||
}),
|
||||
).toStrictEqual([
|
||||
MIDNIGHT_UTC,
|
||||
MIDNIGHT_UTC + 5 * MINUTE,
|
||||
MIDNIGHT_UTC + 10 * MINUTE,
|
||||
MIDNIGHT_UTC + 15 * MINUTE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('has nothing to place on an empty or inverted range', () => {
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: MINUTE,
|
||||
incr: MINUTE,
|
||||
min: 10,
|
||||
max: 10,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
expect(
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: 0,
|
||||
step: MINUTE,
|
||||
incr: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { resolveHeatmapGrid } from '../grid';
|
||||
import { HeatmapSeries } from '../types';
|
||||
|
||||
const BUCKETS = [10, 20];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups over two columns, each missing a value the other reports. */
|
||||
const TWO_GROUPS: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'cart',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [1, 2, 3] },
|
||||
{ timestamp: 120, counts: [null, 5, 6] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'checkout',
|
||||
points: [
|
||||
{ timestamp: 60, counts: [10, 20, 30] },
|
||||
{ timestamp: 120, counts: [40, null, 60] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function resolve(
|
||||
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
|
||||
): ReturnType<typeof resolveHeatmapGrid> {
|
||||
return resolveHeatmapGrid({
|
||||
buckets: BUCKETS,
|
||||
step: STEP,
|
||||
series: TWO_GROUPS,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('resolveHeatmapGrid', () => {
|
||||
it('pivots per-timestamp count arrays into one row per bucket', () => {
|
||||
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
|
||||
|
||||
// 2 boundaries describe 3 rows; each row spans both columns.
|
||||
expect(counts).toStrictEqual([
|
||||
[1, null],
|
||||
[2, 5],
|
||||
[3, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the bounds and step through untouched', () => {
|
||||
const { bounds, step } = resolve();
|
||||
|
||||
expect(bounds).toStrictEqual(BUCKETS);
|
||||
expect(step).toBe(STEP);
|
||||
});
|
||||
|
||||
it('sums every group for the combined view', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
expect(counts[2]).toStrictEqual([33, 66]);
|
||||
});
|
||||
|
||||
it('keeps one group"s count where the other has no data', () => {
|
||||
const { counts } = resolve();
|
||||
|
||||
// cart is null at 120 in row 0 while checkout reports 40.
|
||||
expect(counts[0][1]).toBe(40);
|
||||
// checkout is null at 120 in row 1 while cart reports 5.
|
||||
expect(counts[1][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('reports a cell as no-data only when every group is missing it', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[null], [null]]);
|
||||
});
|
||||
|
||||
it('distinguishes a zero count from no data', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
|
||||
});
|
||||
|
||||
expect(counts[0][0]).toBe(0);
|
||||
expect(counts[1][0]).toBeNull();
|
||||
});
|
||||
|
||||
it('sums only the groups the legend has enabled', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
expect(counts[2]).toStrictEqual([3, 6]);
|
||||
});
|
||||
|
||||
it('sums every group when the legend passes nothing', () => {
|
||||
const { counts } = resolve({ visibleGroups: undefined });
|
||||
|
||||
expect(counts[0]).toStrictEqual([11, 40]);
|
||||
});
|
||||
|
||||
it('ignores an enabled label that left the result', () => {
|
||||
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
|
||||
|
||||
expect(counts[0]).toStrictEqual([1, null]);
|
||||
});
|
||||
|
||||
it('empties the grid when every group is excluded', () => {
|
||||
const { timestamps, counts } = resolve({ visibleGroups: [] });
|
||||
|
||||
expect(timestamps).toStrictEqual([]);
|
||||
expect(counts.every((row) => row.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('unions timestamps when groups do not align', () => {
|
||||
const { timestamps, counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
|
||||
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
expect(counts[0]).toStrictEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('sorts columns ascending regardless of response order', () => {
|
||||
const { timestamps } = resolve({
|
||||
buckets: [10],
|
||||
series: [
|
||||
{
|
||||
label: 'a',
|
||||
points: [
|
||||
{ timestamp: 180, counts: [1, 2] },
|
||||
{ timestamp: 60, counts: [3, 4] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(timestamps).toStrictEqual([60, 180]);
|
||||
});
|
||||
|
||||
it('pads rows the response left short', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10, 20, 30],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
|
||||
});
|
||||
|
||||
it('ignores counts beyond the bucket rows', () => {
|
||||
const { counts } = resolve({
|
||||
buckets: [10],
|
||||
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
|
||||
});
|
||||
|
||||
expect(counts).toStrictEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
it('degrades to an empty grid with no buckets or no series', () => {
|
||||
expect(resolve({ buckets: [] })).toStrictEqual({
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
});
|
||||
expect(resolve({ series: [] }).counts).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
|
||||
import { resolveHeatmapYAxis } from '../geometry';
|
||||
import { createHeatmapHooks } from '../heatmapPlugin';
|
||||
import { HeatmapAxisScale, HeatmapCell } from '../types';
|
||||
|
||||
const BOUNDS = [100, 1000];
|
||||
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
|
||||
const TIMESTAMPS = [1000, 1060, 1120];
|
||||
const STEP = 60;
|
||||
const PLOT_WIDTH = 300;
|
||||
const PLOT_HEIGHT = 300;
|
||||
|
||||
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
|
||||
const DATA = [
|
||||
TIMESTAMPS,
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
] as unknown as uPlot.AlignedData;
|
||||
|
||||
interface FakeContext {
|
||||
fillRect: jest.Mock;
|
||||
fills: string[];
|
||||
}
|
||||
|
||||
interface FakePlot {
|
||||
plot: uPlot;
|
||||
context: FakeContext;
|
||||
setSeries: jest.Mock;
|
||||
over: HTMLDivElement;
|
||||
}
|
||||
|
||||
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
|
||||
const over = document.createElement('div');
|
||||
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
|
||||
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
|
||||
|
||||
const fills: string[] = [];
|
||||
const fillRect = jest.fn();
|
||||
const context = { fills, fillRect };
|
||||
const setSeries = jest.fn();
|
||||
|
||||
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
|
||||
const ySpan = Y_AXIS.max - Y_AXIS.min;
|
||||
|
||||
const ctx = {
|
||||
save: jest.fn(),
|
||||
restore: jest.fn(),
|
||||
beginPath: jest.fn(),
|
||||
rect: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
moveTo: jest.fn(),
|
||||
lineTo: jest.fn(),
|
||||
stroke: jest.fn(),
|
||||
setLineDash: jest.fn(),
|
||||
createPattern: jest.fn(() => null),
|
||||
set fillStyle(value: string) {
|
||||
fills.push(value);
|
||||
},
|
||||
fillRect: (...args: number[]): void => {
|
||||
fillRect(...args);
|
||||
},
|
||||
};
|
||||
|
||||
const plot = {
|
||||
data: DATA,
|
||||
cursor,
|
||||
over,
|
||||
setSeries,
|
||||
ctx,
|
||||
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
|
||||
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
|
||||
// x grows left to right; y is inverted, so the highest bucket is at the top.
|
||||
valToPos: (value: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
|
||||
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
|
||||
posToVal: (pos: number, scaleKey: string): number =>
|
||||
scaleKey === 'x'
|
||||
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
|
||||
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
|
||||
};
|
||||
|
||||
return { plot: plot as unknown as uPlot, context, setSeries, over };
|
||||
}
|
||||
|
||||
function createHooks(
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void,
|
||||
dimOnHover = true,
|
||||
): ReturnType<typeof createHeatmapHooks> {
|
||||
return createHeatmapHooks({
|
||||
yAxis: Y_AXIS,
|
||||
step: STEP,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
}
|
||||
|
||||
describe('heatmap renderer — lifecycle', () => {
|
||||
it('mounts the hover overlay into the plot overlay and tears it down', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
hooks.destroy(plot);
|
||||
expect(
|
||||
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — draw', () => {
|
||||
it('paints every cell of every visible column', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
|
||||
// available under jsdom.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
|
||||
const hooks = createHooks();
|
||||
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = zeroed;
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(9);
|
||||
expect(new Set(context.fills).size).toBe(1);
|
||||
});
|
||||
|
||||
it('skips columns outside the current x range', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { scales: unknown }).scales = {
|
||||
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
|
||||
};
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
// Only the first two columns overlap the range; the third starts past its end.
|
||||
// 2 columns x 3 rows, less the null cell in column 1.
|
||||
expect(context.fillRect).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('draws nothing without columns', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, context } = createFakePlot({ left: -10, top: -10 });
|
||||
(plot as { data: unknown }).data = [[]];
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.draw(plot);
|
||||
|
||||
expect(context.fillRect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heatmap renderer — hover', () => {
|
||||
it('focuses the hovered row and reports the cell under the cursor', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
// Left third of the plot is column 0; the top third is the overflow row.
|
||||
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
|
||||
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
|
||||
});
|
||||
|
||||
it('reports a data gap as a null count instead of zero', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({
|
||||
left: PLOT_WIDTH / 2,
|
||||
top: PLOT_HEIGHT / 2,
|
||||
});
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledWith({
|
||||
row: 1,
|
||||
column: 1,
|
||||
count: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-report the same cell', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the overlay over the hovered cell and dims around it', () => {
|
||||
const hooks = createHooks(undefined, true);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.display).toBe('block');
|
||||
// Column 0 spans the left third of a 300px plot.
|
||||
expect(overlay?.lastElementChild).toHaveStyle({
|
||||
left: '0px',
|
||||
width: '100px',
|
||||
});
|
||||
});
|
||||
|
||||
it('clips the overlay to the plot area', () => {
|
||||
const hooks = createHooks();
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
// An end cell whose bucket or time slice is only partly in view is positioned
|
||||
// past the axis; the plot area's edge is where the highlight has to stop.
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.style.overflow).toBe('hidden');
|
||||
});
|
||||
|
||||
it('collapses the dim rects when dimming is off', () => {
|
||||
const hooks = createHooks(undefined, false);
|
||||
const { plot, over } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
|
||||
const overlay = over.querySelector<HTMLDivElement>(
|
||||
'[data-testid="heatmap-hover-overlay"]',
|
||||
);
|
||||
expect(overlay?.firstElementChild).toHaveStyle({
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
});
|
||||
|
||||
it('releases focus and hides the overlay when the cursor leaves', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: -10,
|
||||
top: -10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
|
||||
expect(
|
||||
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
|
||||
?.style.display,
|
||||
).toBe('none');
|
||||
});
|
||||
|
||||
it('clears the hover when the cursor is inside the plot but past the last column', () => {
|
||||
const onHoverChange = jest.fn();
|
||||
const hooks = createHooks(onHoverChange);
|
||||
const { plot } = createFakePlot({ left: 10, top: 10 });
|
||||
|
||||
hooks.init(plot);
|
||||
hooks.setCursor(plot);
|
||||
(plot as { data: unknown }).data = [[], [], [], []];
|
||||
(plot as { cursor: { left: number; top: number } }).cursor = {
|
||||
left: 10,
|
||||
top: 10,
|
||||
};
|
||||
hooks.setCursor(plot);
|
||||
|
||||
expect(onHoverChange).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { getPaletteStops } from '../palettes';
|
||||
import { HeatmapColorPalette } from '../types';
|
||||
|
||||
const ALL_PALETTES = Object.values(HeatmapColorPalette);
|
||||
|
||||
/** Perceived brightness, good enough to tell a ramp's ends apart. */
|
||||
function luminance(hex: string): number {
|
||||
const value = parseInt(hex.slice(1), 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
describe('getPaletteStops', () => {
|
||||
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
expect(stops).toHaveLength(9);
|
||||
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
|
||||
});
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s climbs from dark to bright on a dark panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, true);
|
||||
|
||||
// Low counts must sit near the surface, whichever direction the ramp is
|
||||
// stored in — otherwise empty cells become the loudest thing on screen.
|
||||
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)(
|
||||
'%s falls from pale to saturated on a light panel',
|
||||
(palette) => {
|
||||
const stops = getPaletteStops(palette, false);
|
||||
|
||||
expect(luminance(stops[0])).toBeGreaterThan(
|
||||
luminance(stops[stops.length - 1]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
|
||||
// Only the polarity flips; the palette itself is theme-independent.
|
||||
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
|
||||
getPaletteStops(palette, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('never mutates the stored ramp when reversing it', () => {
|
||||
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
|
||||
|
||||
expect(first).toStrictEqual(second);
|
||||
});
|
||||
|
||||
it('falls back to the first ramp for an unknown palette', () => {
|
||||
const unknown = 'nope' as HeatmapColorPalette;
|
||||
|
||||
expect(getPaletteStops(unknown, true)).toStrictEqual(
|
||||
getPaletteStops(HeatmapColorPalette.Ice, true),
|
||||
);
|
||||
});
|
||||
|
||||
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
|
||||
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
|
||||
|
||||
// Every stop is a grey: red, green and blue channels stay equal.
|
||||
stops.forEach((stop) => {
|
||||
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
|
||||
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
|
||||
});
|
||||
});
|
||||
});
|
||||
244
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
244
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/colorScale.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Color as DesignToken } from '@signozhq/design-tokens';
|
||||
import Color from 'color';
|
||||
|
||||
import { getPaletteStops } from './palettes';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapColorOptions,
|
||||
HeatmapColorScale,
|
||||
HeatmapColorPalette,
|
||||
} from './types';
|
||||
|
||||
export const MIN_COLOR_STEPS = 2;
|
||||
export const MAX_COLOR_STEPS = 128;
|
||||
export const DEFAULT_COLOR_STEPS = 64;
|
||||
|
||||
/** Without a floor, the lowest counts read as "no data". */
|
||||
export const MIN_OPACITY_ALPHA = 0.1;
|
||||
|
||||
/** Log's bottom when the grid holds no positive count to take it from. */
|
||||
const DEFAULT_LOG_FLOOR = 1;
|
||||
|
||||
/** Widest span a single ramp is stretched over on a log scale. */
|
||||
const MAX_LOG_DECADES = 6;
|
||||
|
||||
/** Used when neither an explicit fill nor a series colour is available. */
|
||||
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
|
||||
|
||||
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
|
||||
mode: HeatmapColorMode.Palette,
|
||||
scale: HeatmapColorScale.Log,
|
||||
minCount: null,
|
||||
maxCount: null,
|
||||
palette: HeatmapColorPalette.Lava,
|
||||
steps: DEFAULT_COLOR_STEPS,
|
||||
fill: '',
|
||||
};
|
||||
|
||||
export interface CountDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
/** Bottom decade of the log scale: the smallest count it separates from zero.
|
||||
* Always positive, since log has no bottom otherwise. */
|
||||
logFloor: number;
|
||||
}
|
||||
|
||||
/** Highest count, ignoring `null`. 0 for an empty grid. */
|
||||
export function getMaxCount(counts: Array<Array<number | null>>): number {
|
||||
let max = 0;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (count !== null && Number.isFinite(count) && count > max) {
|
||||
max = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Smallest count above zero, ignoring `null`. `null` for a grid without one. */
|
||||
export function getSmallestPositiveCount(
|
||||
counts: Array<Array<number | null>>,
|
||||
): number | null {
|
||||
let smallest: number | null = null;
|
||||
for (const row of counts) {
|
||||
for (const count of row) {
|
||||
if (
|
||||
count !== null &&
|
||||
Number.isFinite(count) &&
|
||||
count > 0 &&
|
||||
(smallest === null || count < smallest)
|
||||
) {
|
||||
smallest = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
return smallest;
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid's own resolution: whole counts floor at 1, while a heatmap of rates or
|
||||
* ratios can live entirely below it, where a fixed floor of 1 would flatten every
|
||||
* cell onto the bottom colour. Capped at `MAX_LOG_DECADES` so one stray tiny cell
|
||||
* cannot stretch the ramp over a range nothing else occupies.
|
||||
*/
|
||||
function resolveLogFloor(
|
||||
counts: Array<Array<number | null>>,
|
||||
max: number,
|
||||
): number {
|
||||
const smallest = getSmallestPositiveCount(counts) ?? DEFAULT_LOG_FLOOR;
|
||||
return Math.max(smallest, max / 10 ** MAX_LOG_DECADES);
|
||||
}
|
||||
|
||||
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
|
||||
export function resolveCountDomain(
|
||||
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
|
||||
counts: Array<Array<number | null>>,
|
||||
): CountDomain {
|
||||
const min = options.minCount ?? 0;
|
||||
const max = options.maxCount ?? getMaxCount(counts);
|
||||
const logFloor = resolveLogFloor(counts, max);
|
||||
return max > min ? { min, max, logFloor } : { min, max: min, logFloor };
|
||||
}
|
||||
|
||||
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
|
||||
* all-zero grid renders at the bottom rather than disappearing. */
|
||||
export function normalizeCount({
|
||||
count,
|
||||
domain,
|
||||
scale,
|
||||
}: {
|
||||
count: number;
|
||||
domain: CountDomain;
|
||||
scale: HeatmapColorScale;
|
||||
}): number {
|
||||
const { min, max } = domain;
|
||||
if (!(max > min)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const clamped = Math.min(Math.max(count, min), max);
|
||||
|
||||
if (scale === HeatmapColorScale.Log) {
|
||||
// Anything at or below the floor sits at the bottom; log cannot place it.
|
||||
const bottom = Math.max(min, domain.logFloor);
|
||||
const logMin = Math.log10(bottom);
|
||||
const logMax = Math.log10(max);
|
||||
if (logMax > logMin) {
|
||||
return (Math.log10(Math.max(clamped, bottom)) - logMin) / (logMax - logMin);
|
||||
}
|
||||
// The floor already reaches the top of the domain, so there is no span to
|
||||
// spread logarithmically. Fall through rather than flatten the whole grid.
|
||||
}
|
||||
|
||||
const linear = (clamped - min) / (max - min);
|
||||
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
|
||||
}
|
||||
|
||||
export function clampColorSteps(steps: number): number {
|
||||
if (!Number.isFinite(steps)) {
|
||||
return DEFAULT_COLOR_STEPS;
|
||||
}
|
||||
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
|
||||
}
|
||||
|
||||
/** Colour at `t` (0..1) along a multi-stop ramp. */
|
||||
function sampleStops(stops: string[], t: number): string {
|
||||
if (stops.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (stops.length === 1) {
|
||||
return stops[0];
|
||||
}
|
||||
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
|
||||
const lower = Math.min(Math.floor(scaled), stops.length - 2);
|
||||
return Color(stops[lower])
|
||||
.mix(Color(stops[lower + 1]), scaled - lower)
|
||||
.hex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
|
||||
* fill at full strength. Depends only on the options, not on the data, so callers
|
||||
* can read it before a grid exists.
|
||||
*/
|
||||
export function resolveExtremeColor({
|
||||
options,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
seriesColor: string;
|
||||
}): string {
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
|
||||
}
|
||||
|
||||
export interface HeatmapColorResolver {
|
||||
/** `null` for a `null` count, which must be hatched. */
|
||||
colorFor: (count: number | null) => string | null;
|
||||
/** 0..1, or `null` for a `null` count. */
|
||||
positionOf: (count: number | null) => number | null;
|
||||
/** Low to high. The colour bar renders exactly these. */
|
||||
ramp: string[];
|
||||
domain: CountDomain;
|
||||
}
|
||||
|
||||
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
|
||||
* fill, so the grid matches its group's legend swatch. */
|
||||
export function createHeatmapColorResolver({
|
||||
options,
|
||||
domain,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
}: {
|
||||
options: HeatmapColorOptions;
|
||||
domain: CountDomain;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `options.fill` is empty. */
|
||||
seriesColor: string;
|
||||
}): HeatmapColorResolver {
|
||||
const steps = clampColorSteps(options.steps);
|
||||
const positions = Array.from({ length: steps }, (_, index) =>
|
||||
steps === 1 ? 0 : index / (steps - 1),
|
||||
);
|
||||
|
||||
let ramp: string[];
|
||||
if (options.mode === HeatmapColorMode.Opacity) {
|
||||
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
|
||||
ramp = positions.map((t) =>
|
||||
base
|
||||
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
|
||||
.rgb()
|
||||
.string(),
|
||||
);
|
||||
} else {
|
||||
const stops = getPaletteStops(options.palette, isDarkMode);
|
||||
ramp = positions.map((t) => sampleStops(stops, t));
|
||||
}
|
||||
|
||||
const positionOf = (count: number | null): number | null => {
|
||||
if (count === null || !Number.isFinite(count)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeCount({ count, domain, scale: options.scale });
|
||||
};
|
||||
|
||||
return {
|
||||
positionOf,
|
||||
colorFor: (count): string | null => {
|
||||
const t = positionOf(count);
|
||||
if (t === null) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.min(Math.floor(t * steps), steps - 1);
|
||||
return ramp[index];
|
||||
},
|
||||
ramp,
|
||||
domain,
|
||||
};
|
||||
}
|
||||
489
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
489
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/geometry.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
|
||||
|
||||
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
|
||||
const FALLBACK_LOG_RATIO = 2;
|
||||
|
||||
const EMPTY_Y_AXIS: HeatmapYAxis = {
|
||||
rows: [],
|
||||
edges: [],
|
||||
splits: [],
|
||||
overflowSplit: null,
|
||||
toBucketValue: (axisValue: number): number => axisValue,
|
||||
min: 0,
|
||||
max: 1,
|
||||
};
|
||||
|
||||
/** Ascending, finite, de-duplicated boundaries. */
|
||||
function normalizeBounds(bounds: number[]): number[] {
|
||||
const sorted = bounds
|
||||
.filter((bound) => Number.isFinite(bound))
|
||||
.sort((a, b) => a - b);
|
||||
return sorted.filter(
|
||||
(bound, index) => index === 0 || bound !== sorted[index - 1],
|
||||
);
|
||||
}
|
||||
|
||||
/** True when a plain log axis can place every boundary. */
|
||||
export function canUseLogAxis(bounds: number[]): boolean {
|
||||
return bounds.length > 0 && bounds.every((bound) => bound > 0);
|
||||
}
|
||||
|
||||
interface AxisTransform {
|
||||
toAxisValue: (value: number) => number;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
}
|
||||
|
||||
const LINEAR_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => value,
|
||||
toBucketValue: (axisValue) => axisValue,
|
||||
};
|
||||
|
||||
const LOG_TRANSFORM: AxisTransform = {
|
||||
toAxisValue: (value) => Math.log10(value),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
|
||||
* bucket layout already declares it, so it never needs to be configured.
|
||||
*/
|
||||
function resolveLinearThreshold(bounds: number[]): number {
|
||||
let threshold = Number.POSITIVE_INFINITY;
|
||||
for (const bound of bounds) {
|
||||
const magnitude = Math.abs(bound);
|
||||
if (magnitude > 0 && magnitude < threshold) {
|
||||
threshold = magnitude;
|
||||
}
|
||||
}
|
||||
return Number.isFinite(threshold) ? threshold : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
|
||||
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
|
||||
* deltas, balances — which a plain log cannot place at all, and which a linear axis
|
||||
* squeezes into sub-pixel rows exactly where the interesting data sits.
|
||||
*
|
||||
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
|
||||
* so it lands on a row edge, and row edges are already discrete.
|
||||
*/
|
||||
function createSymlogTransform(threshold: number): AxisTransform {
|
||||
return {
|
||||
toAxisValue: (value) =>
|
||||
Math.abs(value) <= threshold
|
||||
? value / threshold
|
||||
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
|
||||
toBucketValue: (axisValue) =>
|
||||
Math.abs(axisValue) <= 1
|
||||
? axisValue * threshold
|
||||
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Symmetric log about the threshold the bucket layout implies. All-zero
|
||||
* boundaries have no magnitude to scale against and stay linear. */
|
||||
function resolveSymlogTransform(bounds: number[]): AxisTransform {
|
||||
if (!bounds.some((bound) => bound !== 0)) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
return createSymlogTransform(resolveLinearThreshold(bounds));
|
||||
}
|
||||
|
||||
/** One typical bucket, in axis space — the mean ratio between adjacent positive
|
||||
* boundaries, which on a geometric layout is exactly one bucket. */
|
||||
function resolveLogGap(positive: number[]): number {
|
||||
const axisFirst = Math.log10(positive[0]);
|
||||
const axisLast = Math.log10(positive[positive.length - 1]);
|
||||
const gap =
|
||||
positive.length > 1
|
||||
? (axisLast - axisFirst) / (positive.length - 1)
|
||||
: Math.log10(FALLBACK_LOG_RATIO);
|
||||
return gap > 0 ? gap : Math.log10(FALLBACK_LOG_RATIO);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain log10, with the boundaries a logarithm has no answer for — zero and
|
||||
* below — pinned one bucket beneath the smallest positive one. They keep their
|
||||
* own rows, ticks and labels; only their height is synthetic, and it is the
|
||||
* height of a bucket rather than the decade a symmetric log would spend on them.
|
||||
*
|
||||
* Several of them share that one edge, which squashes them together: a layout
|
||||
* that straddles zero wants `Symlog`. This is the scale for the one non-positive
|
||||
* boundary an explicit-bounds histogram routinely carries — its zero bucket.
|
||||
*/
|
||||
function createFloorLogTransform(positive: number[]): AxisTransform {
|
||||
const floor = Math.log10(positive[0]) - resolveLogGap(positive);
|
||||
return {
|
||||
toAxisValue: (value) => (value > 0 ? Math.log10(value) : floor),
|
||||
toBucketValue: (axisValue) => 10 ** axisValue,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAxisTransform(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): AxisTransform {
|
||||
if (scale === HeatmapAxisScale.Linear) {
|
||||
return LINEAR_TRANSFORM;
|
||||
}
|
||||
if (scale === HeatmapAxisScale.Symlog) {
|
||||
return resolveSymlogTransform(bounds);
|
||||
}
|
||||
if (canUseLogAxis(bounds)) {
|
||||
return LOG_TRANSFORM;
|
||||
}
|
||||
// Only a negative boundary straddles zero; the lone zero an explicit-bounds
|
||||
// histogram carries does not.
|
||||
if (scale === HeatmapAxisScale.Auto && bounds.some((bound) => bound < 0)) {
|
||||
return resolveSymlogTransform(bounds);
|
||||
}
|
||||
const positive = bounds.filter((bound) => bound > 0);
|
||||
return positive.length > 0
|
||||
? createFloorLogTransform(positive)
|
||||
: LINEAR_TRANSFORM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-ended rows still need a height, so each gets the grid's typical bucket
|
||||
* width — the mean gap in axis space, which on a geometric layout is exactly one
|
||||
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
|
||||
*/
|
||||
function resolveOuterEdges(
|
||||
bounds: number[],
|
||||
transform: AxisTransform,
|
||||
isLinear: boolean,
|
||||
): { lower: number; upper: number } {
|
||||
const first = bounds[0];
|
||||
const last = bounds[bounds.length - 1];
|
||||
|
||||
if (isLinear) {
|
||||
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
|
||||
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
|
||||
// Never extend below zero unless the boundaries already do.
|
||||
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
|
||||
return { lower, upper: last + safeGap };
|
||||
}
|
||||
|
||||
const axisFirst = transform.toAxisValue(first);
|
||||
const axisLast = transform.toAxisValue(last);
|
||||
const fallback = Math.log10(FALLBACK_LOG_RATIO);
|
||||
const gap =
|
||||
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
|
||||
const safeGap = gap > 0 ? gap : fallback;
|
||||
|
||||
return {
|
||||
lower: transform.toBucketValue(axisFirst - safeGap),
|
||||
upper: transform.toBucketValue(axisLast + safeGap),
|
||||
};
|
||||
}
|
||||
|
||||
/** N boundaries produce N+1 rows: an underflow row below the first, and the
|
||||
* `+Inf` overflow row above the last. */
|
||||
export function resolveHeatmapYAxis(
|
||||
bounds: number[],
|
||||
scale: HeatmapAxisScale,
|
||||
): HeatmapYAxis {
|
||||
const normalized = normalizeBounds(bounds);
|
||||
if (normalized.length === 0) {
|
||||
return EMPTY_Y_AXIS;
|
||||
}
|
||||
|
||||
const transform = resolveAxisTransform(normalized, scale);
|
||||
const isLinear = transform === LINEAR_TRANSFORM;
|
||||
const { toAxisValue, toBucketValue } = transform;
|
||||
|
||||
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
|
||||
const last = normalized[normalized.length - 1];
|
||||
|
||||
const rows: HeatmapRow[] = [
|
||||
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
|
||||
];
|
||||
for (let index = 1; index < normalized.length; index += 1) {
|
||||
rows.push({
|
||||
lower: normalized[index - 1],
|
||||
upper: normalized[index],
|
||||
isUnderflow: false,
|
||||
isOverflow: false,
|
||||
});
|
||||
}
|
||||
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
|
||||
|
||||
const edges = [
|
||||
toAxisValue(lower),
|
||||
...normalized.map(toAxisValue),
|
||||
toAxisValue(upper),
|
||||
];
|
||||
|
||||
return {
|
||||
rows,
|
||||
edges,
|
||||
splits: normalized.map(toAxisValue),
|
||||
overflowSplit: toAxisValue(upper),
|
||||
toBucketValue,
|
||||
min: edges[0],
|
||||
max: edges[edges.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
|
||||
export function resolveRowIndex(
|
||||
edges: number[],
|
||||
axisValue: number,
|
||||
): number | null {
|
||||
if (edges.length < 2) {
|
||||
return null;
|
||||
}
|
||||
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = edges.length - 2;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (axisValue < edges[mid]) {
|
||||
high = mid - 1;
|
||||
} else if (axisValue >= edges[mid + 1]) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
return mid;
|
||||
}
|
||||
}
|
||||
// Exactly on the top edge.
|
||||
return edges.length - 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
|
||||
* snaps to the closest boundary and would report the next column as soon as the
|
||||
* cursor passed a cell's midpoint.
|
||||
*/
|
||||
export function resolveColumnIndex(
|
||||
timestamps: ArrayLike<number>,
|
||||
xValue: number,
|
||||
step: number,
|
||||
): number | null {
|
||||
if (timestamps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let low = 0;
|
||||
let high = timestamps.length - 1;
|
||||
let candidate = -1;
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (timestamps[mid] <= xValue) {
|
||||
candidate = mid;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate < 0) {
|
||||
return null;
|
||||
}
|
||||
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
|
||||
return xValue < timestamps[candidate] + width ? candidate : null;
|
||||
}
|
||||
|
||||
/** The open-ended rows are labelled by their one real boundary; the synthetic
|
||||
* edge is a drawing device, not a value. */
|
||||
export function formatRowLabel(
|
||||
row: HeatmapRow,
|
||||
formatValue: (value: number) => string,
|
||||
): string {
|
||||
if (row.isOverflow) {
|
||||
return `> ${formatValue(row.lower)}`;
|
||||
}
|
||||
if (row.isUnderflow) {
|
||||
return `≤ ${formatValue(row.upper)}`;
|
||||
}
|
||||
return `${formatValue(row.lower)} – ${formatValue(row.upper)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
|
||||
* index, since linear rows are not the same height, and walks down from the top
|
||||
* so the `∞` edge survives whatever else is dropped.
|
||||
*/
|
||||
export function decimateAxisSplits({
|
||||
splits,
|
||||
min,
|
||||
max,
|
||||
plotHeight,
|
||||
minGapPx,
|
||||
}: {
|
||||
/** Candidates in axis space, ascending. */
|
||||
splits: number[];
|
||||
min: number;
|
||||
max: number;
|
||||
/** Plotting area height, in CSS pixels. */
|
||||
plotHeight: number;
|
||||
minGapPx: number;
|
||||
}): number[] {
|
||||
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
|
||||
return splits;
|
||||
}
|
||||
|
||||
const pixelsPerUnit = plotHeight / (max - min);
|
||||
const kept: number[] = [];
|
||||
let lastPosition = 0;
|
||||
|
||||
for (let index = splits.length - 1; index >= 0; index -= 1) {
|
||||
// Axis values grow upward, pixel offsets downward.
|
||||
const position = (max - splits[index]) * pixelsPerUnit;
|
||||
if (kept.length === 0 || position - lastPosition >= minGapPx) {
|
||||
kept.push(splits[index]);
|
||||
lastPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
return kept.reverse();
|
||||
}
|
||||
|
||||
/** Where uPlot switches from a fixed increment to a calendar walk. */
|
||||
const MONTH_INCR_SECONDS = 3600 * 24 * 28;
|
||||
const YEAR_INCR_SECONDS = 3600 * 24 * 365;
|
||||
|
||||
/** Shifts a timestamp into the axis timezone, as uPlot's `tzDate` does: the
|
||||
* returned date's *local* fields read as that timezone's wall clock. */
|
||||
type ToAxisDate = (timestamp: number) => Date;
|
||||
|
||||
const BROWSER_DATE: ToAxisDate = (timestamp) => new Date(timestamp * 1e3);
|
||||
|
||||
/**
|
||||
* Real epoch seconds of the midnight at or before `timestamp`, in the axis
|
||||
* timezone. The browser's own offset cancels: it is inside the shifted date's
|
||||
* fields and inside the correction.
|
||||
*/
|
||||
function resolveDayOrigin(timestamp: number, toDate: ToAxisDate): number {
|
||||
const shifted = toDate(timestamp);
|
||||
const midnight = new Date(
|
||||
shifted.getFullYear(),
|
||||
shifted.getMonth(),
|
||||
shifted.getDate(),
|
||||
);
|
||||
const correction = Math.floor(timestamp) - Math.floor(shifted.getTime() / 1e3);
|
||||
return Math.floor(midnight.getTime() / 1e3) + correction;
|
||||
}
|
||||
|
||||
function fromAxisDate(wall: Date, toDate: ToAxisDate): number {
|
||||
const wallTs = Math.floor(wall.getTime() / 1e3);
|
||||
return wallTs + (wallTs - Math.floor(toDate(wallTs).getTime() / 1e3));
|
||||
}
|
||||
|
||||
function snapToColumnEdge(value: number, phase: number, width: number): number {
|
||||
return phase + Math.round((value - phase) / width) * width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Month and year ticks, walked as calendar dates the way uPlot walks them — no
|
||||
* fixed increment expresses a month. Their spacing is uneven to begin with, so
|
||||
* each tick is snapped to its own nearest column edge.
|
||||
*/
|
||||
function resolveCalendarSplits({
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate,
|
||||
phase,
|
||||
columnWidth,
|
||||
}: {
|
||||
incr: number;
|
||||
min: number;
|
||||
max: number;
|
||||
toDate: ToAxisDate;
|
||||
phase: number;
|
||||
columnWidth: number;
|
||||
}): number[] {
|
||||
const isYear = incr >= YEAR_INCR_SECONDS;
|
||||
const monthsPerTick = Math.max(
|
||||
1,
|
||||
isYear
|
||||
? Math.round(incr / YEAR_INCR_SECONDS) * 12
|
||||
: Math.round(incr / MONTH_INCR_SECONDS),
|
||||
);
|
||||
|
||||
const start = toDate(min);
|
||||
const baseYear = start.getFullYear();
|
||||
const baseMonth = isYear ? 0 : start.getMonth();
|
||||
|
||||
const splits: number[] = [];
|
||||
for (let index = 0; ; index += 1) {
|
||||
const wall = new Date(baseYear, baseMonth + monthsPerTick * index, 1);
|
||||
const value = snapToColumnEdge(
|
||||
fromAxisDate(wall, toDate),
|
||||
phase,
|
||||
columnWidth,
|
||||
);
|
||||
if (value > max) {
|
||||
break;
|
||||
}
|
||||
if (value >= min && value !== splits[splits.length - 1]) {
|
||||
splits.push(value);
|
||||
}
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time ticks placed on column edges, so a vertical grid line falls in the gap
|
||||
* between two cells instead of through one. uPlot's increment is rounded up to a
|
||||
* whole number of columns, and the sequence starts at the column edge nearest
|
||||
* the timezone's midnight — the closest the grid can get to the ticks uPlot
|
||||
* would have drawn. Where midnight is itself an edge, they are those ticks.
|
||||
*/
|
||||
export function resolveColumnAlignedSplits({
|
||||
anchor,
|
||||
step,
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate = BROWSER_DATE,
|
||||
}: {
|
||||
/** Any column start: every edge sits at `anchor + n * step`. */
|
||||
anchor: number;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
/** Increment uPlot picked for the axis, in seconds. */
|
||||
incr: number;
|
||||
min: number;
|
||||
max: number;
|
||||
toDate?: ToAxisDate;
|
||||
}): number[] {
|
||||
if (!(incr > 0) || !(max > min)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const columnWidth = step > 0 ? step : incr;
|
||||
const phase = step > 0 ? ((anchor % step) + step) % step : 0;
|
||||
|
||||
if (incr >= MONTH_INCR_SECONDS) {
|
||||
return resolveCalendarSplits({
|
||||
incr,
|
||||
min,
|
||||
max,
|
||||
toDate,
|
||||
phase,
|
||||
columnWidth,
|
||||
});
|
||||
}
|
||||
|
||||
const tickIncr = Math.ceil(incr / columnWidth) * columnWidth;
|
||||
const origin = snapToColumnEdge(
|
||||
resolveDayOrigin(min, toDate),
|
||||
phase,
|
||||
columnWidth,
|
||||
);
|
||||
|
||||
const splits: number[] = [];
|
||||
for (let index = Math.ceil((min - origin) / tickIncr); ; index += 1) {
|
||||
const value = origin + index * tickIncr;
|
||||
if (value > max) {
|
||||
break;
|
||||
}
|
||||
splits.push(value);
|
||||
}
|
||||
return splits;
|
||||
}
|
||||
77
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
77
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/grid.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { HeatmapGrid, HeatmapSeries } from './types';
|
||||
|
||||
const EMPTY_GRID: HeatmapGrid = {
|
||||
bounds: [],
|
||||
timestamps: [],
|
||||
step: 0,
|
||||
counts: [],
|
||||
};
|
||||
|
||||
/** Groups the legend currently has enabled. `undefined` means all of them. */
|
||||
function resolveVisible(
|
||||
series: HeatmapSeries[],
|
||||
visibleGroups: string[] | undefined,
|
||||
): HeatmapSeries[] {
|
||||
if (visibleGroups === undefined) {
|
||||
return series;
|
||||
}
|
||||
const allowed = new Set(visibleGroups);
|
||||
return series.filter((entry) => allowed.has(entry.label));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pivots the response's column-major counts into the row-major grid the renderer
|
||||
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
|
||||
* needs no extra request. A cell is `null` only when no group contributed to it.
|
||||
*/
|
||||
export function resolveHeatmapGrid({
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
visibleGroups,
|
||||
}: {
|
||||
buckets: number[];
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
series: HeatmapSeries[];
|
||||
/** Labels the legend has enabled. `undefined` sums every group. */
|
||||
visibleGroups?: string[];
|
||||
}): HeatmapGrid {
|
||||
if (buckets.length === 0 || series.length === 0) {
|
||||
return EMPTY_GRID;
|
||||
}
|
||||
|
||||
const selected = resolveVisible(series, visibleGroups);
|
||||
|
||||
// Groups are not guaranteed to share timestamps, so the columns are their union.
|
||||
const timestampSet = new Set<number>();
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => timestampSet.add(point.timestamp));
|
||||
});
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
|
||||
|
||||
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
|
||||
const rowCount = buckets.length + 1;
|
||||
const counts: Array<Array<number | null>> = Array.from(
|
||||
{ length: rowCount },
|
||||
() => new Array<number | null>(timestamps.length).fill(null),
|
||||
);
|
||||
|
||||
selected.forEach((entry) => {
|
||||
entry.points.forEach((point) => {
|
||||
const column = columnOf.get(point.timestamp);
|
||||
if (column === undefined) {
|
||||
return;
|
||||
}
|
||||
point.counts.forEach((count, row) => {
|
||||
if (row >= rowCount || count === null || count === undefined) {
|
||||
return;
|
||||
}
|
||||
counts[row][column] = (counts[row][column] ?? 0) + count;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { bounds: buckets, timestamps, step, counts };
|
||||
}
|
||||
178
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
178
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
HeatmapColorResolver,
|
||||
resolveCountDomain,
|
||||
} from './colorScale';
|
||||
import { resolveColumnIndex, resolveRowIndex } from './geometry';
|
||||
import {
|
||||
createHoverOverlay,
|
||||
HeatmapHoverOverlay,
|
||||
showHoverOverlay,
|
||||
} from './hoverOverlay';
|
||||
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
|
||||
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
|
||||
|
||||
export interface HeatmapRenderOptions {
|
||||
yAxis: HeatmapYAxis;
|
||||
/** Column width in seconds. */
|
||||
step: number;
|
||||
colors: HeatmapColorOptions;
|
||||
isDarkMode: boolean;
|
||||
/** Opacity-mode fill when `colors.fill` is empty. */
|
||||
seriesColor: string;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
/** `null` when the cursor leaves. */
|
||||
onHoverChange?: (cell: HeatmapCell | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
|
||||
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
|
||||
* TooltipPlugin's so the focused row is resolved when the tooltip positions
|
||||
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
|
||||
*/
|
||||
export interface HeatmapHooks {
|
||||
init: (u: uPlot) => void;
|
||||
draw: (u: uPlot) => void;
|
||||
setCursor: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
}
|
||||
|
||||
export function createHeatmapHooks({
|
||||
yAxis,
|
||||
step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover = true,
|
||||
onHoverChange,
|
||||
}: HeatmapRenderOptions): HeatmapHooks {
|
||||
let overlay: HeatmapHoverOverlay | null = null;
|
||||
let hovered: HeatmapCell | null = null;
|
||||
let hatchPattern: CanvasPattern | null = null;
|
||||
|
||||
// On auto, the domain comes from the data, but these hooks are captured once at
|
||||
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
|
||||
// rather than forcing a rebuild.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedResolver: HeatmapColorResolver | null = null;
|
||||
|
||||
function getResolver(u: uPlot): HeatmapColorResolver {
|
||||
if (cachedResolver && cachedData === u.data) {
|
||||
return cachedResolver;
|
||||
}
|
||||
cachedResolver = createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(
|
||||
colors,
|
||||
u.data.slice(1) as Array<Array<number | null>>,
|
||||
),
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
});
|
||||
cachedData = u.data;
|
||||
return cachedResolver;
|
||||
}
|
||||
|
||||
function clearHover(u: uPlot): void {
|
||||
if (overlay) {
|
||||
overlay.container.style.display = 'none';
|
||||
}
|
||||
if (hovered === null) {
|
||||
return;
|
||||
}
|
||||
hovered = null;
|
||||
u.setSeries(null, { focus: true });
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
|
||||
return {
|
||||
init: (u: uPlot): void => {
|
||||
overlay = createHoverOverlay(isDarkMode);
|
||||
u.over.appendChild(overlay.container);
|
||||
},
|
||||
|
||||
draw: (u: uPlot): void => {
|
||||
const timestamps = u.data[0] as ArrayLike<number> | undefined;
|
||||
if (!timestamps?.length || yAxis.rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const { ctx } = u;
|
||||
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
|
||||
ctx.restore();
|
||||
|
||||
drawOverflowBoundary({ u, yAxis, isDarkMode });
|
||||
},
|
||||
|
||||
setCursor: (u: uPlot): void => {
|
||||
const { left = -10, top = -10 } = u.cursor;
|
||||
if (left < 0 || top < 0) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
|
||||
const column = resolveColumnIndex(
|
||||
u.data[0] as ArrayLike<number>,
|
||||
u.posToVal(left, 'x'),
|
||||
step,
|
||||
);
|
||||
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
|
||||
if (column === null || row === null) {
|
||||
clearHover(u);
|
||||
return;
|
||||
}
|
||||
const count =
|
||||
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null;
|
||||
// The count is part of the identity: a refetch swaps the data under a
|
||||
// stationary cursor, and the cell it points at then means something else.
|
||||
if (
|
||||
hovered?.row === row &&
|
||||
hovered?.column === column &&
|
||||
hovered?.count === count
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
hovered = { row, column, count };
|
||||
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
|
||||
// uPlot's own focus is disabled here: it picks the series nearest in value
|
||||
// space, and a heatmap's value is a colour, not a y coordinate.
|
||||
u.setSeries(row + 1, { focus: true });
|
||||
if (overlay) {
|
||||
showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim: dimOnHover,
|
||||
});
|
||||
}
|
||||
onHoverChange?.(hovered);
|
||||
},
|
||||
|
||||
destroy: (): void => {
|
||||
overlay?.container.remove();
|
||||
overlay = null;
|
||||
hatchPattern = null;
|
||||
cachedData = null;
|
||||
cachedResolver = null;
|
||||
// A rebuilt plot starts with no cursor, so a listener still holding this
|
||||
// cell would keep drawing a hover that no longer exists.
|
||||
if (hovered !== null) {
|
||||
hovered = null;
|
||||
onHoverChange?.(null);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
122
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
122
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/hoverOverlay.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
const HIGHLIGHT_BORDER_WIDTH = 1;
|
||||
/** ~55% alpha. */
|
||||
const DIM_ALPHA = '8C';
|
||||
|
||||
export interface HeatmapHoverOverlay {
|
||||
container: HTMLDivElement;
|
||||
highlight: HTMLDivElement;
|
||||
/** Four corner rects whose complement is the hovered row/column cross. */
|
||||
dims: HTMLDivElement[];
|
||||
}
|
||||
|
||||
function createOverlayElement(): HTMLDivElement {
|
||||
const element = document.createElement('div');
|
||||
element.style.position = 'absolute';
|
||||
element.style.pointerEvents = 'none';
|
||||
return element;
|
||||
}
|
||||
|
||||
function setRect(
|
||||
element: HTMLDivElement,
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): void {
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
element.style.width = `${Math.max(0, width)}px`;
|
||||
element.style.height = `${Math.max(0, height)}px`;
|
||||
}
|
||||
|
||||
/** Kept out of the canvas so moving between cells repositions a few nodes
|
||||
* instead of repainting the grid. */
|
||||
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
|
||||
const container = createOverlayElement();
|
||||
container.style.inset = '0';
|
||||
container.style.display = 'none';
|
||||
// The plot area's edge, as the canvas clip is to the cells: a cell at either end
|
||||
// runs past the axis when its bucket or its time slice is only partly in view,
|
||||
// and the highlight would otherwise be drawn over the axis and the panel.
|
||||
container.style.overflow = 'hidden';
|
||||
container.setAttribute('data-testid', 'heatmap-hover-overlay');
|
||||
|
||||
const dimColor = `${
|
||||
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
|
||||
}${DIM_ALPHA}`;
|
||||
const dims = Array.from({ length: 4 }, () => {
|
||||
const dim = createOverlayElement();
|
||||
dim.style.background = dimColor;
|
||||
container.appendChild(dim);
|
||||
return dim;
|
||||
});
|
||||
|
||||
const highlight = createOverlayElement();
|
||||
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
|
||||
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
|
||||
}`;
|
||||
highlight.style.boxSizing = 'border-box';
|
||||
container.appendChild(highlight);
|
||||
|
||||
return { container, highlight, dims };
|
||||
}
|
||||
|
||||
/** Positions the highlight, and the four corner rects so only the hovered row
|
||||
* and column stay at full contrast. */
|
||||
export function showHoverOverlay({
|
||||
overlay,
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
row,
|
||||
column,
|
||||
dim,
|
||||
}: {
|
||||
overlay: HeatmapHoverOverlay;
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
row: number;
|
||||
column: number;
|
||||
dim: boolean;
|
||||
}): void {
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const width = u.over.clientWidth;
|
||||
const height = u.over.clientHeight;
|
||||
|
||||
const cellLeft = u.valToPos(timestamps[column], 'x');
|
||||
const cellRight = u.valToPos(timestamps[column] + step, 'x');
|
||||
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
|
||||
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
|
||||
|
||||
setRect(
|
||||
overlay.highlight,
|
||||
cellLeft,
|
||||
cellTop,
|
||||
cellRight - cellLeft,
|
||||
cellBottom - cellTop,
|
||||
);
|
||||
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
|
||||
if (dim) {
|
||||
setRect(topLeft, 0, 0, cellLeft, cellTop);
|
||||
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
|
||||
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
|
||||
setRect(
|
||||
bottomRight,
|
||||
cellRight,
|
||||
cellBottom,
|
||||
width - cellRight,
|
||||
height - cellBottom,
|
||||
);
|
||||
} else {
|
||||
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
|
||||
}
|
||||
|
||||
overlay.container.style.display = 'block';
|
||||
}
|
||||
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
128
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/paint.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { HeatmapColorResolver } from './colorScale';
|
||||
import { HeatmapYAxis } from './types';
|
||||
|
||||
/** Cells at least this wide/tall keep a hairline separator. */
|
||||
const MIN_CELL_SIZE_FOR_GAP = 4;
|
||||
const HATCH_TILE_SIZE = 6;
|
||||
const OVERFLOW_DASH: [number, number] = [4, 3];
|
||||
|
||||
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
|
||||
* scrape outage reads as a quiet period. */
|
||||
export function createHatchPattern(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
isDarkMode: boolean,
|
||||
): CanvasPattern | null {
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
|
||||
const tile = document.createElement('canvas');
|
||||
tile.width = size;
|
||||
tile.height = size;
|
||||
|
||||
const tileCtx = tile.getContext('2d');
|
||||
if (!tileCtx) {
|
||||
return null;
|
||||
}
|
||||
tileCtx.strokeStyle = isDarkMode
|
||||
? `${Color.BG_VANILLA_400}59`
|
||||
: `${Color.BG_INK_300}40`;
|
||||
tileCtx.lineWidth = Math.max(1, pxRatio);
|
||||
tileCtx.beginPath();
|
||||
// Three strokes keep the pattern continuous across tile seams.
|
||||
tileCtx.moveTo(0, size);
|
||||
tileCtx.lineTo(size, 0);
|
||||
tileCtx.moveTo(-size / 2, size / 2);
|
||||
tileCtx.lineTo(size / 2, -size / 2);
|
||||
tileCtx.moveTo(size / 2, size * 1.5);
|
||||
tileCtx.lineTo(size * 1.5, size / 2);
|
||||
tileCtx.stroke();
|
||||
|
||||
return ctx.createPattern(tile, 'repeat');
|
||||
}
|
||||
|
||||
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function drawCells({
|
||||
u,
|
||||
yAxis,
|
||||
step,
|
||||
resolver,
|
||||
hatchPattern,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
step: number;
|
||||
resolver: HeatmapColorResolver;
|
||||
hatchPattern: CanvasPattern | null;
|
||||
}): void {
|
||||
const { ctx } = u;
|
||||
const timestamps = u.data[0] as ArrayLike<number>;
|
||||
const { rows, edges } = yAxis;
|
||||
const pxRatio = uPlot.pxRatio;
|
||||
|
||||
const xMin = u.scales.x.min ?? timestamps[0];
|
||||
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
|
||||
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
|
||||
|
||||
for (let column = 0; column < timestamps.length; column += 1) {
|
||||
const columnStart = timestamps[column];
|
||||
const columnEnd = columnStart + step;
|
||||
if (columnEnd < xMin || columnStart > xMax) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const left = u.valToPos(columnStart, 'x', true);
|
||||
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
|
||||
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
const width = Math.max(1, rawWidth - gapX);
|
||||
|
||||
for (let row = 0; row < rows.length; row += 1) {
|
||||
const top = rowEdgePositions[row + 1];
|
||||
const rawHeight = rowEdgePositions[row] - top;
|
||||
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
|
||||
|
||||
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
|
||||
column
|
||||
];
|
||||
const fill = resolver.colorFor(count ?? null);
|
||||
|
||||
if (fill === null && hatchPattern === null) {
|
||||
continue;
|
||||
}
|
||||
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
|
||||
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
|
||||
* should not be compared with the real buckets. */
|
||||
export function drawOverflowBoundary({
|
||||
u,
|
||||
yAxis,
|
||||
isDarkMode,
|
||||
}: {
|
||||
u: uPlot;
|
||||
yAxis: HeatmapYAxis;
|
||||
isDarkMode: boolean;
|
||||
}): void {
|
||||
const overflowIndex = yAxis.rows.length - 1;
|
||||
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ctx } = u;
|
||||
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));
|
||||
|
||||
ctx.save();
|
||||
ctx.setLineDash(OVERFLOW_DASH);
|
||||
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
|
||||
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(u.bbox.left, y);
|
||||
ctx.lineTo(u.bbox.left + u.bbox.width, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
167
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/palettes.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { HeatmapColorPalette } from './types';
|
||||
|
||||
interface PaletteDefinition {
|
||||
/** Evenly spaced, one end of the ramp to the other. */
|
||||
stops: string[];
|
||||
/** `true` when `stops[0]` is the dark end. */
|
||||
darkFirst: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop values come from the long-established public palette families —
|
||||
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
|
||||
*/
|
||||
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
|
||||
[HeatmapColorPalette.Ice]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fbff',
|
||||
'#deebf7',
|
||||
'#c3dbee',
|
||||
'#9cc8e2',
|
||||
'#6daed5',
|
||||
'#4391c6',
|
||||
'#2271b4',
|
||||
'#0c5198',
|
||||
'#08306b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Moss]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#f7fcf5',
|
||||
'#e3f4de',
|
||||
'#c6e8bf',
|
||||
'#a0d89b',
|
||||
'#73c378',
|
||||
'#45aa5d',
|
||||
'#228b45',
|
||||
'#066b2d',
|
||||
'#00441b',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Rust]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff5f0',
|
||||
'#feddcf',
|
||||
'#fcbaa1',
|
||||
'#fc9273',
|
||||
'#f9694c',
|
||||
'#eb3d2f',
|
||||
'#cb1c1e',
|
||||
'#a10e15',
|
||||
'#67000d',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Graphite]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffff',
|
||||
'#efefef',
|
||||
'#d8d8d8',
|
||||
'#bbbbbb',
|
||||
'#979797',
|
||||
'#737373',
|
||||
'#505050',
|
||||
'#262626',
|
||||
'#000000',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Ember]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffcc',
|
||||
'#ffeda0',
|
||||
'#fed676',
|
||||
'#feb250',
|
||||
'#fd893c',
|
||||
'#f8502b',
|
||||
'#e11e20',
|
||||
'#b90424',
|
||||
'#800026',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lagoon]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#ffffd9',
|
||||
'#eaf7b8',
|
||||
'#c1e7b5',
|
||||
'#81cebb',
|
||||
'#45b4c2',
|
||||
'#248fbd',
|
||||
'#2260a9',
|
||||
'#20378d',
|
||||
'#081d58',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Orchid]: {
|
||||
darkFirst: false,
|
||||
stops: [
|
||||
'#fff7f3',
|
||||
'#fddfdc',
|
||||
'#fcc3c3',
|
||||
'#fa9cb4',
|
||||
'#f369a3',
|
||||
'#da3495',
|
||||
'#ad0a81',
|
||||
'#7b0176',
|
||||
'#49006a',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Verdant]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#440154',
|
||||
'#472d7b',
|
||||
'#3b528b',
|
||||
'#2c728e',
|
||||
'#21918c',
|
||||
'#28ae80',
|
||||
'#5ec962',
|
||||
'#addc30',
|
||||
'#fde725',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Lava]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#000004',
|
||||
'#1d1147',
|
||||
'#51127c',
|
||||
'#832681',
|
||||
'#b73779',
|
||||
'#e75263',
|
||||
'#fc8961',
|
||||
'#fec488',
|
||||
'#fcfdbf',
|
||||
],
|
||||
},
|
||||
[HeatmapColorPalette.Beacon]: {
|
||||
darkFirst: true,
|
||||
stops: [
|
||||
'#002051',
|
||||
'#11366c',
|
||||
'#3c4d6e',
|
||||
'#62646f',
|
||||
'#7f7c75',
|
||||
'#9a9478',
|
||||
'#bbaf71',
|
||||
'#e2cb5c',
|
||||
'#fdea45',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/** Stops oriented low-count first for the active theme. At the wrong polarity,
|
||||
* empty cells become the loudest thing on screen. */
|
||||
export function getPaletteStops(
|
||||
palette: HeatmapColorPalette,
|
||||
isDarkMode: boolean,
|
||||
): string[] {
|
||||
const definition = PALETTES[palette] ?? PALETTES[HeatmapColorPalette.Ice];
|
||||
return definition.darkFirst === isDarkMode
|
||||
? definition.stops
|
||||
: [...definition.stops].reverse();
|
||||
}
|
||||
122
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
122
frontend/src/lib/uPlotV2/plugins/HeatmapPlugin/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export enum HeatmapColorScale {
|
||||
Log = 'log',
|
||||
Sqrt = 'sqrt',
|
||||
Linear = 'linear',
|
||||
}
|
||||
|
||||
export enum HeatmapColorMode {
|
||||
Palette = 'palette',
|
||||
Opacity = 'opacity',
|
||||
}
|
||||
|
||||
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
|
||||
* read as a threshold that does not exist. */
|
||||
export enum HeatmapColorPalette {
|
||||
Ice = 'ice',
|
||||
Moss = 'moss',
|
||||
Rust = 'rust',
|
||||
Graphite = 'graphite',
|
||||
Ember = 'ember',
|
||||
Lagoon = 'lagoon',
|
||||
Orchid = 'orchid',
|
||||
Verdant = 'verdant',
|
||||
Lava = 'lava',
|
||||
Beacon = 'beacon',
|
||||
}
|
||||
|
||||
export interface HeatmapColorOptions {
|
||||
mode: HeatmapColorMode;
|
||||
scale: HeatmapColorScale;
|
||||
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
|
||||
minCount: number | null;
|
||||
/** `null` derives it from the grid's highest count. */
|
||||
maxCount: number | null;
|
||||
palette: HeatmapColorPalette;
|
||||
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
|
||||
* column width in seconds. */
|
||||
steps: number;
|
||||
/** Opacity mode. Empty falls back to the caller's series colour. */
|
||||
fill: string;
|
||||
}
|
||||
|
||||
/** Row-height distribution of the bucket axis. */
|
||||
export enum HeatmapAxisScale {
|
||||
/** Whichever of the three below the boundaries admit: log when they are all
|
||||
* positive or sit above a zero, symmetric log when they reach below it,
|
||||
* linear when they are all zero. The choice is a property of the data, so
|
||||
* this is the default. */
|
||||
Auto = 'auto',
|
||||
Linear = 'linear',
|
||||
/** Plain log10. A boundary at or below zero has no logarithm, so it is pinned
|
||||
* one bucket below the smallest positive one — see `resolveHeatmapYAxis`. */
|
||||
Log = 'log',
|
||||
/** Linear within ±the smallest non-zero boundary, logarithmic beyond,
|
||||
* mirrored across zero. The scale for boundaries that straddle zero. */
|
||||
Symlog = 'symlog',
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesPoint {
|
||||
/** Column start, in seconds. */
|
||||
timestamp: number;
|
||||
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
|
||||
counts: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface HeatmapSeriesLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HeatmapSeries {
|
||||
/** Group label, as the legend and the tooltip name it. Empty when there is no
|
||||
* grouping. */
|
||||
label: string;
|
||||
points: HeatmapSeriesPoint[];
|
||||
}
|
||||
|
||||
/** Counts pivoted into rows and aligned to one column axis. Internal to the
|
||||
* chart, which resolves it from `buckets` and `series`. */
|
||||
export interface HeatmapGrid {
|
||||
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
|
||||
bounds: number[];
|
||||
/** Column starts, in seconds. */
|
||||
timestamps: number[];
|
||||
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
|
||||
* and the last column has no successor to infer it from. */
|
||||
step: number;
|
||||
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
|
||||
* at the bottom of the scale — conflating them hides an outage. */
|
||||
counts: Array<Array<number | null>>;
|
||||
}
|
||||
|
||||
export interface HeatmapRow {
|
||||
/** Synthetic on the underflow row. */
|
||||
lower: number;
|
||||
/** Synthetic on the overflow row. */
|
||||
upper: number;
|
||||
isUnderflow: boolean;
|
||||
isOverflow: boolean;
|
||||
}
|
||||
|
||||
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
|
||||
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
|
||||
* ticks and uPlot's decade-only label filter cannot hide them. */
|
||||
export interface HeatmapYAxis {
|
||||
rows: HeatmapRow[];
|
||||
/** Row edges, ascending. Length is `rows.length + 1`. */
|
||||
edges: number[];
|
||||
/** Real bucket boundaries — one tick each. */
|
||||
splits: number[];
|
||||
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
|
||||
* which would sit half a row from the last boundary and collide with it. */
|
||||
overflowSplit: number | null;
|
||||
toBucketValue: (axisValue: number) => number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface HeatmapCell {
|
||||
row: number;
|
||||
column: number;
|
||||
count: number | null;
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export default function ChartWrapper({
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
customLegend,
|
||||
legendLabels,
|
||||
contentFooter,
|
||||
'data-testid': testId,
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
@@ -61,6 +64,10 @@ export default function ChartWrapper({
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// Charts whose legend does not list uPlot series supply their own.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
@@ -69,7 +76,7 @@ export default function ChartWrapper({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[config, legendConfig.position, showLegend],
|
||||
[config, legendConfig.position, showLegend, customLegend],
|
||||
);
|
||||
|
||||
const renderTooltipCallback = useCallback(
|
||||
@@ -100,6 +107,8 @@ export default function ChartWrapper({
|
||||
containerHeight={containerHeight}
|
||||
legendConfig={legendConfig}
|
||||
legendComponent={legendComponent}
|
||||
seriesLabels={legendLabels}
|
||||
contentFooter={contentFooter}
|
||||
layoutChildren={layoutChildren}
|
||||
>
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
|
||||
324
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
324
frontend/src/lib/visualization/charts/Heatmap/Heatmap.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
|
||||
import Legend from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveCountDomain,
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
prepareHeatmapChartData,
|
||||
resolveBoundaryPrecision,
|
||||
} from './utils';
|
||||
|
||||
/** Vertical space the colour bar takes out of the container. */
|
||||
const COLOR_BAR_HEIGHT = 28;
|
||||
|
||||
/** Row, column and count together: a refetch changes what the cell under a
|
||||
* stationary cursor means, while the row and column stay put. */
|
||||
function isSameCell(a: HeatmapCell | null, b: HeatmapCell | null): boolean {
|
||||
if (a === null || b === null) {
|
||||
return a === b;
|
||||
}
|
||||
return a.row === b.row && a.column === b.column && a.count === b.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Columns are time slices, rows are bucket ranges, cell colour is the observation
|
||||
* count — so a distribution can be watched changing shape instead of collapsing to
|
||||
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
|
||||
* ~9,600 cells, far past what per-cell DOM carries.
|
||||
*/
|
||||
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
const {
|
||||
id,
|
||||
buckets,
|
||||
step,
|
||||
series,
|
||||
width,
|
||||
height,
|
||||
isDarkMode,
|
||||
axisScale = HeatmapAxisScale.Auto,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
showVisualMap = true,
|
||||
showLegend = true,
|
||||
legendPosition = LegendPosition.BOTTOM,
|
||||
dimOnHover = true,
|
||||
showTooltip = true,
|
||||
canPinTooltip = false,
|
||||
pinKey,
|
||||
seriesColor,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
onCellClick,
|
||||
renderTooltipFooter,
|
||||
tooltipPortalRoot,
|
||||
layoutChildren,
|
||||
'data-testid': testId,
|
||||
} = props;
|
||||
|
||||
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
|
||||
const hoveredCellRef = useRef<HeatmapCell | null>(null);
|
||||
const hoverFrameRef = useRef<number | null>(null);
|
||||
const onCellClickRef = useRef(onCellClick);
|
||||
onCellClickRef.current = onCellClick;
|
||||
|
||||
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
|
||||
|
||||
const colors = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
|
||||
[props.colors],
|
||||
);
|
||||
|
||||
// The opacity fill no longer follows a group colour: with several groups enabled
|
||||
// at once there is no single one to follow.
|
||||
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
|
||||
|
||||
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
|
||||
// read against the panel.
|
||||
const extremeColor = resolveExtremeColor({
|
||||
options: colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
});
|
||||
|
||||
const { visibleGroups, focusedSeriesIndex, onLegendAction } =
|
||||
useHeatmapGroupLegend({ groups });
|
||||
|
||||
const grid = useMemo(
|
||||
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
|
||||
[buckets, step, series, visibleGroups],
|
||||
);
|
||||
|
||||
const yAxis = useMemo(
|
||||
() => resolveHeatmapYAxis(grid.bounds, axisScale),
|
||||
[grid.bounds, axisScale],
|
||||
);
|
||||
|
||||
// The axis, the series labels and the tooltip all name rows by their boundaries,
|
||||
// so they share one precision.
|
||||
const boundaryPrecision = useMemo(
|
||||
() => resolveBoundaryPrecision({ yAxis, yAxisUnit, decimalPrecision }),
|
||||
[yAxis, yAxisUnit, decimalPrecision],
|
||||
);
|
||||
|
||||
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
hasGrid
|
||||
? prepareHeatmapChartData(grid, yAxis.rows.length)
|
||||
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
|
||||
[grid, yAxis.rows.length, hasGrid],
|
||||
);
|
||||
|
||||
const colorResolver = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options: colors,
|
||||
domain: resolveCountDomain(colors, grid.counts),
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
}),
|
||||
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
|
||||
);
|
||||
|
||||
// Stable: the renderer captures it at config-build time, so a new identity would
|
||||
// recreate the plot on every hover.
|
||||
//
|
||||
// The plot reports the cell from inside its own render path, which React can be
|
||||
// driving — a resize or a data swap runs the plot's hooks during a commit, and a
|
||||
// rebuilt plot re-reports the cell the cursor is still sitting on. Committing to
|
||||
// state there nests an update inside the commit that caused it, so the two feed
|
||||
// each other until React gives up at its depth limit. The frame takes the update
|
||||
// out of that chain; the equality check drops a report that carries nothing new.
|
||||
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
|
||||
hoveredCellRef.current = cell;
|
||||
if (hoverFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
hoverFrameRef.current = requestAnimationFrame(() => {
|
||||
hoverFrameRef.current = null;
|
||||
setHoveredCell((previous) =>
|
||||
isSameCell(previous, hoveredCellRef.current)
|
||||
? previous
|
||||
: hoveredCellRef.current,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => (): void => {
|
||||
if (hoverFrameRef.current !== null) {
|
||||
cancelAnimationFrame(hoverFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const config = useMemo(
|
||||
() =>
|
||||
buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor: resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange: handleHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision: boundaryPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}),
|
||||
[
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
resolvedSeriesColor,
|
||||
dimOnHover,
|
||||
handleHoverChange,
|
||||
yAxisUnit,
|
||||
boundaryPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
],
|
||||
);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
groups.map((group, index) => ({
|
||||
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
|
||||
// handling is identical across charts.
|
||||
seriesIndex: index + 1,
|
||||
label: group,
|
||||
// Colour means count here, so a marker names its group rather than keying
|
||||
// a colour — every one of them takes the top of the ramp.
|
||||
color: extremeColor,
|
||||
show: visibleGroups.includes(group),
|
||||
})),
|
||||
[groups, visibleGroups, extremeColor],
|
||||
);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => (
|
||||
<HeatmapTooltip
|
||||
{...args}
|
||||
id={id}
|
||||
yAxis={yAxis}
|
||||
step={grid.step}
|
||||
series={series}
|
||||
visibleGroups={visibleGroups}
|
||||
groupColor={extremeColor}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={boundaryPrecision}
|
||||
timezone={timezone}
|
||||
canPinTooltip={canPinTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
/>
|
||||
),
|
||||
[
|
||||
id,
|
||||
yAxis,
|
||||
grid.step,
|
||||
series,
|
||||
visibleGroups,
|
||||
extremeColor,
|
||||
yAxisUnit,
|
||||
boundaryPrecision,
|
||||
timezone,
|
||||
canPinTooltip,
|
||||
renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback((clickData: ChartClickData): void => {
|
||||
if (hoveredCellRef.current) {
|
||||
onCellClickRef.current?.(hoveredCellRef.current, clickData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
),
|
||||
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
|
||||
);
|
||||
|
||||
const visualMap = useMemo(() => {
|
||||
if (!showVisualMap || !hasGrid) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ColorBar
|
||||
label="count"
|
||||
ramp={colorResolver.ramp}
|
||||
minLabel={colorResolver.domain.min.toLocaleString()}
|
||||
maxLabel={colorResolver.domain.max.toLocaleString()}
|
||||
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
|
||||
/>
|
||||
);
|
||||
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={
|
||||
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
|
||||
}
|
||||
legendConfig={{ position: legendPosition }}
|
||||
showLegend={showLegend}
|
||||
customLegend={groupLegend}
|
||||
legendLabels={groups}
|
||||
showTooltip={showTooltip}
|
||||
canPinTooltip={canPinTooltip}
|
||||
pinKey={pinKey}
|
||||
onClick={onCellClick ? handleClick : undefined}
|
||||
yAxisUnit={yAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
customTooltip={renderTooltip}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
tooltipPortalRoot={tooltipPortalRoot}
|
||||
contentFooter={visualMap}
|
||||
layoutChildren={layoutChildren}
|
||||
data-testid={testId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type React from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
resolveExtremeColor,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapColorMode,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
|
||||
import Heatmap from '../Heatmap';
|
||||
|
||||
// The shared Legend virtualises its items; render them all so they are queryable.
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
VirtuosoGrid: ({
|
||||
data,
|
||||
itemContent,
|
||||
}: {
|
||||
data: LegendItem[];
|
||||
itemContent: (index: number, item: LegendItem) => React.ReactNode;
|
||||
}): JSX.Element => (
|
||||
<div>
|
||||
{data.map((item, index) => (
|
||||
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const BUCKETS = [128, 256, 1024];
|
||||
const STEP = 60;
|
||||
|
||||
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
|
||||
const SERIES: HeatmapSeries[] = [
|
||||
{
|
||||
label: 'service.name=cart',
|
||||
points: [
|
||||
{ timestamp: 1000, counts: [1, 4, 7, 10] },
|
||||
{ timestamp: 1060, counts: [2, null, 8, 11] },
|
||||
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'service.name=checkout',
|
||||
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
|
||||
},
|
||||
];
|
||||
|
||||
function renderHeatmap(
|
||||
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<Heatmap
|
||||
id="panel-1"
|
||||
buckets={BUCKETS}
|
||||
step={STEP}
|
||||
series={SERIES}
|
||||
width={800}
|
||||
height={400}
|
||||
isDarkMode
|
||||
data-testid="heatmap"
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Heatmap', () => {
|
||||
it('renders the plot container', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the colour bar with the resolved count domain', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,204')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts the colour bar against the plot, with the legend after it', () => {
|
||||
renderHeatmap();
|
||||
|
||||
const bar = screen.getByTestId('color-bar');
|
||||
const legend = screen
|
||||
.getByText('service.name=cart')
|
||||
.closest('[data-legend-item-id]');
|
||||
// The bar is the scale key for the grid, so it reads before the controls.
|
||||
expect(
|
||||
bar.compareDocumentPosition(legend as Node) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the colour bar inside the chart column, not below the legend', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(
|
||||
screen.getByTestId('color-bar').closest('.chart-layout__content'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the colour bar when the visual map is off', () => {
|
||||
renderHeatmap({ showVisualMap: false });
|
||||
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the colour bar with an explicit clamp instead of the data range', () => {
|
||||
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
|
||||
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('500')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when the metric has no buckets', () => {
|
||||
renderHeatmap({ buckets: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the no-data state when no columns came back', () => {
|
||||
renderHeatmap({ series: [] });
|
||||
|
||||
expect(screen.getByText('No Data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Heatmap group legend', () => {
|
||||
const CART = 'service.name=cart';
|
||||
const CHECKOUT = 'service.name=checkout';
|
||||
|
||||
function legendItem(label: string): HTMLElement {
|
||||
return screen.getByRole('switch', { name: label });
|
||||
}
|
||||
|
||||
function marker(label: string): HTMLElement {
|
||||
const element = legendItem(label).querySelector<HTMLElement>(
|
||||
'[data-is-legend-marker]',
|
||||
);
|
||||
if (!element) {
|
||||
throw new Error(`no marker for ${label}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
it('lists the groups, with no combined-view entry', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('enables every group to begin with', () => {
|
||||
renderHeatmap();
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('isolates a group when its label is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('restores every group when the isolated label is clicked again', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
await userEvent.click(screen.getByText(CART));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('excludes just one group when its marker is clicked', async () => {
|
||||
renderHeatmap();
|
||||
|
||||
await userEvent.click(marker(CHECKOUT));
|
||||
|
||||
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
|
||||
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('gives every marker the top of the palette, whatever the group"s counts', () => {
|
||||
renderHeatmap();
|
||||
// The DOM lowercases hex; the palette is built uppercase.
|
||||
const extreme = resolveExtremeColor({
|
||||
options: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
|
||||
}).toLowerCase();
|
||||
|
||||
// cart peaks at 1200 and checkout at 4, which the marker does not report.
|
||||
expect(marker(CART).style.borderColor.toLowerCase()).toBe(extreme);
|
||||
expect(marker(CHECKOUT).style.borderColor.toLowerCase()).toBe(extreme);
|
||||
});
|
||||
|
||||
it('gives every marker the solid fill in opacity mode', () => {
|
||||
renderHeatmap({
|
||||
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
|
||||
});
|
||||
|
||||
// A partially transparent marker is hard to read against the panel.
|
||||
expect(marker(CART).style.borderColor).toBe(
|
||||
marker(CHECKOUT).style.borderColor,
|
||||
);
|
||||
expect(marker(CART).style.borderColor).not.toBe('');
|
||||
});
|
||||
|
||||
it('shows the legend for a single group, which names what the grid plots', () => {
|
||||
renderHeatmap({ series: [SERIES[0]] });
|
||||
|
||||
expect(screen.getByText(CART)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the legend when asked', () => {
|
||||
renderHeatmap({ showLegend: false });
|
||||
|
||||
expect(screen.queryByText(CART)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { LegendAction } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useHeatmapGroupLegend } from '../useHeatmapGroupLegend';
|
||||
|
||||
const GROUPS = ['cart', 'checkout', 'payments'];
|
||||
|
||||
function render(
|
||||
groups: string[] = GROUPS,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useHeatmapGroupLegend>, unknown>
|
||||
> {
|
||||
return renderHook(() => useHeatmapGroupLegend({ groups }));
|
||||
}
|
||||
|
||||
describe('useHeatmapGroupLegend', () => {
|
||||
it('enables every group to begin with', () => {
|
||||
const { result } = render();
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('isolates a group when the legend asks to show only it', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('restores every group when the legend asks to show all', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('moves the isolation to the group named last', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.SHOW_ONLY,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['payments']);
|
||||
});
|
||||
|
||||
it('excludes just one group when it is toggled off', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'payments']);
|
||||
});
|
||||
|
||||
it('re-includes a group when it is toggled again', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('excludes more than one group', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 1,
|
||||
}),
|
||||
);
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['checkout']);
|
||||
});
|
||||
|
||||
it('allows every group to be excluded, as the other legends do', () => {
|
||||
const { result } = render();
|
||||
|
||||
GROUPS.forEach((_, index) =>
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: index + 1,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('ignores an action naming an entry that is not there', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 99,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(GROUPS);
|
||||
});
|
||||
|
||||
it('forgets a hidden group that left the result', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ groups }) => useHeatmapGroupLegend({ groups }),
|
||||
{ initialProps: { groups: GROUPS } },
|
||||
);
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.TOGGLE,
|
||||
seriesIndex: 3,
|
||||
}),
|
||||
);
|
||||
rerender({ groups: ['cart', 'checkout'] });
|
||||
|
||||
expect(result.current.visibleGroups).toStrictEqual(['cart', 'checkout']);
|
||||
});
|
||||
|
||||
it('tracks the hovered entry for the legend"s focus highlight', () => {
|
||||
const { result } = render();
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: 2,
|
||||
}),
|
||||
);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
act(() =>
|
||||
result.current.onLegendAction({
|
||||
type: LegendAction.HOVER,
|
||||
seriesIndex: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import {
|
||||
HeatmapAxisScale,
|
||||
HeatmapGrid,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
prepareHeatmapChartData,
|
||||
resolveBoundaryPrecision,
|
||||
} from '../utils';
|
||||
|
||||
const GRID: HeatmapGrid = {
|
||||
bounds: [128, 256, 1024],
|
||||
timestamps: [1000, 1060, 1120],
|
||||
step: 60,
|
||||
counts: [
|
||||
[1, 2, 3],
|
||||
[4, null, 6],
|
||||
[7, 8, 9],
|
||||
[0, 0, 0],
|
||||
],
|
||||
};
|
||||
|
||||
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
|
||||
|
||||
/** Tall enough that no tick needs thinning. */
|
||||
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
|
||||
|
||||
function readSplits(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
plot: uPlot,
|
||||
): number[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
|
||||
}
|
||||
|
||||
function readLabels(
|
||||
config: ReturnType<typeof buildHeatmapConfig>,
|
||||
splits: number[],
|
||||
): string[] {
|
||||
const [, yAxisConfig] = config.getConfig().axes ?? [];
|
||||
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
|
||||
{} as uPlot,
|
||||
splits,
|
||||
);
|
||||
}
|
||||
|
||||
function readRange(scale?: uPlot.Scale): [number, number] {
|
||||
const range = scale?.range as (
|
||||
u: uPlot,
|
||||
min: number,
|
||||
max: number,
|
||||
) => [number, number];
|
||||
return range({} as uPlot, 0, 0);
|
||||
}
|
||||
|
||||
function buildConfig(
|
||||
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
|
||||
): ReturnType<typeof buildHeatmapConfig> {
|
||||
return buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
|
||||
const CLOSE_BOUNDS = [
|
||||
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
|
||||
0.0681567332915786,
|
||||
];
|
||||
|
||||
describe('resolveBoundaryPrecision', () => {
|
||||
it('raises the precision until every boundary prints differently', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('leaves the panel"s precision alone where the boundaries already separate', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: Y_AXIS,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('never drops below the panel"s precision', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: Y_AXIS,
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 4,
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('stops at the most precise the panel can express', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(
|
||||
[0.062501, 0.062502, 0.062503],
|
||||
HeatmapAxisScale.Log,
|
||||
),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 2,
|
||||
}),
|
||||
).toBe(4);
|
||||
});
|
||||
|
||||
it('leaves full precision as it is, having nothing above it to reach for', () => {
|
||||
expect(
|
||||
resolveBoundaryPrecision({
|
||||
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: PrecisionOptionsEnum.FULL,
|
||||
}),
|
||||
).toBe(PrecisionOptionsEnum.FULL);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareHeatmapChartData', () => {
|
||||
it('puts timestamps first and one series per bucket row', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
expect(data[0]).toStrictEqual(GRID.timestamps);
|
||||
expect(data[1]).toStrictEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves null cells rather than zeroing them', () => {
|
||||
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
|
||||
|
||||
expect(data[2]).toStrictEqual([4, null, 6]);
|
||||
});
|
||||
|
||||
it('pads short rows so every uPlot data array is the same length', () => {
|
||||
const data = prepareHeatmapChartData(
|
||||
{ ...GRID, counts: [[1]] },
|
||||
Y_AXIS.rows.length,
|
||||
);
|
||||
|
||||
expect(data[1]).toStrictEqual([1, null, null]);
|
||||
});
|
||||
|
||||
it('pads missing rows up to the resolved row count', () => {
|
||||
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
|
||||
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data[2]).toStrictEqual([null, null, null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeatmapConfig', () => {
|
||||
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
|
||||
const config = buildHeatmapConfig({
|
||||
id: 'panel-1',
|
||||
grid: GRID,
|
||||
yAxis: Y_AXIS,
|
||||
colors: DEFAULT_HEATMAP_COLORS,
|
||||
isDarkMode: true,
|
||||
seriesColor: '#4e74f8',
|
||||
}).getConfig();
|
||||
|
||||
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
|
||||
});
|
||||
|
||||
it('draws no paths or points per series — the renderer paints the cells', () => {
|
||||
const [, firstRow] = buildConfig().getConfig().series ?? [];
|
||||
|
||||
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
|
||||
expect((firstRow as uPlot.Series).points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('labels series by bucket range, including the open-ended rows', () => {
|
||||
const labels = (buildConfig().getConfig().series ?? [])
|
||||
.slice(1)
|
||||
.map((series) => series.label);
|
||||
|
||||
expect(labels[0]).toContain('≤');
|
||||
expect(labels[labels.length - 1]).toContain('>');
|
||||
});
|
||||
|
||||
it('spans the x scale to the end of the last column, not its start', () => {
|
||||
const { x } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([1000, 1180]);
|
||||
});
|
||||
|
||||
it('prefers the query window over the grid extent', () => {
|
||||
const { x } =
|
||||
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
|
||||
{};
|
||||
|
||||
expect(readRange(x)).toStrictEqual([900, 1500]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
|
||||
const { y } = buildConfig().getConfig().scales ?? {};
|
||||
|
||||
expect(y?.auto).toBe(false);
|
||||
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
|
||||
});
|
||||
|
||||
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
|
||||
const splits = readSplits(buildConfig(), TALL_PLOT);
|
||||
|
||||
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
|
||||
});
|
||||
|
||||
it('labels the overflow edge as infinite and the rest by bucket value', () => {
|
||||
const config = buildConfig();
|
||||
const labels = readLabels(config, readSplits(config, TALL_PLOT));
|
||||
|
||||
expect(labels[0]).toBe('128');
|
||||
expect(labels[labels.length - 1]).toBe('∞');
|
||||
});
|
||||
|
||||
it('thins the tick set when the panel is too short to label every boundary', () => {
|
||||
const config = buildConfig();
|
||||
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
|
||||
|
||||
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
|
||||
// The infinite edge is the one label that must never be dropped.
|
||||
expect(readLabels(config, splits).at(-1)).toBe('∞');
|
||||
});
|
||||
|
||||
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
|
||||
const config = buildConfig().getConfig();
|
||||
|
||||
expect(config.cursor?.focus?.prox).toBe(-1);
|
||||
expect(config.cursor?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
|
||||
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
|
||||
});
|
||||
|
||||
it('registers the renderer hooks', () => {
|
||||
const { hooks } = buildConfig().getConfig();
|
||||
|
||||
expect(hooks?.init).toHaveLength(1);
|
||||
expect(hooks?.draw).toHaveLength(1);
|
||||
expect(hooks?.setCursor).toHaveLength(1);
|
||||
expect(hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { LegendAction, OnLegendAction } from 'lib/uPlotV2/components/types';
|
||||
|
||||
export interface UseHeatmapGroupLegendResult {
|
||||
/** Groups currently enabled. The grid sums exactly these. */
|
||||
visibleGroups: string[];
|
||||
focusedSeriesIndex: number | null;
|
||||
onLegendAction: OnLegendAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visibility for the heatmap legend, matching every other legend in the
|
||||
* product: the shared Legend decides what a click means and sends the action;
|
||||
* this only applies it. Everything is enabled to begin with.
|
||||
*
|
||||
* Counts are additive, so whatever is enabled is summed client-side and needs no
|
||||
* extra request.
|
||||
*
|
||||
* Visibility only. Marker colour is resolved by the caller, which owns the colour
|
||||
* ramp — and that ramp depends on which groups this hook has enabled.
|
||||
*/
|
||||
export function useHeatmapGroupLegend({
|
||||
groups,
|
||||
}: {
|
||||
groups: string[];
|
||||
}): UseHeatmapGroupLegendResult {
|
||||
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
||||
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const visibleGroups = useMemo(
|
||||
() => groups.filter((group) => !hidden.has(group)),
|
||||
[groups, hidden],
|
||||
);
|
||||
|
||||
const onLegendAction = useCallback<OnLegendAction>(
|
||||
(payload): void => {
|
||||
// Legend items are numbered from 1, mirroring uPlot's 1-based data series.
|
||||
const groupAt = (seriesIndex: number): string | undefined =>
|
||||
groups[seriesIndex - 1];
|
||||
|
||||
switch (payload.type) {
|
||||
case LegendAction.TOGGLE: {
|
||||
const group = groupAt(payload.seriesIndex);
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
setHidden((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(group)) {
|
||||
next.delete(group);
|
||||
} else {
|
||||
next.add(group);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LegendAction.SHOW_ONLY: {
|
||||
const group = groupAt(payload.seriesIndex);
|
||||
if (group === undefined) {
|
||||
return;
|
||||
}
|
||||
setHidden(new Set(groups.filter((entry) => entry !== group)));
|
||||
break;
|
||||
}
|
||||
case LegendAction.SHOW_ALL:
|
||||
setHidden(new Set());
|
||||
break;
|
||||
case LegendAction.HOVER:
|
||||
setFocusedSeriesIndex(payload.seriesIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[groups],
|
||||
);
|
||||
|
||||
return {
|
||||
visibleGroups,
|
||||
focusedSeriesIndex,
|
||||
onLegendAction,
|
||||
};
|
||||
}
|
||||
256
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
256
frontend/src/lib/visualization/charts/Heatmap/utils.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import {
|
||||
decimateAxisSplits,
|
||||
formatRowLabel,
|
||||
resolveColumnAlignedSplits,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
|
||||
import {
|
||||
createHeatmapHooks,
|
||||
HeatmapRenderOptions,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
|
||||
import {
|
||||
HeatmapGrid,
|
||||
HeatmapYAxis,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Minimum gap between y tick labels, in CSS pixels. */
|
||||
const MIN_Y_TICK_GAP_PX = 18;
|
||||
|
||||
/** Label for the edge above the overflow row. */
|
||||
const OVERFLOW_AXIS_LABEL = '∞';
|
||||
|
||||
const BOUNDARY_PRECISIONS: Array<
|
||||
Exclude<PrecisionOption, PrecisionOptionsEnum.FULL>
|
||||
> = [0, 1, 2, 3, 4];
|
||||
|
||||
/** What `getYAxisFormattedValue` falls back to, so an unset precision starts
|
||||
* where the panel renders. */
|
||||
const DEFAULT_BOUNDARY_PRECISION = 2;
|
||||
|
||||
/**
|
||||
* Lowest precision, at or above the panel's, that prints every boundary
|
||||
* distinctly: `0.0625` and `0.0653` are both `0.06` at two decimals, and the rows
|
||||
* either side then claim the same range. Settles for the most precise candidate
|
||||
* when none separates every pair.
|
||||
*/
|
||||
export function resolveBoundaryPrecision({
|
||||
yAxis,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
}: {
|
||||
yAxis: HeatmapYAxis;
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
}): PrecisionOption | undefined {
|
||||
if (decimalPrecision === PrecisionOptionsEnum.FULL) {
|
||||
return decimalPrecision;
|
||||
}
|
||||
|
||||
// The overflow row's upper edge is the synthetic top of the grid, labelled `∞`.
|
||||
const boundaries = yAxis.rows
|
||||
.filter((row) => !row.isOverflow)
|
||||
.map((row) => row.upper);
|
||||
|
||||
const floor = decimalPrecision ?? DEFAULT_BOUNDARY_PRECISION;
|
||||
const candidates = BOUNDARY_PRECISIONS.filter(
|
||||
(candidate) => candidate >= floor,
|
||||
);
|
||||
|
||||
const separates = (candidate: PrecisionOption): boolean => {
|
||||
const labels = boundaries.map((boundary) =>
|
||||
getToolTipValue(String(boundary), yAxisUnit, candidate),
|
||||
);
|
||||
return labels.every(
|
||||
(label, index) => index === 0 || label !== labels[index - 1],
|
||||
);
|
||||
};
|
||||
|
||||
return candidates.find(separates) ?? candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
|
||||
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
|
||||
*
|
||||
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
|
||||
* the response carried duplicate boundaries — since uPlot requires equal lengths.
|
||||
*/
|
||||
export function prepareHeatmapChartData(
|
||||
grid: HeatmapGrid,
|
||||
rowCount: number,
|
||||
): uPlot.AlignedData {
|
||||
const columnCount = grid.timestamps.length;
|
||||
const rows = Array.from({ length: rowCount }, (_, row) => {
|
||||
const counts = grid.counts[row] ?? [];
|
||||
return Array.from({ length: columnCount }, (_, column) =>
|
||||
counts[column] === undefined ? null : counts[column],
|
||||
);
|
||||
});
|
||||
|
||||
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface BuildHeatmapConfigArgs extends Omit<
|
||||
HeatmapRenderOptions,
|
||||
'step'
|
||||
> {
|
||||
id: string;
|
||||
grid: HeatmapGrid;
|
||||
/** Unit of the bucket boundaries; counts are never formatted with it. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Query window, in seconds. Falls back to the grid's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
}
|
||||
|
||||
export function buildHeatmapConfig({
|
||||
id,
|
||||
grid,
|
||||
yAxis,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
onDragSelect,
|
||||
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
|
||||
const tzDate = timezone
|
||||
? (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
|
||||
: undefined;
|
||||
|
||||
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
|
||||
|
||||
// uPlot's focus picks the series closest in value space, meaningless when the
|
||||
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
|
||||
// that call off uPlot's full-redraw path.
|
||||
builder.setFocus({ alpha: 1 });
|
||||
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
|
||||
|
||||
const formatBucketValue = (value: number): string =>
|
||||
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
|
||||
|
||||
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
|
||||
const xRange: [number, number] = [
|
||||
minTimeScale ?? grid.timestamps[0] ?? 0,
|
||||
maxTimeScale ?? lastTimestamp + grid.step,
|
||||
];
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
range: (): [number, number] => xRange,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: false,
|
||||
range: (): [number, number] => [yAxis.min, yAxis.max],
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
|
||||
// Grid lines are drawn on the ticks, so the ticks go on the cell edges.
|
||||
splits: (_self, _axisIdx, scaleMin, scaleMax, foundIncr): number[] =>
|
||||
resolveColumnAlignedSplits({
|
||||
anchor: grid.timestamps[0] ?? 0,
|
||||
step: grid.step,
|
||||
incr: foundIncr,
|
||||
min: scaleMin,
|
||||
max: scaleMax,
|
||||
toDate: tzDate,
|
||||
}),
|
||||
});
|
||||
|
||||
// Ticks sit on row edges, so the overflow row is the band between the last
|
||||
// boundary and `∞`. A centre label would sit half a row from the boundary tick
|
||||
// and collide with it.
|
||||
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
|
||||
const hasOverflowTick =
|
||||
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
|
||||
const axisSplits = hasOverflowTick
|
||||
? [...yAxis.splits, yAxis.overflowSplit as number]
|
||||
: yAxis.splits;
|
||||
|
||||
// From the boundaries themselves, not by inverting the transform:
|
||||
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
|
||||
const splitLabels = new Map<number, string>();
|
||||
yAxis.splits.forEach((split, index) => {
|
||||
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
|
||||
});
|
||||
if (hasOverflowTick) {
|
||||
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
|
||||
}
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
// Thinned to whatever fits: a histogram can carry more boundaries than the
|
||||
// panel has room to label.
|
||||
splits: (self): number[] =>
|
||||
decimateAxisSplits({
|
||||
splits: axisSplits,
|
||||
min: yAxis.min,
|
||||
max: yAxis.max,
|
||||
plotHeight: self.bbox.height / uPlot.pxRatio,
|
||||
minGapPx: MIN_Y_TICK_GAP_PX,
|
||||
}),
|
||||
values: (_, splits): string[] =>
|
||||
splits.map(
|
||||
(split) =>
|
||||
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
|
||||
),
|
||||
});
|
||||
|
||||
yAxis.rows.forEach((row) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
// Nothing is stroked per series; the draw hook paints the grid.
|
||||
drawStyle: DrawStyle.Line,
|
||||
pathBuilder: (): null => null,
|
||||
showPoints: false,
|
||||
spanGaps: false,
|
||||
label: formatRowLabel(row, formatBucketValue),
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
const hooks = createHeatmapHooks({
|
||||
yAxis,
|
||||
step: grid.step,
|
||||
colors,
|
||||
isDarkMode,
|
||||
seriesColor,
|
||||
dimOnHover,
|
||||
onHoverChange,
|
||||
});
|
||||
|
||||
// Order matters — see the HeatmapHooks doc comment.
|
||||
builder.addHook('init', hooks.init);
|
||||
builder.addHook('draw', hooks.draw);
|
||||
builder.addHook('setCursor', hooks.setCursor);
|
||||
builder.addHook('destroy', hooks.destroy);
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
HeatmapColorOptions,
|
||||
HeatmapSeries,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -33,6 +39,13 @@ interface BaseChartProps {
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
contentFooter?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
interface UPlotBasedChartProps {
|
||||
@@ -74,6 +87,61 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
|
||||
* y axis *is* the bucket axis and `buckets` fully determines it.
|
||||
*
|
||||
* `buckets`, `series` and `colors` must be referentially stable: a new identity
|
||||
* rebuilds the config, which recreates the plot.
|
||||
*/
|
||||
export interface HeatmapChartProps {
|
||||
id: string;
|
||||
/** Ascending. N boundaries describe N+1 rows. */
|
||||
buckets: number[];
|
||||
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
|
||||
* the requested one. Cannot be inferred: the last column has no successor. */
|
||||
step: number;
|
||||
/** One entry per group; a query without grouping yields one series. */
|
||||
series: HeatmapSeries[];
|
||||
width: number;
|
||||
height: number;
|
||||
isDarkMode: boolean;
|
||||
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
|
||||
colors?: Partial<HeatmapColorOptions>;
|
||||
/** Default auto: the boundaries pick between log, symmetric log and
|
||||
* linear. */
|
||||
axisScale?: HeatmapAxisScale;
|
||||
/** Unit of the bucket boundaries; counts are always plain numbers. */
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
timezone?: Timezone;
|
||||
/** Colour bar below the grid. Default true. */
|
||||
showVisualMap?: boolean;
|
||||
/** Default true; hidden anyway when there is only one group. Every group starts
|
||||
* enabled — the label isolates one, the marker excludes one. */
|
||||
showLegend?: boolean;
|
||||
legendPosition?: LegendPosition;
|
||||
/** Default true. */
|
||||
dimOnHover?: boolean;
|
||||
showTooltip?: boolean;
|
||||
canPinTooltip?: boolean;
|
||||
pinKey?: string;
|
||||
/** Overrides the opacity-mode fill, which otherwise follows the selected
|
||||
* group's legend colour so the grid matches the swatch that was clicked. */
|
||||
seriesColor?: string;
|
||||
/** Query window, in seconds. Falls back to the data's own extent. */
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
|
||||
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
layoutChildren?: React.ReactNode;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -16,20 +16,31 @@ export interface ChartLayoutProps {
|
||||
averageLegendWidth: number;
|
||||
}) => React.ReactNode;
|
||||
layoutChildren?: React.ReactNode;
|
||||
/**
|
||||
* Rendered directly under the plot, inside the chart column — so it stays next to
|
||||
* the axis with the legend below it, and beside a RIGHT legend rather than under
|
||||
* it. `layoutChildren` sits below everything instead.
|
||||
*/
|
||||
contentFooter?: React.ReactNode;
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
legendConfig: LegendConfig;
|
||||
config: UPlotConfigBuilder;
|
||||
/** Defaults to the chart's series labels. Pass them when the legend lists
|
||||
* something else, or the split is measured against the wrong text. */
|
||||
seriesLabels?: string[];
|
||||
}
|
||||
export default function ChartLayout({
|
||||
showLegend = true,
|
||||
legendComponent,
|
||||
children,
|
||||
layoutChildren,
|
||||
contentFooter,
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
config,
|
||||
seriesLabels,
|
||||
}: ChartLayoutProps): JSX.Element {
|
||||
const chartDimensions = useMemo(
|
||||
() => {
|
||||
@@ -42,19 +53,20 @@ export default function ChartLayout({
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
const seriesLabels = Object.values(legendItemsMap)
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
const resolvedLabels =
|
||||
seriesLabels ??
|
||||
Object.values(config.getLegendItems())
|
||||
.map((item) => item.label)
|
||||
.filter((label): label is string => label !== undefined);
|
||||
return calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig,
|
||||
seriesLabels,
|
||||
seriesLabels: resolvedLabels,
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[containerWidth, containerHeight, legendConfig, showLegend],
|
||||
[containerWidth, containerHeight, legendConfig, showLegend, seriesLabels],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -72,6 +84,7 @@ export default function ChartLayout({
|
||||
chartHeight: chartDimensions.height,
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
})}
|
||||
{contentFooter}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
.all-errors-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.all-errors-quick-filter-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
.right-toolbar-actions-container {
|
||||
display: flex;
|
||||
@@ -11,4 +18,14 @@
|
||||
.ant-tabs {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { Filter } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
|
||||
@@ -57,52 +59,63 @@ function AllErrors(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<QuickFiltersLayout
|
||||
className="all-errors-page"
|
||||
contentClassName="all-errors-right-section"
|
||||
showFilters={showFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
'all-errors-right-section',
|
||||
showFilters ? 'filter-visible' : '',
|
||||
)}
|
||||
>
|
||||
<TypicalOverlayScrollbar>
|
||||
<>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
/>
|
||||
</QuickFiltersLayout>
|
||||
</>
|
||||
</TypicalOverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
.api-monitoring-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -8,6 +15,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,12 +13,9 @@ function ApiMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Explorer];
|
||||
|
||||
return (
|
||||
<RouteTab
|
||||
className="api-monitoring-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
<div className="api-monitoring-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardtypesLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
SectionKind,
|
||||
type AnyThreshold,
|
||||
type PanelAxesSlice,
|
||||
type PanelChartAppearanceSlice,
|
||||
type PanelFormattingSlice,
|
||||
type SectionEditorProps,
|
||||
type SectionSpecMap,
|
||||
@@ -81,8 +81,8 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.Axes]: {
|
||||
Component: AxesSection,
|
||||
get: (spec): DashboardtypesAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
|
||||
get: (spec): PanelAxesSlice | undefined =>
|
||||
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
[SectionKind.Legend]: {
|
||||
@@ -94,11 +94,8 @@ export const SECTION_REGISTRY: {
|
||||
},
|
||||
[SectionKind.ChartAppearance]: {
|
||||
Component: ChartAppearanceSection,
|
||||
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
|
||||
spec,
|
||||
'chartAppearance',
|
||||
),
|
||||
get: (spec): PanelChartAppearanceSlice | undefined =>
|
||||
getPluginSlice<PanelChartAppearanceSlice>(spec, 'chartAppearance'),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './AxesSection.module.scss';
|
||||
|
||||
@@ -17,9 +19,37 @@ const SCALE_OPTIONS = [
|
||||
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
|
||||
];
|
||||
|
||||
// Auto is the only one that reads the boundaries rather than overriding them.
|
||||
const Y_SCALE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.auto,
|
||||
label: 'Auto',
|
||||
tooltip:
|
||||
'Log when every bucket bound is positive, symmetric log when they cross zero',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
tooltip: 'Row heights proportional to the bucket range',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.log,
|
||||
label: 'Log',
|
||||
tooltip:
|
||||
'Log-proportional row heights; a zero bucket sits one bucket below the smallest positive bound',
|
||||
},
|
||||
{
|
||||
value: DashboardtypesHeatmapYScaleDTO.symlog,
|
||||
label: 'Symmetric log',
|
||||
tooltip:
|
||||
'Linear within the smallest non-zero bound, logarithmic beyond, mirrored across zero',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
|
||||
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds, the
|
||||
* linear/logarithmic scale toggle, and the heatmap's bucket-axis distribution.
|
||||
* Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function AxesSection({
|
||||
value,
|
||||
@@ -76,6 +106,19 @@ function AxesSection({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.yScale && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Y-axis scale</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-y-scale"
|
||||
placeholder="Select scale…"
|
||||
value={value?.yScale}
|
||||
items={Y_SCALE_OPTIONS}
|
||||
onChange={(next): void => onChange({ ...value, yScale: next })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AxesSection from '../AxesSection';
|
||||
@@ -64,6 +65,65 @@ describe('AxesSection', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ softMax: null });
|
||||
});
|
||||
|
||||
it('offers the bucket-axis scale instead of the soft bounds when yScale is on', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ yScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-y-scale')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-soft-min'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-log-scale'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers every bucket-axis scale, symmetric log included', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ yScale: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId('panel-editor-v2-y-scale');
|
||||
await user.click(
|
||||
trigger.querySelector('.ant-select-selector') as HTMLElement,
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('option', { name: /Auto/ }),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Log/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /Linear/ })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('option', { name: /Symmetric log/ }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
yScale: DashboardtypesHeatmapYScaleDTO.symlog,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the scale the spec asks for', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={{ yScale: DashboardtypesHeatmapYScaleDTO.symlog }}
|
||||
controls={{ yScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Symmetric log')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the logarithmic scale through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
@@ -15,6 +15,7 @@ import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { SegmentIcon } from '../../controls/segmentIcons';
|
||||
import type { SectionEditorContext } from '../../sectionContext';
|
||||
import DisconnectValuesField from './DisconnectValuesField';
|
||||
import HeatmapColorsField from './HeatmapColorsField';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
@@ -73,9 +74,9 @@ const FILL_MODE_OPTIONS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
|
||||
* control is gated by its `controls` flag.
|
||||
* Edits the `chartAppearance` slice of a panel spec: line style / interpolation, fill
|
||||
* mode, point markers and the connect-null-gaps threshold for the time-axis charts,
|
||||
* the cell colour ramp for the heatmap. Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
@@ -150,6 +151,13 @@ function ChartAppearanceSection({
|
||||
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.colors && (
|
||||
<HeatmapColorsField
|
||||
value={value?.colors}
|
||||
onChange={(colors): void => onChange({ ...value, colors })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Slider } from '@signozhq/ui/slider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
clampColorSteps,
|
||||
DEFAULT_COLOR_STEPS,
|
||||
MAX_COLOR_STEPS,
|
||||
MIN_COLOR_STEPS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import { singleSliderValue } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorStepsFieldProps {
|
||||
/** Unset until the slider moves; the chart's default stands in. */
|
||||
value: number | undefined;
|
||||
onChange: (steps: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many colours the ramp is quantised into. Committed on release, so a drag
|
||||
* doesn't write every value it passes through to the spec.
|
||||
*/
|
||||
function HeatmapColorStepsField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapColorStepsFieldProps): JSX.Element {
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
|
||||
const steps = dragging ?? value ?? DEFAULT_COLOR_STEPS;
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<div className={styles.fieldHeader}>
|
||||
<Typography.Text>Color steps</Typography.Text>
|
||||
<span className={styles.mono}>{steps}</span>
|
||||
</div>
|
||||
<Slider
|
||||
testId="panel-editor-v2-heatmap-color-steps"
|
||||
className={styles.slider}
|
||||
value={steps}
|
||||
min={MIN_COLOR_STEPS}
|
||||
max={MAX_COLOR_STEPS}
|
||||
step={1}
|
||||
aria-label="Color steps"
|
||||
onChange={(next): void => setDragging(singleSliderValue(next))}
|
||||
onAfterChange={(next): void => {
|
||||
setDragging(null);
|
||||
onChange(clampColorSteps(singleSliderValue(next)));
|
||||
}}
|
||||
/>
|
||||
<Typography.Text className={styles.help}>
|
||||
How many colours the ramp is split into.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapColorStepsField;
|
||||
@@ -0,0 +1,138 @@
|
||||
.colors {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
// The track is inset by half a thumb at each end by default, which reads as an
|
||||
// indent against the label and help text either side of it. Radix keeps the thumb
|
||||
// in bounds on its own, so the track can run the full width of the field.
|
||||
.slider {
|
||||
--slider-track-margin-x: 0;
|
||||
}
|
||||
|
||||
.help {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
font-size: var(--periscope-font-size-small);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fillGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-6);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.customFillValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.customFillSwatch {
|
||||
width: var(--spacing-12);
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.previewRamp {
|
||||
height: var(--spacing-8);
|
||||
border-radius: var(--radius-1);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.previewBounds {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.paletteGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.paletteCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--radius-1);
|
||||
background: var(--l2-background-60);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.paletteRamp {
|
||||
height: var(--spacing-6);
|
||||
border-radius: var(--radius-1);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
type DashboardtypesHeatmapColorsDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import HeatmapColorStepsField from './HeatmapColorStepsField';
|
||||
import HeatmapCountRangeField from './HeatmapCountRangeField';
|
||||
import HeatmapFillField from './HeatmapFillField';
|
||||
import HeatmapPaletteGrid from './HeatmapPaletteGrid';
|
||||
import HeatmapRampPreview from './HeatmapRampPreview';
|
||||
import { COLOR_MODE_OPTIONS, COLOR_SCALE_OPTIONS } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapColorsFieldProps {
|
||||
value: DashboardtypesHeatmapColorsDTO | undefined;
|
||||
onChange: (next: DashboardtypesHeatmapColorsDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits `chartAppearance.colors` — how a cell's count becomes a colour. Palette
|
||||
* mode ramps through a sequential palette, opacity mode through one fill's alpha;
|
||||
* everything below the mode applies to both.
|
||||
*/
|
||||
function HeatmapColorsField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapColorsFieldProps): JSX.Element {
|
||||
const isOpacityMode =
|
||||
value?.mode === DashboardtypesHeatmapColorModeDTO.opacity;
|
||||
|
||||
return (
|
||||
<div className={styles.colors}>
|
||||
<HeatmapRampPreview colors={value} />
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-mode"
|
||||
value={value?.mode}
|
||||
items={COLOR_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
mode: next as DashboardtypesHeatmapColorModeDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOpacityMode ? (
|
||||
<HeatmapFillField
|
||||
value={value?.fill}
|
||||
onChange={(fill): void => onChange({ ...value, fill })}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Palette</Typography.Text>
|
||||
<HeatmapPaletteGrid
|
||||
value={value?.palette}
|
||||
onChange={(palette): void => onChange({ ...value, palette })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Color scale</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-heatmap-color-scale"
|
||||
value={value?.scale}
|
||||
items={COLOR_SCALE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
scale: next as DashboardtypesHeatmapColorScaleDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.help}>
|
||||
How a count maps onto the ramp.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<HeatmapColorStepsField
|
||||
value={value?.steps || undefined}
|
||||
onChange={(steps): void => onChange({ ...value, steps })}
|
||||
/>
|
||||
|
||||
<HeatmapCountRangeField
|
||||
value={{ minCount: value?.minCount, maxCount: value?.maxCount }}
|
||||
onChange={(bounds): void => onChange({ ...value, ...bounds })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapColorsField;
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
/** `null` on either bound derives it from the data. */
|
||||
type CountBound = Pick<DashboardtypesHeatmapColorsDTO, 'minCount' | 'maxCount'>;
|
||||
|
||||
interface HeatmapCountRangeFieldProps {
|
||||
value: CountBound;
|
||||
onChange: (next: CountBound) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the colour scale to a fixed count range. Two panels rarely derive the
|
||||
* same bounds, so comparing them needs the range pinned on both.
|
||||
*/
|
||||
function HeatmapCountRangeField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapCountRangeFieldProps): JSX.Element {
|
||||
// An empty field derives the bound (null), as does transient non-numeric input
|
||||
// (a lone "-"), which pinning would read as a bound the user never typed.
|
||||
const handleBound =
|
||||
(bound: keyof CountBound) =>
|
||||
(event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = event.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<div className={styles.bounds}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Min count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-min-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.minCount ?? ''}
|
||||
onChange={handleBound('minCount')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Max count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-heatmap-max-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value.maxCount ?? ''}
|
||||
onChange={handleBound('maxCount')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Typography.Text className={styles.help}>
|
||||
The counts the ramp's ends mean; empty reads them off the data.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapCountRangeField;
|
||||
@@ -0,0 +1,85 @@
|
||||
import cx from 'classnames';
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DEFAULT_OPACITY_FILL } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
|
||||
import { FILL_PRESETS, isSameColor } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapFillFieldProps {
|
||||
/** Empty follows the selected group's legend colour. */
|
||||
value: string | undefined;
|
||||
onChange: (fill: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The colour opacity mode ramps the alpha of. Any hex goes, since the point of
|
||||
* the fill is matching whatever else the dashboard already uses.
|
||||
*/
|
||||
function HeatmapFillField({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapFillFieldProps): JSX.Element {
|
||||
const isAuto = !value;
|
||||
const isCustom =
|
||||
!isAuto && !FILL_PRESETS.some((preset) => isSameColor(preset.color, value));
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Base color</Typography.Text>
|
||||
|
||||
<div className={styles.fillGrid}>
|
||||
{FILL_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
aria-pressed={isSameColor(preset.color, value)}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: isSameColor(preset.color, value),
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-fill-${preset.label}`}
|
||||
onClick={(): void => onChange(preset.color)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: preset.color }}
|
||||
/>
|
||||
<span className={styles.mono}>{preset.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
value={value || DEFAULT_OPACITY_FILL}
|
||||
trigger="click"
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.customFill, {
|
||||
[styles.isSelected]: isCustom,
|
||||
})}
|
||||
data-testid="panel-editor-v2-heatmap-fill-custom"
|
||||
>
|
||||
<Typography.Text>Custom</Typography.Text>
|
||||
<span className={styles.customFillValue}>
|
||||
<span className={styles.mono}>
|
||||
{isAuto ? 'group colour' : value.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={styles.customFillSwatch}
|
||||
style={{ background: value || DEFAULT_OPACITY_FILL }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
|
||||
<Typography.Text className={styles.help}>
|
||||
The colour whose alpha ramps; unset uses the group's.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapFillField;
|
||||
@@ -0,0 +1,45 @@
|
||||
import cx from 'classnames';
|
||||
import type { DashboardtypesHeatmapPaletteDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { paletteGradient, PALETTE_OPTIONS } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
interface HeatmapPaletteGridProps {
|
||||
value: DashboardtypesHeatmapPaletteDTO | undefined;
|
||||
onChange: (palette: DashboardtypesHeatmapPaletteDTO) => void;
|
||||
}
|
||||
|
||||
/** The palettes as the ramps they are: "lava" and "ember" name nothing visible. */
|
||||
function HeatmapPaletteGrid({
|
||||
value,
|
||||
onChange,
|
||||
}: HeatmapPaletteGridProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className={styles.paletteGrid}>
|
||||
{PALETTE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={option.value === value}
|
||||
className={cx(styles.paletteCard, {
|
||||
[styles.isSelected]: option.value === value,
|
||||
})}
|
||||
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
>
|
||||
<span
|
||||
className={styles.paletteRamp}
|
||||
style={{ background: paletteGradient(option.value, isDarkMode) }}
|
||||
/>
|
||||
<span className={styles.mono}>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapPaletteGrid;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
createHeatmapColorResolver,
|
||||
DEFAULT_HEATMAP_COLORS,
|
||||
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { HeatmapColorMode } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
|
||||
import { resolveHeatmapColors } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
|
||||
|
||||
import { rampGradient } from './heatmapColorOptions';
|
||||
|
||||
import styles from './HeatmapColorsField.module.scss';
|
||||
|
||||
/** The ramp is domain-independent; the preview shows colours, not positions. */
|
||||
const NOMINAL_DOMAIN = { min: 0, max: 1, logFloor: 1 };
|
||||
|
||||
interface HeatmapRampPreviewProps {
|
||||
colors: DashboardtypesHeatmapColorsDTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ramp the grid will draw, resolved through the chart's own colour resolver.
|
||||
* Its ends carry the counts it is stretched between; an unset maximum is only
|
||||
* known once the data arrives.
|
||||
*/
|
||||
function HeatmapRampPreview({ colors }: HeatmapRampPreviewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const options = useMemo(
|
||||
() => ({ ...DEFAULT_HEATMAP_COLORS, ...resolveHeatmapColors(colors) }),
|
||||
[colors],
|
||||
);
|
||||
|
||||
const ramp = useMemo(
|
||||
() =>
|
||||
createHeatmapColorResolver({
|
||||
options,
|
||||
domain: NOMINAL_DOMAIN,
|
||||
isDarkMode,
|
||||
// No group to follow here, so the resolver's fallback fill stands in.
|
||||
seriesColor: '',
|
||||
}).ramp,
|
||||
[options, isDarkMode],
|
||||
);
|
||||
|
||||
const summary = [
|
||||
options.mode === HeatmapColorMode.Opacity ? 'opacity' : options.palette,
|
||||
options.scale,
|
||||
`${options.steps} steps`,
|
||||
].join(' · ');
|
||||
|
||||
return (
|
||||
<div className={styles.preview} data-testid="panel-editor-v2-heatmap-preview">
|
||||
<div className={styles.previewHeader}>
|
||||
<Typography.Text>Preview</Typography.Text>
|
||||
<span className={styles.mono}>{summary}</span>
|
||||
</div>
|
||||
<div
|
||||
className={styles.previewRamp}
|
||||
style={{ background: rampGradient(ramp) }}
|
||||
/>
|
||||
<div className={styles.previewBounds}>
|
||||
<span className={styles.mono}>
|
||||
{(colors?.minCount ?? 0).toLocaleString()}
|
||||
</span>
|
||||
<span className={styles.mono}>
|
||||
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HeatmapRampPreview;
|
||||
@@ -85,6 +85,36 @@ describe('ChartAppearanceSection', () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the heatmap colour ramp only when the colors flag is set', () => {
|
||||
const { unmount } = render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={ALL_CONTROLS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-heatmap-color-mode'),
|
||||
).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ colors: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-mode'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-line-style'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen fill mode through the segmented control', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DEFAULT_COLOR_STEPS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import HeatmapColorsField from '../HeatmapColorsField';
|
||||
|
||||
const PALETTE_CARD = `panel-editor-v2-heatmap-palette-${DashboardtypesHeatmapPaletteDTO.lagoon}`;
|
||||
|
||||
describe('HeatmapColorsField', () => {
|
||||
it('shows the ramp the grid will draw, with what resolved it', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
palette: DashboardtypesHeatmapPaletteDTO.lava,
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
steps: 32,
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-preview'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('lava · log · 32 steps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('labels the ramp ends with the counts it is stretched between', () => {
|
||||
render(
|
||||
<HeatmapColorsField value={{ maxCount: 4000 }} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
// An unset minimum always resolves to 0; an unset maximum needs the data.
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
expect(screen.getByText('4,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the palettes as the ramps they are in palette mode', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(PALETTE_CARD)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-scale'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-color-steps'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('swaps the palettes for the base colour in opacity mode', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(PALETTE_CARD)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Base color')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the colour mode through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Opacity'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the picked palette through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.click(screen.getByTestId(PALETTE_CARD));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
palette: DashboardtypesHeatmapPaletteDTO.lagoon,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the palette the spec asks for as the selected one', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ palette: DashboardtypesHeatmapPaletteDTO.lagoon }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId(PALETTE_CARD)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the colour scale through onChange and says what it drives', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/How a count maps onto the ramp/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText('Sqrt'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the default for a zero step count, which is how the wire sends none', () => {
|
||||
render(<HeatmapColorsField value={{ steps: 0 }} onChange={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(`lava · log · ${DEFAULT_COLOR_STEPS} steps`),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(String(DEFAULT_COLOR_STEPS))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('steps off the default step count, which stands in until it is moved', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
String(DEFAULT_COLOR_STEPS),
|
||||
);
|
||||
|
||||
screen.getByRole('slider').focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
steps: DEFAULT_COLOR_STEPS + 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be taken past the step counts the chart can draw', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={{ steps: 2 }} onChange={onChange} />);
|
||||
|
||||
const slider = screen.getByRole('slider');
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '2');
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '128');
|
||||
|
||||
// Already at the floor, so there is no step to take and nothing to write.
|
||||
slider.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes a pinned count bound through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-heatmap-max-count'), '5');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({ maxCount: 5 });
|
||||
});
|
||||
|
||||
it('takes no letters, which would silently read as no bound at all', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
|
||||
|
||||
const field = screen.getByTestId('panel-editor-v2-heatmap-min-count');
|
||||
await user.type(field, 'dsd');
|
||||
|
||||
expect(field).toHaveValue(null);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a count bound to null, which asks for the derived one', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(<HeatmapColorsField value={{ maxCount: 500 }} onChange={onChange} />);
|
||||
|
||||
await user.clear(screen.getByTestId('panel-editor-v2-heatmap-max-count'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ maxCount: null });
|
||||
});
|
||||
|
||||
it('writes a preset base colour through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-heatmap-fill-forest'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: Color.BG_FOREST_400,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the preset the spec asks for, whatever case its hex is in', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: Color.BG_FOREST_400.toUpperCase(),
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-forest'),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('shows a fill that is no preset as the custom one', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{
|
||||
mode: DashboardtypesHeatmapColorModeDTO.opacity,
|
||||
fill: '#0060e6',
|
||||
}}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('#0060E6')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('names the group colour as the fill until one is picked', () => {
|
||||
render(
|
||||
<HeatmapColorsField
|
||||
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('group colour')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import {
|
||||
DashboardtypesHeatmapColorModeDTO,
|
||||
DashboardtypesHeatmapColorScaleDTO,
|
||||
DashboardtypesHeatmapPaletteDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
|
||||
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
|
||||
|
||||
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
export const COLOR_MODE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.palette, label: 'Palette' },
|
||||
{ value: DashboardtypesHeatmapColorModeDTO.opacity, label: 'Opacity' },
|
||||
];
|
||||
|
||||
// Log first: bucket counts routinely span decades.
|
||||
export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.log,
|
||||
label: 'Log',
|
||||
icon: 'scale-log',
|
||||
},
|
||||
{ value: DashboardtypesHeatmapColorScaleDTO.sqrt, label: 'Sqrt' },
|
||||
{
|
||||
value: DashboardtypesHeatmapColorScaleDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: 'scale-linear',
|
||||
},
|
||||
];
|
||||
|
||||
export interface HeatmapFillPreset {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Opacity-mode base colours: the palette values that read on both themes. */
|
||||
export const FILL_PRESETS: HeatmapFillPreset[] = [
|
||||
{ label: 'robin', color: Color.BG_ROBIN_400 },
|
||||
{ label: 'aqua', color: Color.BG_AQUA_400 },
|
||||
{ label: 'forest', color: Color.BG_FOREST_400 },
|
||||
{ label: 'sienna', color: Color.BG_SIENNA_400 },
|
||||
];
|
||||
|
||||
/** Hex comparison for a spec value that may differ only in case. */
|
||||
export function isSameColor(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
): boolean {
|
||||
return Boolean(left) && left?.toLowerCase() === right?.toLowerCase();
|
||||
}
|
||||
|
||||
export interface HeatmapPaletteOption {
|
||||
value: DashboardtypesHeatmapPaletteDTO;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const PALETTE_OPTIONS: HeatmapPaletteOption[] = [
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.ice, label: 'ice' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.moss, label: 'moss' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.rust, label: 'rust' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.graphite, label: 'graphite' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.ember, label: 'ember' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.lagoon, label: 'lagoon' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.orchid, label: 'orchid' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.verdant, label: 'verdant' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.lava, label: 'lava' },
|
||||
{ value: DashboardtypesHeatmapPaletteDTO.beacon, label: 'beacon' },
|
||||
];
|
||||
|
||||
/** `background` for a palette's card, low-count-first like the colour bar. */
|
||||
export function paletteGradient(
|
||||
palette: DashboardtypesHeatmapPaletteDTO,
|
||||
isDarkMode: boolean,
|
||||
): string {
|
||||
const stops = getPaletteStops(HEATMAP_PALETTE_MAP[palette], isDarkMode);
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** `background` for a resolved ramp: hard-edged bands, one per colour step. */
|
||||
export function rampGradient(ramp: string[]): string {
|
||||
if (ramp.length === 0) {
|
||||
return 'transparent';
|
||||
}
|
||||
if (ramp.length === 1) {
|
||||
return ramp[0];
|
||||
}
|
||||
const stops = ramp.flatMap((color, index) => [
|
||||
`${color} ${(index / ramp.length) * 100}%`,
|
||||
`${color} ${((index + 1) / ramp.length) * 100}%`,
|
||||
]);
|
||||
return `linear-gradient(90deg, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
/** The Slider emits a pair in range mode; these are all single-thumb. */
|
||||
export function singleSliderValue(value: number | number[]): number {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import { toPanelType } from '../../Panels/types/panelKind';
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
/** The edited kind's definition — drives supported query types, the signals the
|
||||
* builder may be pointed at, and how it narrows the builder's fields. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
isLoadingQueries: boolean;
|
||||
@@ -98,6 +99,7 @@ function PanelEditorQueryBuilder({
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
fieldsConfig={panelDefinition.queryBuilderFields}
|
||||
allowedDataSources={panelDefinition.supportedSignals}
|
||||
showTraceOperator={!isRawQuery}
|
||||
version="v3"
|
||||
isRawQuery={isRawQuery}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user