Compare commits

..

25 Commits

Author SHA1 Message Date
Abhi Kumar
e72b40b144 feat(dashboards): follow linear bucketing on the heatmap's auto axis
Evenly spaced bounds on a log row layout squeeze the whole axis into the
bottom of the panel. Read off the request rather than inferred from the
bound spacing: a zero-headed histogram is arithmetically spaced too and
deliberately keeps its log axis.

Assisted-by: Claude Opus 5
2026-09-21 17:43:04 +05:30
Abhi Kumar
cd4791bc67 fix(dashboards): stop the heatmap axis above its data
A bucket layout routinely runs decades past anything observed, up to
+Inf, and that empty tail took most of the panel. The scale now stops one
row above the highest occupied one, as a time-series axis stops just
above its highest point. Only max and the ticks above it move, so a
cropped-away +Inf row is never relabelled as the bucket below it.

Assisted-by: Claude Opus 5
2026-09-21 17:43:04 +05:30
Abhi Kumar
bdf991964b fix(dashboards): draw the heatmap's overflow row taller
At one bucket's height the dashed edge below +Inf read as the panel's
border rather than as the end of the scale.

Assisted-by: Claude Opus 5
2026-09-21 17:42:29 +05:30
Abhi Kumar
dc0da3eee7 feat(dashboards): break a heatmap cell down by share
Names the two columns the contribution list holds and gives each group
its percentage of the cell. A share that rounds to 0% but is not shows
as <1%.

Assisted-by: Claude Opus 5
2026-09-21 17:42:04 +05:30
Abhi Kumar
41c0a3754c feat(dashboards): order the heatmap legend by contribution
Response order said nothing to a reader, and every swatch carries the
same colour, so the list had no apparent order at all. Ties break
alphabetically to keep it stable across refetches.

Assisted-by: Claude Opus 5
2026-09-21 17:42:04 +05:30
Abhi Kumar
4c3469a5ca fix(dashboards): key a heatmap gap only when there is one
The colour bar always showed both a "no data" and a "count 0" key. The
latter duplicated the bar's own minimum label, and the former claimed a
gap the grid often did not have.

Assisted-by: Claude Opus 5
2026-09-21 17:41:36 +05:30
Abhi Kumar
23cf713be4 fix(dashboards): sink an empty heatmap cell into the panel
Zero now takes the panel surface instead of the ramp's bottom colour.
Darkening the ramp itself would have hidden real data: on a log scale a
count of zero and the smallest count that did occur both normalise to
position 0, so the floor is left to the latter.

Assisted-by: Claude Opus 5
2026-09-21 17:41:03 +05:30
Abhi Kumar
9be4db51bd fix(query-builder): name the default in the bucket-count placeholder
A bare "60" read as a value to clear before typing.

Assisted-by: Claude Opus 5
2026-09-21 17:40:56 +05:30
Abhi Kumar
bb9c3fc095 fix(query-builder): stop the bucket bounds reading as controls
The bounds are derived from the toggles above, but shared their filled
pill, so they looked pressable. The kind hint moves behind an info icon
on the Bounds label — it is reference material, not a caption.

Assisted-by: Claude Opus 5
2026-09-21 17:40:41 +05:30
Abhi Kumar
a093eb041a feat(query-builder): pick a log bucket axis by its scale
The toggle offered bands-per-doubling (1…16) and converted to the scale
the request takes. It now offers the scale directly, across the full
-4…4 the API already validates, and the bounds strip below says what a
given one means.

Assisted-by: Claude Opus 5
2026-09-21 17:40:12 +05:30
Abhi Kumar
308b05ec3b feat(dashboards): give the sqrt colour scale a glyph
It sat between two segments that had one.

Assisted-by: Claude Opus 5
2026-09-21 17:33:27 +05:30
Abhi Kumar
64193f0376 feat(dashboards): show the heatmap's colour controls first
The ramp is what the grid is read through, so it leads the config pane.

Assisted-by: Claude Opus 5
2026-09-21 17:33:19 +05:30
Abhi Kumar
f0e7663d85 fix(query-builder): centre a unit label against its input
Typography.Text resolves its display from --typography-text-display; left
at the `inline` default the label blockified as a flex item and its text
sat at the top of the row rather than centred.

Assisted-by: Claude Opus 5
2026-09-21 17:33:17 +05:30
Abhi Kumar
8a4965d1fb fix(dashboards): centre a select option's icon against its label
An inline-flex box takes its baseline from the icon's bottom edge, which
lifted every panel-type glyph a few px above the text.

Assisted-by: Claude Opus 5
2026-09-21 17:33:06 +05:30
Abhi Kumar
1041801cc1 fix(dashboards): give the heatmap its own panel icon
Grid3X3 was near-indistinguishable from the table panel's icon.

Assisted-by: Claude Opus 5
2026-09-21 17:32:43 +05:30
Abhi Kumar
0ec5756645 fix(query-builder): aggregate a histogram heatmap by count
The le labels are the axis, so every percentile draws what count draws. Leaving a heatmap
restores a percentile; entering one keeps a single enabled query.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:13 +05:30
Abhi Kumar
939a244a18 feat(query-builder): let a heatmap query pick its bucket axis
Request-level on the wire, edited beside the one query a heatmap draws, which is the formula
when its inputs are disabled. Hidden for a histogram metric, whose buckets are its own.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:10 +05:30
Abhi Kumar
688dba6e13 fix(dashboards): offer only the panel kind's signals in the query builder
Assisted-by: Claude Fable 5.1
2026-09-18 14:32:07 +05:30
Abhi Kumar
b051e14643 feat(dashboards): add the heatmap panel kind
Metrics only, heatmap request type, and a bucketed step interval so a wide range stays a
readable number of columns.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:04 +05:30
Abhi Kumar
b72456f935 feat(dashboards): add the heatmap's axis and colour controls
A zero step count is read as unset: the wire type is a plain int.

Assisted-by: Claude Fable 5.1
2026-09-18 14:32:00 +05:30
Abhi Kumar
a10be0d95d fix(dashboards): derive the metric y-axis unit instead of storing it
A state-and-effect pair over an array rebuilt every render schedules an update per render.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:57 +05:30
Abhi Kumar
73373f654f feat(dashboards): let the heatmap pick its axis scale and colour floor from the data
Auto chooses log, symlog or linear from the bucket boundaries. The log colour scale floors
at the smallest count rather than 1. Hover identity includes the count, so a refetch under a
still cursor redraws, and the overlay is clipped to the plot area.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:54 +05:30
Abhi Kumar
30bc4411d5 fix(dashboards): align the heatmap tooltip with the other chart tooltips
Assisted-by: Claude Fable 5.1
2026-09-18 14:31:15 +05:30
Abhi Kumar
90a55ce72d feat(dashboards): register the heatmap panel type
V2-only: the V1 maps carry it as null so the V2 registry renders it.

Assisted-by: Claude Fable 5.1
2026-09-18 14:31:12 +05:30
Abhi Kumar
67afca0016 chore(api): regenerate the client for the heatmap panel spec
Assisted-by: Claude Fable 5.1
2026-09-18 14:31:09 +05:30
100 changed files with 5465 additions and 435 deletions

View File

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

View File

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

View File

@@ -5,6 +5,10 @@
border-radius: 2px 0px 0px 2px;
.label {
// Typography.Text takes its display from this token; at its `inline` default
// the label blockifies as a flex item and the text rides the top of the row.
--typography-text-display: flex;
font-size: 12px;
font-style: normal;
font-weight: 500;

View File

@@ -0,0 +1,142 @@
/**
* 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-6);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
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;
}
/** Unfilled: a pill on the same row as the toggles above reads as another control,
* and the bounds are derived rather than set here. */
.bound {
padding: var(--spacing-1) 0;
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
}
.overflowBound {
color: var(--l3-foreground);
}
.muted {
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
cursor: help;
color: var(--l3-foreground);
}
.closeBtn {
margin-left: auto;
width: 36px;
height: 36px;
}

View File

@@ -0,0 +1,269 @@
import { useCallback, useMemo, useState } from 'react';
import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { InputNumber } from '@signozhq/ui/input-number';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ChevronUp, Info } from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import {
BUCKET_KIND_HINTS,
BUCKET_KIND_OPTIONS,
DEFAULT_NUM_BUCKETS,
LOG_SCALE_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
BucketKindOption,
formatUpperBound,
hasBoundsBeyondPreview,
isLinearBuckets,
kindOptionOf,
linearBuckets,
logBuckets,
logScaleOf,
previewUpperBounds,
} 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 handleScaleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = 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 scaleItems = useMemo(
() =>
LOG_SCALE_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}>Scale</span>
<ToggleGroupSimple
type="single"
value={String(logScale)}
items={scaleItems}
onChange={handleScaleChange}
testId="bucket-options-scale"
/>
</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={`Default ${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
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
<Info size={12} className={styles.hintIcon} />
</Tooltip>
</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>
</div>
);
}
BucketOptions.defaultProps = {
bucketOptions: undefined,
unit: undefined,
onClose: undefined,
};
export default BucketOptions;

View File

@@ -0,0 +1,88 @@
import { MAX_LOG_SCALE } from '../constants';
import {
bandsPerDoublingFromScale,
formatUpperBound,
hasBoundsBeyondPreview,
kindOptionOf,
linearBuckets,
logBuckets,
previewUpperBounds,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[-2, 0.25],
[0, 1],
[1, 2],
[2, 4],
[3, 8],
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
});
});
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');
});
});

View File

@@ -0,0 +1,49 @@
/**
* 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;
/** One band per 16x, the coarsest axis worth rendering. */
export const MIN_LOG_SCALE = -4;
export const MAX_NUM_BUCKETS = 512;
export const DEFAULT_NUM_BUCKETS = 60;
/** Every scale the request accepts, coarsest first. The bounds strip below says
* how coarse a given one is, so the numbers stand alone. */
export const LOG_SCALES = Array.from(
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
(_, index) => MIN_LOG_SCALE + index,
);
/** 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_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
value: String(scale),
label: String(scale),
}));
export const BUCKET_KIND_HINTS = {
auto:
'Bounds are picked for you: a log axis at scale 4, 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. A lower scale 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.',
};

View File

@@ -0,0 +1,128 @@
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;
/** A log axis places `2^scale` bounds per doubling. */
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
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();
}

View File

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

View File

@@ -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,170 @@ 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: '0' })).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 picked scale', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
it('offers the coarser scales the request accepts below one band per doubling', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: '-4' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: -4 },
});
});
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();
});
});
});

View File

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

View File

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

View File

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

View File

@@ -338,6 +338,7 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

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

View File

@@ -467,6 +467,7 @@ describe('Footer utils', () => {
timeAggregation: 'avg',
},
],
bucketOptions: undefined,
disabled: false,
filter: {
expression: '',

View File

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

View File

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

View File

@@ -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: '0' })).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 },
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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
? []

View File

@@ -11,7 +11,7 @@
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--text-vanilla-400);
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
@@ -29,7 +29,9 @@
bottom: -3px;
width: 2px;
transform: translateX(-1px);
background: var(--text-vanilla-100);
// 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;
}
@@ -38,7 +40,7 @@
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-vanilla-400);
color: var(--muted-foreground);
}
.keys {
@@ -53,23 +55,20 @@
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--text-vanilla-400);
color: var(--muted-foreground);
}
.swatch,
// Approximates the canvas hatch painted over null cells, which `createHatchPattern`
// strokes in the theme's own direction — light on dark, dark on light.
.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.
.hatchSwatch {
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--text-vanilla-400) 2px 3px
var(--muted-foreground) 2px 3px
);
}

View File

@@ -12,9 +12,8 @@ export interface ColorBarProps {
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;
/** Keys the one state a ramp cannot express: a hatched data gap. */
showNoDataKey?: boolean;
'data-testid'?: string;
}
@@ -25,7 +24,7 @@ export default function ColorBar({
maxLabel,
markerPosition = null,
label,
showStateKeys = true,
showNoDataKey = false,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
@@ -64,16 +63,12 @@ export default function ColorBar({
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showStateKeys && (
{showNoDataKey && (
<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>

View File

@@ -46,26 +46,17 @@ describe('ColorBar', () => {
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" />);
it('keys the one state a colour ramp cannot express', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
);
expect(screen.getByText('no data')).toBeInTheDocument();
expect(screen.getByText('count 0')).toBeInTheDocument();
});
it('draws the count-0 key with the bottom of the ramp', () => {
it('leaves the no-data key out unless the data has a gap', () => {
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();
});

View File

@@ -13,15 +13,16 @@ export default function HeatmapBucketList({
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
{rows.map((row) => (
{rows.map((bucket) => (
<div
key={row.label}
className={cx(Styles.row, { [Styles.rowHovered]: row.isHovered })}
data-hovered={row.isHovered}
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}>{row.label}</span>
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowSeparator} />
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>

View File

@@ -1,6 +1,6 @@
import {
formatCount,
formatPercent,
formatShare,
HeatmapContributionRow,
} from './heatmapTooltipContent';
@@ -9,15 +9,15 @@ import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
groupByLabel,
}: {
rows: HeatmapContributionRow[];
/** The `groupBy` keys these rows are by. */
groupByLabel: string;
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
{groupByLabel && <span className={Styles.section}>{groupByLabel}</span>}
<div className={Styles.columnHeader}>
<span>Group</span>
<span>Contribution</span>
</div>
{rows.map((row) => (
<div
key={row.label}
@@ -26,12 +26,18 @@ export default function HeatmapContributionList({
>
<span
className={Styles.marker}
style={{ background: row.color }}
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>
<span className={Styles.rowPercent}>{formatPercent(row.percent)}</span>
<span
className={Styles.rowShare}
data-testid="heatmap-tooltip-contribution-share"
>
{formatShare(row.share)}
</span>
</div>
))}
</div>

View File

@@ -1,10 +1,18 @@
@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).
// 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);
@@ -32,8 +40,6 @@
.identity {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--spacing-4) var(--spacing-3);
}
.header {
@@ -41,29 +47,36 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
font-size: 11px;
color: var(--text-vanilla-400);
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: 5px;
gap: var(--spacing-2);
min-width: 0;
padding: var(--spacing-3) var(--gutter) 0;
}
// Hollow ring, matching the legend's unselected marker — this names the filter the
// grid is under, it is not a colour key.
// Names the group the grid is under, as the legend draws a shown series; it is
// not a colour key.
.filterMarker {
width: 9px;
height: 9px;
border-radius: 50%;
border: 2px solid currentColor;
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;
@@ -71,59 +84,76 @@
.title {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
gap: var(--spacing-2);
padding: var(--spacing-4) var(--gutter);
}
.titleBucket {
font-size: 13px;
font-weight: 600;
color: var(--text-vanilla-100);
.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-size: 13px;
font-weight: 600;
color: var(--text-vanilla-100);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
padding: var(--spacing-3) var(--spacing-4);
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;
}
.section {
.columnHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: 0 var(--row-inset) var(--spacing-2);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-vanilla-400);
padding: 0 var(--spacing-2) var(--spacing-1);
color: var(--muted-foreground);
}
.row {
display: flex;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-1) var(--spacing-2);
border-radius: 3px;
font-size: 12px;
color: var(--text-vanilla-400);
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);
color: var(--text-vanilla-100);
font-weight: 500;
font-weight: 700;
}
.rowLabel,
.rowValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -133,19 +163,38 @@
.rowValue {
flex: 0 0 auto;
text-align: right;
white-space: nowrap;
}
.rowPercent {
.rowShare {
flex: 0 0 auto;
min-width: 40px;
min-width: 34px;
text-align: right;
color: var(--text-vanilla-400);
opacity: 0.75;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
color: var(--muted-foreground);
}
.marker {
width: 8px;
height: 8px;
border-radius: 50%;
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;
}

View File

@@ -7,6 +7,7 @@ import {
import { useTimezone } from 'providers/Timezone';
import { HeatmapTooltipProps } from '../../../types';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
@@ -15,9 +16,7 @@ import {
formatBucketLabel,
formatColumnRange,
formatCount,
formatGroupFilter,
HeatmapTooltipBody,
resolveGroupByLabel,
resolveTooltipBody,
} from './heatmapTooltipContent';
@@ -120,7 +119,7 @@ export default function HeatmapTooltip({
// 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 = formatGroupFilter(isolated);
const filterLabel = isolated?.label ?? '';
return (
<div
@@ -137,18 +136,20 @@ export default function HeatmapTooltip({
timezone: resolvedTimezone,
})}
</span>
{filterLabel && (
<span
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</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({
@@ -158,6 +159,7 @@ export default function HeatmapTooltip({
decimalPrecision,
})}
</span>
<span className={Styles.titleSeparator} />
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
@@ -167,10 +169,7 @@ export default function HeatmapTooltip({
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList
rows={contributionRows}
groupByLabel={resolveGroupByLabel(series)}
/>
<HeatmapContributionList rows={contributionRows} />
) : (
<HeatmapBucketList rows={bucketRows} />
)}

View File

@@ -23,8 +23,7 @@ function seriesFor(
countsAtHoveredRow: [number, number],
): HeatmapSeries {
return {
label: `service.name=${group}`,
labels: [{ key: 'service.name', value: group }],
label: `{service.name="${group}"}`,
points: TIMESTAMPS.map((timestamp, column) => ({
timestamp,
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
@@ -100,7 +99,7 @@ describe('HeatmapTooltip — cell identity', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
/^\d{2}:\d{2} → \d{2}:\d{2}$/,
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
);
});
@@ -122,6 +121,18 @@ describe('HeatmapTooltip — cell identity', () => {
);
});
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();
@@ -180,13 +191,7 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
).not.toBeInTheDocument();
});
it('heads the breakdown with the groupBy key', () => {
renderTooltip();
expect(screen.getByText('service.name')).toBeInTheDocument();
});
it('names each row by value alone and orders by contribution', () => {
it('names each row as the legend does and orders by contribution', () => {
renderTooltip();
const rows = screen
@@ -199,23 +204,34 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
expect(rows[2]).toContain('cart');
});
it('shows each group"s share of the cell', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
// 355 / 455 = 78%, 86 / 455 = 19%, 14 / 455 = 3.1%
expect(rows[0]).toHaveTextContent('78%');
expect(rows[1]).toHaveTextContent('19%');
expect(rows[2]).toHaveTextContent('3.1%');
});
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.0%');
expect(rows[3]).toHaveTextContent('0');
});
it('names what the two columns hold', () => {
renderTooltip();
expect(screen.getByText('Group')).toBeInTheDocument();
expect(screen.getByText('Contribution')).toBeInTheDocument();
});
it('gives each group its share of the cell alongside the count', () => {
renderTooltip();
const shares = screen
.getAllByTestId('heatmap-tooltip-contribution-share')
.map((share) => share.textContent);
expect(shares).toHaveLength(GROUPED.length);
expect(
shares.reduce((sum, share) => sum + parseInt(share ?? '0', 10), 0),
).toBe(100);
expect(shares[shares.length - 1]).toBe('0%');
});
it('does not name a filter when every group is enabled', () => {
@@ -228,7 +244,7 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
});
describe('HeatmapTooltip — grouped, one enabled', () => {
const selected = { visibleGroups: ['service.name=checkout'] };
const selected = { visibleGroups: ['{service.name="checkout"}'] };
it('returns to neighbouring buckets, since contribution is already answered', () => {
renderTooltip(selected);
@@ -243,7 +259,7 @@ describe('HeatmapTooltip — grouped, one enabled', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
'service.name = checkout',
'{service.name="checkout"}',
);
});
});

View File

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

View File

@@ -2,16 +2,19 @@ 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 share a percentage needs a decimal to stay informative. */
const PERCENT_DECIMAL_THRESHOLD = 10;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
@@ -28,6 +31,9 @@ export enum HeatmapTooltipBody {
}
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;
@@ -37,8 +43,8 @@ export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Share of the cell's total, 0..100. */
percent: number;
/** Fraction of the cell this group contributed, 0..1. */
share: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
@@ -49,8 +55,9 @@ export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport which observations
* it contains. The date is left to the x axis directly below. */
/** 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,
@@ -62,13 +69,15 @@ export function formatColumnRange({
step: number;
timezone: string;
}): string {
const format =
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);
return `${from.format(format)}${to.format(format)}`;
const toFormat = to.isSame(from, 'day') ? time : dated;
return `${from.format(dated)}${to.format(toFormat)}`;
}
/** Formatted with the panel's unit. */
@@ -96,36 +105,13 @@ export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
export function formatPercent(percent: number): string {
return percent >= PERCENT_DECIMAL_THRESHOLD
? `${Math.round(percent)}%`
: `${percent.toFixed(1)}%`;
}
/** Names the group the grid is currently isolated to. */
export function formatGroupFilter(series: HeatmapSeries | undefined): string {
if (!series) {
return '';
/** `<1%` rather than `0%`, so a group that did contribute never reads as one
* that didn't. */
export function formatShare(share: number): string {
if (share > 0 && share < 0.005) {
return '<1%';
}
if (!series.labels?.length) {
return series.label;
}
return series.labels
.map((label) => `${label.key} = ${label.value}`)
.join(', ');
}
/** The `groupBy` keys the breakdown is by. */
export function resolveGroupByLabel(series: HeatmapSeries[]): string {
const keys = series[0]?.labels?.map((label) => label.key) ?? [];
return keys.join(', ');
}
function formatSeriesValue(series: HeatmapSeries): string {
if (!series.labels?.length) {
return series.label;
}
return series.labels.map((label) => label.value).join(', ');
return `${Math.round(share * 100)}%`;
}
/** Highest first, so the list reads in the same direction as the y axis. */
@@ -156,6 +142,7 @@ export function buildBucketRows({
continue;
}
rows.push({
row: index,
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
@@ -179,21 +166,25 @@ export function buildContributionRows({
/** Column start, in seconds. */
timestamp: number;
row: number;
/** The grid's one colour; there is no per-series hue. */
color: string;
}): HeatmapContributionRow[] {
const counts = series.map((entry) => {
const counted = series.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
// Absent or null contributed nothing to the sum, which is what this breaks down.
return point?.counts[row] ?? 0;
});
const total = counts.reduce((sum, count) => sum + count, 0);
return series
.map((entry, index) => ({
label: formatSeriesValue(entry),
return {
label: entry.label,
color,
count: counts[index],
percent: total > 0 ? (counts[index] / total) * 100 : 0,
// Absent or null contributed nothing to the sum this breaks down.
count: point?.counts[row] ?? 0,
};
});
const total = counted.reduce((sum, entry) => sum + entry.count, 0);
return counted
.map((entry) => ({
...entry,
share: total > 0 ? entry.count / total : 0,
}))
.sort((a, b) => b.count - a.count);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,12 @@
import { Color } from '@signozhq/design-tokens';
import {
clampColorSteps,
createHeatmapColorResolver,
DEFAULT_COLOR_STEPS,
DEFAULT_HEATMAP_COLORS,
getMaxCount,
getSmallestPositiveCount,
MAX_COLOR_STEPS,
MIN_OPACITY_ALPHA,
normalizeCount,
@@ -33,6 +36,21 @@ describe('getMaxCount', () => {
});
});
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(
@@ -40,6 +58,7 @@ describe('resolveCountDomain', () => {
).toStrictEqual({
min: 0,
max: 20,
logFloor: 5,
});
});
@@ -49,6 +68,7 @@ describe('resolveCountDomain', () => {
).toStrictEqual({
min: 10,
max: 100,
logFloor: 5,
});
});
@@ -58,12 +78,32 @@ describe('resolveCountDomain', () => {
).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 };
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 =>
@@ -99,7 +139,7 @@ describe('normalizeCount', () => {
expect(
normalizeCount({
count: 250,
domain: { min: 0, max: 1000 },
domain: { min: 0, max: 1000, logFloor: 1 },
scale: HeatmapColorScale.Sqrt,
}),
).toBeCloseTo(0.5, 6);
@@ -115,20 +155,43 @@ describe('normalizeCount', () => {
expect(
normalizeCount({
count: 7,
domain: { min: 7, max: 7 },
domain: { min: 7, max: 7, logFloor: 1 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
it('handles a log domain whose min and max share a decade floor', () => {
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: { min: 0, max: 1 },
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(0);
).toBe(1);
expect(
normalizeCount({
count: 0.5,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(0.5);
});
});
@@ -151,7 +214,7 @@ describe('createHeatmapColorResolver', () => {
): ReturnType<typeof createHeatmapColorResolver> =>
createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
domain: { min: 0, max: 1000 },
domain: { min: 0, max: 1000, logFloor: 1 },
isDarkMode,
seriesColor: SERIES_COLOR,
});
@@ -163,9 +226,29 @@ describe('createHeatmapColorResolver', () => {
expect(resolver.positionOf(null)).toBeNull();
});
it('gives a zero count the bottom colour, not the null treatment', () => {
it('recedes a zero count into the surface, not the null treatment', () => {
const dark = build();
const light = build({}, false);
expect(dark.colorFor(0)).toBe(Color.BG_INK_500);
expect(light.colorFor(0)).toBe(Color.BG_VANILLA_100);
});
it('keeps the bottom of the ramp for the smallest count that occurred', () => {
const resolver = build();
// Zero and the floor both sit at position 0; only the floor takes a colour.
expect(resolver.colorFor(1)).toBe(resolver.ramp[0]);
});
it('colours zero off the ramp once an explicit minimum lifts it off the bottom', () => {
const resolver = createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, minCount: 5 },
domain: { min: 5, max: 1000, logFloor: 5 },
isDarkMode: true,
seriesColor: SERIES_COLOR,
});
expect(resolver.colorFor(0)).toBe(resolver.ramp[0]);
});
@@ -202,6 +285,6 @@ describe('createHeatmapColorResolver', () => {
});
it('reports the domain it applied', () => {
expect(build().domain).toStrictEqual({ min: 0, max: 1000 });
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
});
});

View File

@@ -1,7 +1,9 @@
import {
canUseLogAxis,
cropHeatmapYAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
resolveColumnIndex,
resolveHeatmapYAxis,
resolveRowIndex,
@@ -57,9 +59,10 @@ describe('resolveHeatmapYAxis', () => {
);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
// Outer edges extend by the geometric mean ratio, (4096/128)^(1/3) = 3.174…
// Geometric mean ratio (4096/128)^(1/3) = 3.174…, two of them for the
// overflow row.
expect(10 ** min).toBeCloseTo(128 / (4096 / 128) ** (1 / 3), 6);
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (1 / 3), 6);
expect(10 ** max).toBeCloseTo(4096 * (4096 / 128) ** (2 / 3), 6);
});
it('keeps bounds in value space on a linear axis', () => {
@@ -254,7 +257,7 @@ describe('decimateAxisSplits', () => {
});
});
describe('resolveHeatmapYAxis — symmetric log', () => {
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,
@@ -264,10 +267,10 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
const PLOT_HEIGHT = 250;
/** Row heights in axis units, which map linearly to pixels. */
/** Row heights in axis units, less the deliberately taller overflow row. */
function rowHeights(bounds: number[]): number[] {
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Log);
return edges.slice(1).map((edge, index) => edge - edges[index]);
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
@@ -281,25 +284,46 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
}
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Log);
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 0100ms buckets — where the data is — under a pixel.
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Log)).toBeGreaterThan(4);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
});
it('gives the zero-crossing row a full decade, since it cannot be compressed', () => {
it('gives the row above zero one bucket of height, not a whole decade', () => {
const heights = rowHeights(OTEL);
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log);
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);
// One axis unit — the same space a decade gets above the threshold.
expect(heights[nearZero]).toBeCloseTo(1, 6);
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', () => {
@@ -308,8 +332,15 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('draws the overflow row taller than a bucket, so its edge reads as one', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
const overflowHeight = edges[edges.length - 1] - edges[edges.length - 2];
expect(overflowHeight).toBeGreaterThan(Math.max(...rowHeights(SKEW)));
});
it('keeps negative boundaries ascending', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
@@ -317,7 +348,7 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
it('round-trips a boundary back to its bucket value', () => {
const { splits, toBucketValue } = resolveHeatmapYAxis(
SKEW,
HeatmapAxisScale.Log,
HeatmapAxisScale.Auto,
);
expect(
@@ -329,7 +360,7 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
const { edges, rows } = resolveHeatmapYAxis(
[-100, -10, 0, 10, 100],
HeatmapAxisScale.Log,
HeatmapAxisScale.Auto,
);
const crossing = rows.findIndex(
(row) => row.lower === -10 && row.upper === 0,
@@ -342,15 +373,334 @@ describe('resolveHeatmapYAxis — symmetric log', () => {
it('leaves an all-positive layout on a plain log axis', () => {
const { splits } = resolveHeatmapYAxis(
[128, 256, 1024],
HeatmapAxisScale.Log,
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.Log);
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, less the deliberately taller overflow row. */
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
const { edges } = resolveHeatmapYAxis(bounds, scale);
return edges.slice(1, -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('cropHeatmapYAxis', () => {
// Four bounds, so five rows: underflow, three buckets, overflow.
const AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const counts = (perRow: Array<number | null>): Array<Array<number | null>> =>
perRow.map((count) => [count]);
it('stops one row above the highest occupied one', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.max).toBe(AXIS.edges[3]);
expect(cropped.splits).toStrictEqual(AXIS.splits.slice(0, 3));
});
it('drops the infinity tick once its row is off the top', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.overflowSplit).toBeNull();
});
it('keeps the rows intact, so a bucket is never relabelled', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.rows).toStrictEqual(AXIS.rows);
expect(cropped.edges).toStrictEqual(AXIS.edges);
});
it('leaves the axis alone when the top row is occupied', () => {
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 0, 2]))).toStrictEqual(AXIS);
});
it('leaves the axis alone when the row below the top is occupied', () => {
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 9, 0]))).toStrictEqual(AXIS);
});
it('treats zero and no-data cells alike — neither occupies a row', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([4, 0, null, null, null]));
expect(cropped.max).toBe(AXIS.edges[2]);
});
it('leaves an entirely empty grid alone, having nothing to crop against', () => {
expect(
cropHeatmapYAxis(AXIS, counts([null, null, null, null, null])),
).toStrictEqual(AXIS);
});
});
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([]);
});
});

View File

@@ -86,6 +86,16 @@ function createFakePlot(cursor: { left: number; top: number }): FakePlot {
return { plot: plot as unknown as uPlot, context, setSeries, over };
}
/** Cursor `top` at the centre of `row`, so naming a cell does not also mean
* knowing how tall the rows are. */
function topOfRow(row: number): number {
const centre = (Y_AXIS.edges[row] + Y_AXIS.edges[row + 1]) / 2;
return (
PLOT_HEIGHT -
((centre - Y_AXIS.min) / (Y_AXIS.max - Y_AXIS.min)) * PLOT_HEIGHT
);
}
function createHooks(
onHoverChange?: (cell: HeatmapCell | null) => void,
dimOnHover = true,
@@ -190,7 +200,7 @@ describe('heatmap renderer — hover', () => {
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({
left: PLOT_WIDTH / 2,
top: PLOT_HEIGHT / 2,
top: topOfRow(1),
});
hooks.init(plot);
@@ -233,6 +243,21 @@ describe('heatmap renderer — hover', () => {
});
});
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 });

View File

@@ -16,9 +16,22 @@ 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;
/** Zero takes the panel surface so an empty stretch recedes, leaving the ramp's
* bottom to the smallest count that did occur — on a log scale the two collapse. */
const ZERO_FILL: Record<'dark' | 'light', string> = {
dark: DesignToken.BG_INK_500,
light: DesignToken.BG_VANILLA_100,
};
export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
mode: HeatmapColorMode.Palette,
scale: HeatmapColorScale.Log,
@@ -32,6 +45,9 @@ export const DEFAULT_HEATMAP_COLORS: HeatmapColorOptions = {
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. */
@@ -47,6 +63,40 @@ export function getMaxCount(counts: Array<Array<number | null>>): number {
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'>,
@@ -54,7 +104,8 @@ export function resolveCountDomain(
): CountDomain {
const min = options.minCount ?? 0;
const max = options.maxCount ?? getMaxCount(counts);
return max > min ? { min, max } : { min, max: min };
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
@@ -76,13 +127,15 @@ export function normalizeCount({
const clamped = Math.min(Math.max(count, min), max);
if (scale === HeatmapColorScale.Log) {
// 0 and 1 both sit at the bottom; log of either is meaningless.
const logMin = Math.log10(Math.max(min, 1));
const logMax = Math.log10(Math.max(max, 1));
if (!(logMax > logMin)) {
return 0;
// 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);
}
return (Math.log10(Math.max(clamped, 1)) - 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);
@@ -182,9 +235,15 @@ export function createHeatmapColorResolver({
return normalizeCount({ count, domain, scale: options.scale });
};
// An explicit `minCount` above zero makes it a clamped value like any other.
const zeroFill = domain.min <= 0 ? ZERO_FILL[isDarkMode ? 'dark' : 'light'] : null;
return {
positionOf,
colorFor: (count): string | null => {
if (count === 0 && zeroFill !== null) {
return zeroFill;
}
const t = positionOf(count);
if (t === null) {
return null;

View File

@@ -3,6 +3,10 @@ import { HeatmapAxisScale, HeatmapRow, HeatmapYAxis } from './types';
/** Used when the ratio cannot be inferred, i.e. a single boundary. */
const FALLBACK_LOG_RATIO = 2;
/** Taller than a real bucket, so the dashed edge below it reads as the end of the
* scale and not as the panel's border. */
const OVERFLOW_ROW_HEIGHT_RATIO = 2;
const EMPTY_Y_AXIS: HeatmapYAxis = {
rows: [],
edges: [],
@@ -80,23 +84,69 @@ function createSymlogTransform(threshold: number): AxisTransform {
};
}
function resolveAxisTransform(
bounds: number[],
scale: HeatmapAxisScale,
): AxisTransform {
if (scale !== HeatmapAxisScale.Log) {
return LINEAR_TRANSFORM;
}
if (canUseLogAxis(bounds)) {
return LOG_TRANSFORM;
}
// All-zero bounds have no magnitude to scale against.
/** 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
@@ -115,7 +165,7 @@ function resolveOuterEdges(
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 };
return { lower, upper: last + safeGap * OVERFLOW_ROW_HEIGHT_RATIO };
}
const axisFirst = transform.toAxisValue(first);
@@ -127,7 +177,9 @@ function resolveOuterEdges(
return {
lower: transform.toBucketValue(axisFirst - safeGap),
upper: transform.toBucketValue(axisLast + safeGap),
upper: transform.toBucketValue(
axisLast + safeGap * OVERFLOW_ROW_HEIGHT_RATIO,
),
};
}
@@ -179,6 +231,48 @@ export function resolveHeatmapYAxis(
};
}
/** Highest row holding a count above zero; `null` for a grid with none. */
function resolveTopOccupiedRow(
counts: Array<Array<number | null>>,
): number | null {
for (let row = counts.length - 1; row >= 0; row -= 1) {
if (counts[row]?.some((count) => count !== null && count > 0)) {
return row;
}
}
return null;
}
/**
* Stops the axis one row above the highest occupied one: a bucket layout routinely
* runs decades past anything observed, and that tail squeezes the rows carrying the
* distribution. Only `max` and the ticks above it move, so a cropped-away `+Inf` row
* is never relabelled as the one below it.
*/
export function cropHeatmapYAxis(
yAxis: HeatmapYAxis,
counts: Array<Array<number | null>>,
): HeatmapYAxis {
const topOccupied = resolveTopOccupiedRow(counts);
if (topOccupied === null) {
return yAxis;
}
const topVisible = Math.min(topOccupied + 1, yAxis.rows.length - 1);
if (topVisible >= yAxis.rows.length - 1) {
return yAxis;
}
return {
...yAxis,
// `splits[i]` is row `i`'s upper edge, so truncating keeps the caller's
// index-keyed labels aligned.
splits: yAxis.splits.slice(0, topVisible + 1),
overflowSplit: null,
max: yAxis.edges[topVisible + 1],
};
}
/** Row containing `axisValue`, or `null` when it falls outside the grid. */
export function resolveRowIndex(
edges: number[],
@@ -295,3 +389,149 @@ export function decimateAxisSplits({
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;
}

View File

@@ -7,28 +7,6 @@ const EMPTY_GRID: HeatmapGrid = {
counts: [],
};
/**
* Highest single-cell count each group reaches. Read against the same domain the
* colour bar uses, this is where a group sits on that bar.
*/
export function resolveGroupPeaks(
series: HeatmapSeries[],
): Map<string, number> {
const peaks = new Map<string, number>();
series.forEach((entry) => {
let peak = 0;
entry.points.forEach((point) =>
point.counts.forEach((count) => {
if (count !== null && count > peak) {
peak = count;
}
}),
);
peaks.set(entry.label, peak);
});
return peaks;
}
/** Groups the legend currently has enabled. `undefined` means all of them. */
function resolveVisible(
series: HeatmapSeries[],

View File

@@ -130,16 +130,19 @@ export function createHeatmapHooks({
clearHover(u);
return;
}
if (hovered?.row === row && hovered?.column === column) {
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:
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null,
};
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.
@@ -161,10 +164,15 @@ export function createHeatmapHooks({
destroy: (): void => {
overlay?.container.remove();
overlay = null;
hovered = 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);
}
},
};
}

View File

@@ -40,6 +40,10 @@ 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 = `${

View File

@@ -112,6 +112,11 @@ export function drawOverflowBoundary({
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
return;
}
// A cropped axis puts the edge off the plot, where the line would stroke over
// the panel.
if (yAxis.edges[overflowIndex] >= yAxis.max) {
return;
}
const { ctx } = u;
const y = Math.round(u.valToPos(yAxis.edges[overflowIndex], 'y', true));

View File

@@ -41,8 +41,18 @@ export interface HeatmapColorOptions {
/** Row-height distribution of the bucket axis. */
export enum HeatmapAxisScale {
Log = 'log',
/** 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 {
@@ -58,10 +68,9 @@ export interface HeatmapSeriesLabel {
}
export interface HeatmapSeries {
/** Group label, as the legend names it. Empty when there is no grouping. */
/** Group label, as the legend and the tooltip name it. Empty when there is no
* grouping. */
label: string;
/** The pairs behind `label`, letting the tooltip name rows by value alone. */
labels?: HeatmapSeriesLabel[];
points: HeatmapSeriesPoint[];
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
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';
@@ -14,25 +14,39 @@ import {
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
resolveGroupPeaks,
resolveHeatmapGrid,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
cropHeatmapYAxis,
resolveHeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
HeatmapCell,
HeatmapColorMode,
} 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 } from './utils';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} 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
@@ -48,7 +62,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
width,
height,
isDarkMode,
axisScale = HeatmapAxisScale.Log,
axisScale = HeatmapAxisScale.Auto,
yAxisUnit,
decimalPrecision,
timezone,
@@ -72,13 +86,11 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
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]);
// One series has nothing to choose between.
const hasGroupLegend = showLegend && groups.length > 1;
const groups = useMemo(() => resolveGroupOrder(series), [series]);
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
@@ -106,8 +118,15 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
);
const yAxis = useMemo(
() => resolveHeatmapYAxis(grid.bounds, axisScale),
[grid.bounds, axisScale],
() => cropHeatmapYAxis(resolveHeatmapYAxis(grid.bounds, axisScale), grid.counts),
[grid.bounds, grid.counts, 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;
@@ -133,11 +152,37 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
// 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;
setHoveredCell(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({
@@ -150,7 +195,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
dimOnHover,
onHoverChange: handleHoverChange,
yAxisUnit,
decimalPrecision,
decimalPrecision: boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
@@ -166,7 +211,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
dimOnHover,
handleHoverChange,
yAxisUnit,
decimalPrecision,
boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
@@ -174,11 +219,6 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
],
);
// Each marker takes the ramp colour for where that group's densest cell falls on
// the colour bar, so a swatch reads against the same scale as the grid.
const groupPeaks = useMemo(() => resolveGroupPeaks(series), [series]);
const isPaletteMode = colors.mode === HeatmapColorMode.Palette;
const legendItems = useMemo<LegendItem[]>(
() =>
groups.map((group, index) => ({
@@ -186,19 +226,12 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
// handling is identical across charts.
seriesIndex: index + 1,
label: group,
color: isPaletteMode
? (colorResolver.colorFor(groupPeaks.get(group) ?? 0) ?? extremeColor)
: extremeColor,
// 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,
isPaletteMode,
colorResolver,
groupPeaks,
extremeColor,
],
[groups, visibleGroups, extremeColor],
);
const renderTooltip = useCallback(
@@ -212,7 +245,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
visibleGroups={visibleGroups}
groupColor={extremeColor}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
decimalPrecision={boundaryPrecision}
timezone={timezone}
canPinTooltip={canPinTooltip}
renderTooltipFooter={renderTooltipFooter}
@@ -226,7 +259,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
visibleGroups,
extremeColor,
yAxisUnit,
decimalPrecision,
boundaryPrecision,
timezone,
canPinTooltip,
renderTooltipFooter,
@@ -263,9 +296,10 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
minLabel={colorResolver.domain.min.toLocaleString()}
maxLabel={colorResolver.domain.max.toLocaleString()}
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
showNoDataKey={hasMissingCells(grid.counts)}
/>
);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell]);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell, grid.counts]);
return (
<ChartWrapper
@@ -276,7 +310,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={hasGroupLegend}
showLegend={showLegend}
customLegend={groupLegend}
legendLabels={groups}
showTooltip={showTooltip}

View File

@@ -3,11 +3,9 @@ import userEvent from '@testing-library/user-event';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen } from 'tests/test-utils';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapColorMode,
HeatmapSeries,
@@ -193,35 +191,18 @@ describe('Heatmap group legend', () => {
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
});
/** The ramp the cells and colour bar are drawn from, for the default options. */
function activeRamp(): string[] {
const grid = resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: SERIES,
});
return createHeatmapColorResolver({
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,
domain: resolveCountDomain(DEFAULT_HEATMAP_COLORS, grid.counts),
isDarkMode: true,
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
}).ramp.map((color) => color.toLowerCase());
}
}).toLowerCase();
it('places each marker where its group sits on the colour bar', () => {
renderHeatmap();
const ramp = activeRamp();
// cart peaks at 1200, checkout at 4, so cart sits further along the ramp.
// The DOM lowercases hex; the ramp is built uppercase.
const cart = ramp.indexOf(marker(CART).style.borderColor.toLowerCase());
const checkout = ramp.indexOf(
marker(CHECKOUT).style.borderColor.toLowerCase(),
);
expect(cart).toBeGreaterThan(-1);
expect(checkout).toBeGreaterThan(-1);
expect(cart).toBeGreaterThan(checkout);
// 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', () => {
@@ -236,10 +217,10 @@ describe('Heatmap group legend', () => {
expect(marker(CART).style.borderColor).not.toBe('');
});
it('hides the legend when there is only one group to choose from', () => {
it('shows the legend for a single group, which names what the grid plots', () => {
renderHeatmap({ series: [SERIES[0]] });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
expect(screen.getByText(CART)).toBeInTheDocument();
});
it('hides the legend when asked', () => {

View File

@@ -3,10 +3,19 @@ import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorS
import {
HeatmapAxisScale,
HeatmapGrid,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type uPlot from 'uplot';
import { buildHeatmapConfig, prepareHeatmapChartData } from '../utils';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} from '../utils';
const GRID: HeatmapGrid = {
bounds: [128, 256, 1024],
@@ -67,6 +76,67 @@ function buildConfig(
});
}
/** 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);
@@ -193,3 +263,54 @@ describe('buildHeatmapConfig', () => {
expect(hooks?.destroy).toHaveLength(1);
});
});
describe('resolveGroupOrder', () => {
const group = (
label: string,
counts: Array<number | null>,
): HeatmapSeries => ({
label,
points: [{ timestamp: 1000, counts }],
});
it('puts the biggest contributor first', () => {
expect(
resolveGroupOrder([
group('small', [1, 1]),
group('big', [40, 2]),
group('middling', [5, 5]),
]),
).toStrictEqual(['big', 'middling', 'small']);
});
it('sums every column, not just the first', () => {
expect(
resolveGroupOrder([
{ label: 'steady', points: [{ timestamp: 1000, counts: [5] }, { timestamp: 1060, counts: [5] }] },
{ label: 'spike', points: [{ timestamp: 1000, counts: [9] }] },
]),
).toStrictEqual(['steady', 'spike']);
});
it('breaks a tie alphabetically, so the order is stable across refetches', () => {
expect(
resolveGroupOrder([group('b', [1]), group('a', [1])]),
).toStrictEqual(['a', 'b']);
});
it('counts a no-data cell as no contribution', () => {
expect(
resolveGroupOrder([group('gaps', [null, null]), group('counts', [1])]),
).toStrictEqual(['counts', 'gaps']);
});
});
describe('hasMissingCells', () => {
it('finds a gap anywhere in the grid', () => {
expect(hasMissingCells([[1, 2], [3, null]])).toBe(true);
});
it('does not mistake a zero for a gap', () => {
expect(hasMissingCells([[0, 0], [0, 1]])).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
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';
@@ -7,12 +7,17 @@ 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 } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import {
HeatmapGrid,
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import uPlot from 'uplot';
/** Minimum gap between y tick labels, in CSS pixels. */
@@ -21,6 +26,79 @@ 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];
}
function totalCount(entry: HeatmapSeries): number {
let total = 0;
entry.points.forEach((point) => {
point.counts.forEach((count) => {
total += count ?? 0;
});
});
return total;
}
/** Largest contributor first, as the tooltip already breaks a cell down. Response
* order says nothing about a legend where every swatch is the same colour. */
export function resolveGroupOrder(series: HeatmapSeries[]): string[] {
return series
.map((entry) => ({ label: entry.label, total: totalCount(entry) }))
.sort((a, b) => b.total - a.total || a.label.localeCompare(b.label))
.map((entry) => entry.label);
}
/** Whether any cell is a gap rather than a count. */
export function hasMissingCells(counts: Array<Array<number | null>>): boolean {
return counts.some((row) => row.some((count) => count === null));
}
/**
* Flattens the grid into `[timestamps, ...rows]`, one series per bucket row so
* `setData` handles refetches. The series draw nothing; the renderer paints cells.
@@ -114,6 +192,16 @@ export function buildHeatmapConfig({
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

View File

@@ -110,7 +110,8 @@ export interface HeatmapChartProps {
isDarkMode: boolean;
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
colors?: Partial<HeatmapColorOptions>;
/** Default log. */
/** 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;

View File

@@ -8,7 +8,9 @@
}
.item {
display: inline-flex;
// Not inline-flex: that aligns the icon's bottom edge to the row's text baseline,
// lifting the glyph a few px.
display: flex;
align-items: center;
gap: 9px;
}

View File

@@ -13,6 +13,7 @@ export type SegmentIconName =
| 'pos-right'
| 'scale-linear'
| 'scale-log'
| 'scale-sqrt'
| 'interp-linear'
| 'interp-spline'
| 'interp-step-before'
@@ -115,6 +116,12 @@ export function SegmentIcon({
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
</Svg>
);
case 'scale-sqrt':
return (
<Svg>
<path d="M2.5 13 C5.5 11, 8.5 6.5, 13.5 3" />
</Svg>
);
case 'interp-linear':
return (
<Svg>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;s ends mean; empty reads them off the data.
</Typography.Text>
</div>
);
}
export default HeatmapCountRangeField;

View File

@@ -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&apos;s.
</Typography.Text>
</div>
);
}
export default HeatmapFillField;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
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',
icon: 'scale-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;
}

View File

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

View File

@@ -59,6 +59,7 @@ function lastQueryBuilderProps(): {
isRawQuery: boolean;
showTraceOperator: boolean;
fieldsConfig: unknown;
allowedDataSources: string[];
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
@@ -119,6 +120,15 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
expect(props.fieldsConfig).toStrictEqual({});
});
it('hands the fields the Heatmap hides to the builder', () => {
renderBuilder('signoz/HeatmapPanel');
expect(lastQueryBuilderProps().fieldsConfig).toStrictEqual({
functions: { state: 'hidden' },
having: { state: 'hidden' },
});
});
it('marks List raw and leaves the field surface to the raw baseline', () => {
renderBuilder('signoz/ListPanel');
@@ -129,3 +139,38 @@ describe('PanelEditorQueryBuilder field visibility (driven by the capabilities g
expect(props.fieldsConfig).toStrictEqual({});
});
});
describe('PanelEditorQueryBuilder signal dropdown (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
redirectWithQueryBuilderData: jest.fn(),
});
});
it('offers metrics alone for the Heatmap kind — the only signal with a bucket axis', () => {
renderBuilder('signoz/HeatmapPanel');
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual(['metrics']);
});
it('offers logs and traces for the List kind, which reads raw rows', () => {
renderBuilder('signoz/ListPanel');
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual([
'logs',
'traces',
]);
});
it('offers every signal for a kind that visualizes them all', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(lastQueryBuilderProps().allowedDataSources).toStrictEqual([
'metrics',
'logs',
'traces',
]);
});
});

View File

@@ -171,6 +171,151 @@ describe('usePanelTypeSwitch', () => {
});
});
it('keeps one query and drops the formulas when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: {
queryData: [{ queryName: 'A' }, { queryName: 'B' }],
queryFormulas: [{ queryName: 'F1' }],
},
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/HeatmapPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
expect(persisted.builder.queryData.map((qd) => qd.queryName)).toStrictEqual([
'A',
]);
expect(persisted.builder.queryFormulas).toStrictEqual([]);
});
it('swaps a percentile spatial aggregation for count when switching to a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
spaceAggregation: 'p90',
aggregations: [{ metricName: 'signoz_latency', spaceAggregation: 'p90' }],
},
],
queryFormulas: [],
},
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/HeatmapPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
const [queryData] = persisted.builder.queryData;
expect(queryData.spaceAggregation).toBe('count');
expect(queryData.aggregations?.[0]).toStrictEqual({
metricName: 'signoz_latency',
spaceAggregation: 'count',
});
});
it('swaps count back for a percentile when a histogram leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
aggregateAttribute: { key: 'signoz_latency', type: 'Histogram' },
spaceAggregation: 'count',
aggregations: [
{ metricName: 'signoz_latency', spaceAggregation: 'count' },
],
},
],
queryFormulas: [],
},
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
const [queryData] = persisted.builder.queryData;
expect(queryData.spaceAggregation).toBe('p90');
expect(queryData.aggregations?.[0]).toStrictEqual({
metricName: 'signoz_latency',
spaceAggregation: 'p90',
});
});
it('leaves a non-histogram metric on sum when it leaves a Heatmap panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'heatmap-current', queryType: 'builder' } as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: {
queryData: [
{
queryName: 'A',
aggregateAttribute: { key: 'signoz_calls_total', type: 'Sum' },
spaceAggregation: 'sum',
aggregations: [
{ metricName: 'signoz_calls_total', spaceAggregation: 'sum' },
],
},
],
queryFormulas: [],
},
} as unknown as Query);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const [persisted] = mockToPerses.mock.calls[0] as [Query];
expect(persisted.builder.queryData[0].spaceAggregation).toBe('sum');
});
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;

View File

@@ -6,7 +6,7 @@ import type {
DashboardtypesQueryDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { ATTRIBUTE_TYPES, type PANEL_TYPES } from 'constants/queryBuilder';
import {
handleQueryChange,
type PartialPanelTypes,
@@ -14,9 +14,14 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type {
IBuilderQuery,
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type {
MetricAggregation,
SpaceAggregation,
} from 'types/api/v5/queryRange';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
@@ -32,6 +37,24 @@ const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
{ columnName: 'timestamp', order: 'desc' },
];
const PERCENTILE_SPACE_AGGREGATIONS = new Set<SpaceAggregation>([
'p50',
'p75',
'p90',
'p95',
'p99',
]);
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const DEFAULT_HISTOGRAM_SPACE_AGGREGATION = 'p90';
/** What a heatmap cell holds, and the one space aggregation the kind offers. */
const HEATMAP_HISTOGRAM_SPACE_AGGREGATION = 'count';
function withDefaultListOrder(query: Query): Query {
return {
...query,
@@ -46,6 +69,93 @@ function withDefaultListOrder(query: Query): Query {
};
}
/**
* A heatmap draws against one bucket axis, so the request takes exactly one enabled
* query and refuses a formula over the cells. The per-kind cache restores whatever is
* dropped here if the panel switches back.
*
* Percentiles go the same way: a histogram heatmap takes its axis from the `le` labels,
* so a percentile draws the grid a count already draws, and the statement builder sums
* the `le` counts either way.
*/
function withSingleHeatmapQuery(query: Query): Query {
const [first] = query.builder.queryData;
if (!first) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: [
{
...first,
spaceAggregation: withoutPercentile(first.spaceAggregation),
aggregations: first.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutPercentile(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
},
],
queryFormulas: [],
},
};
}
/** Cast because the substitute is a `SpaceAggregation` whichever of its callers `T` came from. */
function withoutPercentile<T extends string | undefined>(
spaceAggregation: T,
): T {
return (
PERCENTILE_SPACE_AGGREGATIONS.has(spaceAggregation as SpaceAggregation)
? HEATMAP_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/**
* Undoes the count above: every other kind offers a histogram metric percentiles alone,
* so a count carried out of a heatmap would sit in the selector with no option behind
* it. Other metric types are left alone — count is a real choice for a sum or a gauge.
*/
function withPercentileHistogramAggregation(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((queryData) => {
if (
!HISTOGRAM_ATTRIBUTE_TYPES.has(queryData.aggregateAttribute?.type ?? '')
) {
return queryData;
}
return {
...queryData,
spaceAggregation: withoutCount(queryData.spaceAggregation),
aggregations: queryData.aggregations?.map((aggregation) => ({
...aggregation,
spaceAggregation: withoutCount(
(aggregation as MetricAggregation).spaceAggregation,
),
})) as IBuilderQuery['aggregations'],
};
}),
},
};
}
function withoutCount<T extends string | undefined>(spaceAggregation: T): T {
return (
spaceAggregation === HEATMAP_HISTOGRAM_SPACE_AGGREGATION
? DEFAULT_HISTOGRAM_SPACE_AGGREGATION
: spaceAggregation
) as T;
}
/** What a kind looks like when you leave it; restored verbatim if you return. */
interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
@@ -156,10 +266,14 @@ export function usePanelTypeSwitch({
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
let nextQuery =
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
nextQuery =
newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(nextQuery)
: withPercentileHistogramAggregation(nextQuery);
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;

View File

@@ -22,13 +22,14 @@ import type { PanelKind } from '../types/panelKind';
const { QUERY_BUILDER, CLICKHOUSE, PROM } = EQueryType;
const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw } = Querybuildertypesv5RequestTypeDTO;
const { time_series, scalar, raw, heatmap } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
@@ -41,6 +42,8 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/BarChartPanel': [metrics, logs, traces],
'signoz/NumberPanel': [metrics, logs, traces],
'signoz/HistogramPanel': [metrics, logs, traces],
// A heatmap needs a bucket axis, which only a metric carries.
'signoz/HeatmapPanel': [metrics],
'signoz/PieChartPanel': [metrics, logs, traces],
'signoz/TablePanel': [metrics, logs, traces],
// List renders raw rows; metrics produce no row data.
@@ -77,6 +80,15 @@ const EXPECTED_QUERY_CAPABILITIES: Partial<
orderTiebreaker: false,
serverPaginated: false,
},
// Only Heatmap asks for `heatmap`: server-bucketed counts, one set per timestamp. Like
// Bar it draws one mark per point, so it asks for a widened step interval too.
'signoz/HeatmapPanel': {
requestType: heatmap,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
'signoz/NumberPanel': {
requestType: scalar,
formatTableResultForUI: false,

View File

@@ -0,0 +1,151 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHeatmapPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Heatmap from 'lib/visualization/charts/Heatmap/Heatmap';
import TooltipFooter from 'lib/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
getExecStats,
getHeatmapResults,
} from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import { useTimezone } from 'providers/Timezone';
import NoData from '../../components/NoData/NoData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveHeatmapAxisScale,
resolveHeatmapColors,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import {
prepareHeatmapData,
resolveHeatmapStep,
resolveRequestedBucketKind,
} from './prepareData';
function HeatmapPanelRenderer({
panelId,
panel,
data,
isFetching,
refetch,
onDragSelect,
}: PanelRendererProps<'signoz/HeatmapPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const spec = useMemo<DashboardtypesHeatmapPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const { buckets, series, queryName } = useMemo(
() =>
prepareHeatmapData({
results: getHeatmapResults(data.response),
legendMap: data.legendMap ?? {},
}),
[data.response, data.legendMap],
);
const step = useMemo(
() =>
resolveHeatmapStep({
series,
stepInterval: getExecStats(data.response)?.stepIntervals?.[queryName],
}),
[series, queryName, data.response],
);
// From the request that produced the data, so the grid keeps the window it
// fetched while a newly zoomed one is in flight.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
// The chart rebuilds its plot when `colors` changes identity.
const colors = useMemo(
() => resolveHeatmapColors(spec.chartAppearance?.colors),
[spec.chartAppearance?.colors],
);
const axisScale = useMemo(
() =>
resolveHeatmapAxisScale(
spec.axes?.yScale,
resolveRequestedBucketKind(data.requestPayload),
),
[spec.axes?.yScale, data.requestPayload],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const hasGrid = buckets.length > 0 && series.length > 0;
return (
<div
ref={graphRef}
data-testid="heatmap-panel-renderer"
className={PanelStyles.panelContainer}
>
{!hasGrid && (
<NoData isFetching={isFetching} onRetry={refetch} panel={panel} />
)}
{hasGrid &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (
<Heatmap
key={panelId}
id={panelId}
buckets={buckets}
series={series}
step={step}
colors={colors}
axisScale={axisScale}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
legendPosition={legendPosition}
timezone={timezone}
isDarkMode={isDarkMode}
canPinTooltip
width={containerDimensions.width}
height={containerDimensions.height}
minTimeScale={minTimeScale}
maxTimeScale={maxTimeScale}
onDragSelect={onDragSelect}
renderTooltipFooter={renderTooltipFooter}
/>
)}
</div>
);
}
export default HeatmapPanelRenderer;

View File

@@ -0,0 +1,207 @@
import type {
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import {
FALLBACK_STEP_SECONDS,
prepareHeatmapData,
resolveHeatmapStep,
resolveRequestedBucketKind,
} from '../prepareData';
/** One result carrying a single aggregation with `buckets` on its meta. */
function result({
queryName = 'A',
buckets = [1, 2, 4],
series = [],
}: {
queryName?: string;
buckets?: number[];
series?: Querybuildertypesv5TimeSeriesDTO[];
} = {}): Querybuildertypesv5TimeSeriesDataDTO {
return { queryName, aggregations: [{ index: 0, meta: { buckets }, series }] };
}
describe('prepareHeatmapData', () => {
it('reads the bucket bounds and pivots each series into seconds', () => {
const { buckets, series, queryName } = prepareHeatmapData({
results: [
result({
series: [
{
labels: [{ key: { name: 'service' }, value: 'redis' }],
values: [
{ timestamp: 1_700_000_000_000, values: [1, 2, 0, 3] },
{ timestamp: 1_700_000_060_000, values: [0, 0, 0, 0] },
],
},
],
}),
],
legendMap: {},
});
expect(buckets).toStrictEqual([1, 2, 4]);
expect(queryName).toBe('A');
expect(series).toStrictEqual([
{
label: '{service="redis"}',
points: [
{ timestamp: 1_700_000_000, counts: [1, 2, 0, 3] },
{ timestamp: 1_700_000_060, counts: [0, 0, 0, 0] },
],
},
]);
});
it('names an ungrouped series after its query, so the legend can name it', () => {
const { series } = prepareHeatmapData({
results: [
result({ series: [{ values: [{ timestamp: 0, values: [1, 0, 0, 0] }] }] }),
],
legendMap: { A: '' },
});
expect(series).toHaveLength(1);
expect(series[0].label).toBe('A');
});
it('applies the query legend as the group label template', () => {
const { series } = prepareHeatmapData({
results: [
result({
series: [
{
labels: [{ key: { name: 'host' }, value: 'web-1' }],
values: [],
},
],
}),
],
legendMap: { A: '{{host}}' },
});
expect(series[0].label).toBe('web-1');
});
it('keeps 0 a count and turns a non-finite one into "no data"', () => {
const { series } = prepareHeatmapData({
results: [
result({
series: [
{ values: [{ timestamp: 0, values: [0, Number.NaN, Infinity, 2] }] },
],
}),
],
legendMap: {},
});
expect(series[0].points[0].counts).toStrictEqual([0, null, null, 2]);
});
it('skips aggregations with no bounds — those carry no bucket axis', () => {
const { buckets, queryName } = prepareHeatmapData({
results: [
{ queryName: 'A', aggregations: [{ index: 0, series: [] }] },
result({ queryName: 'B', buckets: [8, 16] }),
],
legendMap: {},
});
expect(buckets).toStrictEqual([8, 16]);
expect(queryName).toBe('B');
});
it('answers empty for a response with no results', () => {
expect(prepareHeatmapData({ results: [], legendMap: {} })).toStrictEqual({
buckets: [],
series: [],
queryName: '',
});
});
});
describe('resolveHeatmapStep', () => {
const series = [
{
label: '',
points: [
{ timestamp: 100, counts: [1] },
{ timestamp: 130, counts: [1] },
],
},
];
it('prefers the step the server reports', () => {
expect(resolveHeatmapStep({ series, stepInterval: 60 })).toBe(60);
});
it('falls back to the first positive gap between columns', () => {
expect(resolveHeatmapStep({ series, stepInterval: undefined })).toBe(30);
});
it('falls back to a minute when nothing says how wide a column is', () => {
expect(
resolveHeatmapStep({
series: [{ label: '', points: [{ timestamp: 100, counts: [1] }] }],
stepInterval: 0,
}),
).toBe(FALLBACK_STEP_SECONDS);
});
});
describe('resolveRequestedBucketKind', () => {
const request = (
queries: unknown[],
): Querybuildertypesv5QueryRangeRequestDTO =>
({
compositeQuery: { queries },
} as Querybuildertypesv5QueryRangeRequestDTO);
it('reads the kind off the query that carries a bucket axis', () => {
expect(
resolveRequestedBucketKind(
request([
{ spec: { name: 'A' } },
{
spec: {
name: 'B',
bucketOptions: {
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
spec: { maxValue: 100 },
},
},
},
]),
),
).toBe(Querybuildertypesv5BucketOptionsLinearDTOKind.linear);
});
it('reads a log axis too', () => {
expect(
resolveRequestedBucketKind(
request([
{
spec: {
bucketOptions: {
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale: 4 },
},
},
},
]),
),
).toBe(Querybuildertypesv5BucketOptionsLogDTOKind.log);
});
it('leaves the choice to the server when no query asked for an axis', () => {
expect(resolveRequestedBucketKind(request([{ spec: {} }]))).toBeUndefined();
expect(resolveRequestedBucketKind(undefined)).toBeUndefined();
});
});

View File

@@ -0,0 +1,55 @@
import { LayoutGrid } from '@signozhq/icons';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
kind: 'signoz/HeatmapPanel',
displayName: 'Heatmap',
mode: 'query',
icon: LayoutGrid,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
// Only metrics carry a bucket axis; the request rejects the other signals.
supportedSignals: [TelemetrytypesSignalDTO.metrics],
supportedQueryTypes: [
EQueryType.QUERY_BUILDER,
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
// The request rejects both: a point is a count per bucket, not a single value.
queryBuilderFields: {
[QueryBuilderField.Functions]: { state: 'hidden' },
[QueryBuilderField.Having]: { state: 'hidden' },
},
// Every timestamp is a full column of cells, so the request asks for a step interval
// wide enough to keep the grid legible: at raw resolution a multi-day range is tens of
// thousands of cells, each of them sub-pixel.
queryCapabilities: {
requestType: Querybuildertypesv5RequestTypeDTO.heatmap,
formatTableResultForUI: false,
bucketedStepInterval: true,
orderTiebreaker: false,
serverPaginated: false,
},
actions: {
view: true,
edit: true,
clone: true,
download: { csv: false, png: true, svg: true },
// A distribution per timestamp has no single value to threshold.
createAlert: false,
search: false,
drilldown: false,
},
};

View File

@@ -0,0 +1,144 @@
import type {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
} from 'api/generated/services/sigNoz.schemas';
import getLabelName from 'lib/getLabelName';
import type {
HeatmapSeries,
HeatmapSeriesLabel,
HeatmapSeriesPoint,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
const MS_PER_SECOND = 1000;
export const FALLBACK_STEP_SECONDS = 60;
export interface HeatmapPanelData {
/** Ascending bucket upper bounds shared by every group; empty means no grid. */
buckets: number[];
series: HeatmapSeries[];
/** The key the grid's step interval is reported under. */
queryName: string;
}
const EMPTY_DATA: HeatmapPanelData = {
buckets: [],
series: [],
queryName: '',
};
/**
* V5 heatmap results → the chart's `buckets` + `series`. Each point carries one
* count per bucket bound plus the trailing open-above overflow.
*
* Only the first aggregation carrying bounds is read: each comes back with its own
* bucket axis, and a grid has one y axis.
*/
export function prepareHeatmapData({
results,
legendMap,
}: {
results: Querybuildertypesv5TimeSeriesDataDTO[];
legendMap: Record<string, string>;
}): HeatmapPanelData {
for (const result of results) {
const queryName = result.queryName ?? '';
for (const aggregation of result.aggregations ?? []) {
const buckets = aggregation.meta?.buckets;
if (!buckets?.length) {
continue;
}
return {
buckets,
queryName,
series: (aggregation.series ?? []).map((series) =>
toHeatmapSeries(series, queryName, legendMap[queryName] ?? ''),
),
};
}
}
return EMPTY_DATA;
}
/**
* Bucket axis the request asked for; `undefined` leaves the choice to the server. A
* heatmap draws one query, so the first envelope carrying `bucketOptions` is it —
* every variant declares it at the same place, which the union cannot express.
*/
export function resolveRequestedBucketKind(
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): Querybuildertypesv5BucketOptionsDTO['kind'] | undefined {
for (const envelope of request?.compositeQuery?.queries ?? []) {
const spec = envelope.spec as
| { bucketOptions?: Querybuildertypesv5BucketOptionsDTO }
| undefined;
if (spec?.bucketOptions) {
return spec.bucketOptions.kind;
}
}
return undefined;
}
/**
* Column width in seconds. The server's effective step is authoritative — the
* last column has no successor to infer a width from — so the timestamp delta is
* only a fallback for a response that reports no step.
*/
export function resolveHeatmapStep({
series,
stepInterval,
}: {
series: HeatmapSeries[];
/** `meta.stepIntervals[queryName]`, in seconds. */
stepInterval: number | undefined;
}): number {
if (stepInterval && stepInterval > 0) {
return stepInterval;
}
const points = series[0]?.points ?? [];
for (let index = 1; index < points.length; index++) {
const delta = points[index].timestamp - points[index - 1].timestamp;
if (delta > 0) {
return delta;
}
}
return FALLBACK_STEP_SECONDS;
}
function toHeatmapSeries(
series: Querybuildertypesv5TimeSeriesDTO,
queryName: string,
legend: string,
): HeatmapSeries {
const labels = (series.labels ?? []).flatMap<HeatmapSeriesLabel>((label) =>
label.key?.name ? [{ key: label.key.name, value: String(label.value) }] : [],
);
return {
label: resolveGroupLabel(labels, queryName, legend),
points: (series.values ?? []).map<HeatmapSeriesPoint>((point) => ({
timestamp: (point.timestamp ?? 0) / MS_PER_SECOND,
// `null` is "no data"; `0` is an observed absence and stays a number.
counts: (point.values ?? []).map((count) =>
Number.isFinite(count) ? count : null,
),
})),
};
}
function resolveGroupLabel(
labels: HeatmapSeriesLabel[],
queryName: string,
legend: string,
): string {
const record: Record<string, string> = {};
labels.forEach((label) => {
record[label.key] = label.value;
});
return getLabelName(record, queryName, legend);
}

View File

@@ -0,0 +1,17 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
// No thresholds: colour already means "count". No bucket controls either — the
// bucket axis comes back with the response and the panel never re-bins it.
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true, timePreference: true },
},
// Ahead of the rest: the colour ramp is what a heatmap is read through.
{ kind: SectionKind.ChartAppearance, controls: { colors: true } },
// Formats the bucket bounds; counts are plain integers and take no unit.
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { yScale: true } },
// Position only: a group's swatch colour comes off the grid's own ramp.
{ kind: SectionKind.Legend, controls: { position: true } },
];

View File

@@ -1,4 +1,5 @@
import { definition as BarChart } from './kinds/BarChartPanel/definition';
import { definition as Heatmap } from './kinds/HeatmapPanel/definition';
import { definition as Histogram } from './kinds/HistogramPanel/definition';
import { definition as NumberValue } from './kinds/NumberPanel/definition';
import { definition as PieChart } from './kinds/PieChartPanel/definition';
@@ -23,6 +24,7 @@ export const PANELS: PanelRegistry = {
[BarChart.kind]: BarChart,
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[Heatmap.kind]: Heatmap,
[List.kind]: List,
[Text.kind]: Text,
};

View File

@@ -28,6 +28,8 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
// Drag-select only: the drill-down payload has no shape for a bucket range.
'signoz/HeatmapPanel': { onDragSelect?: DragSelect };
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };

View File

@@ -13,8 +13,9 @@ export interface PanelQueryCapabilities {
/** Server transposes the scalar result into UI table rows (`formatOptions.formatTableResultForUI`). */
formatTableResultForUI: boolean;
/**
* Widen the step interval to cap how many buckets come back — kinds that bin
* client-side from a raw time series rather than plotting every point.
* Widen the step interval to cap how many points a range comes back as — kinds that
* draw one mark per point (a bar, a column of cells) rather than a line through it,
* where a raw-resolution series is both unreadable and more than the canvas can carry.
*/
bucketedStepInterval: boolean;
/**

View File

@@ -23,6 +23,7 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
'signoz/HeatmapPanel': PANEL_TYPES.HEATMAP,
'signoz/TextPanel': PANEL_TYPES.TEXT,
};

View File

@@ -3,6 +3,8 @@ import type {
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHeatmapAxesDTO,
DashboardtypesHeatmapChartAppearanceDTO,
DashboardtypesHeaderOptionsDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
@@ -87,11 +89,20 @@ export type AnyThreshold =
export type PanelFormattingSlice = DashboardtypesPanelFormattingDTO &
Pick<DashboardtypesTableFormattingDTO, 'columnUnits'>;
// Superset of every kind's axes DTO, gated by the `controls` bag.
export type PanelAxesSlice = DashboardtypesAxesDTO &
DashboardtypesHeatmapAxesDTO;
// Superset of every kind's chart-appearance DTO, gated by `controls`.
export type PanelChartAppearanceSlice =
DashboardtypesTimeSeriesChartAppearanceDTO &
DashboardtypesHeatmapChartAppearanceDTO;
export interface SectionSpecMap {
[SectionKind.Formatting]: PanelFormattingSlice; // spec.plugin.spec.formatting
[SectionKind.Axes]: DashboardtypesAxesDTO; // spec.plugin.spec.axes
[SectionKind.Axes]: PanelAxesSlice; // spec.plugin.spec.axes
[SectionKind.Legend]: DashboardtypesLegendDTO; // spec.plugin.spec.legend
[SectionKind.ChartAppearance]: DashboardtypesTimeSeriesChartAppearanceDTO; // spec.plugin.spec.chartAppearance
[SectionKind.ChartAppearance]: PanelChartAppearanceSlice; // spec.plugin.spec.chartAppearance
[SectionKind.Buckets]: DashboardtypesHistogramBucketsDTO; // spec.plugin.spec.histogramBuckets
// spec.plugin.spec.visualization — typed as the Bar shape (widest superset);
// the `controls` bag gates which fields each kind writes.
@@ -113,7 +124,13 @@ export interface SectionControls {
decimals?: boolean;
columnUnits?: boolean;
};
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
// minMax → softMin/softMax; yScale → the heatmap's bucket-axis distribution,
// whose domain comes from the response and so takes no soft bounds.
[SectionKind.Axes]: {
minMax?: boolean;
logScale?: boolean;
yScale?: boolean;
};
[SectionKind.Legend]: {
position?: boolean;
// colors → customColors; the resolver supplies the labels overrides are keyed by,
@@ -126,6 +143,8 @@ export interface SectionControls {
fillMode?: boolean;
showPoints?: boolean;
spanGaps?: boolean;
// colors → the heatmap's cell colour ramp; nothing else here applies to a grid.
colors?: boolean;
};
[SectionKind.Buckets]: {
count?: boolean;

View File

@@ -1,6 +1,10 @@
import {
DashboardtypesComparisonOperatorDTO,
DashboardtypesFillModeDTO,
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
DashboardtypesHeatmapYScaleDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
@@ -170,10 +174,7 @@ const SECTION_SEEDS: SectionSeeds = {
[SectionKind.Axes]: {
specKey: 'axes',
seed: (controls, { oldPluginSpec }): SectionSpecMap[SectionKind.Axes] => {
const old = oldPluginSpec?.axes;
if (!old) {
return {};
}
const old = oldPluginSpec?.axes ?? {};
return {
...(controls.minMax &&
typeof old.softMin === 'number' && { softMin: old.softMin }),
@@ -181,6 +182,10 @@ const SECTION_SEEDS: SectionSeeds = {
typeof old.softMax === 'number' && { softMax: old.softMax }),
...(controls.logScale &&
old.isLogScale !== undefined && { isLogScale: old.isLogScale }),
// Seeded so the control opens on the axis being drawn.
...(controls.yScale && {
yScale: old.yScale ?? DashboardtypesHeatmapYScaleDTO.auto,
}),
};
},
},
@@ -207,6 +212,7 @@ const SECTION_SEEDS: SectionSeeds = {
fillMode = DashboardtypesFillModeDTO.none,
showPoints,
spanGaps,
colors,
} = oldPluginSpec?.chartAppearance ?? {};
const appearance: SectionSpecMap[SectionKind.ChartAppearance] = {};
if (controls.lineStyle) {
@@ -224,6 +230,16 @@ const SECTION_SEEDS: SectionSeeds = {
if (controls.spanGaps && spanGaps !== undefined) {
appearance.spanGaps = spanGaps;
}
// Seeded so the controls open on the ramp the grid is drawn with; the
// count bounds and step count stay unset, which reads as derived.
if (controls.colors) {
appearance.colors = {
...colors,
mode: colors?.mode ?? DashboardtypesHeatmapColorModeDTO.palette,
palette: colors?.palette ?? DashboardtypesHeatmapPaletteDTO.lava,
scale: colors?.scale ?? DashboardtypesHeatmapColorScaleDTO.log,
};
}
return appearance;
},
},

View File

@@ -1,4 +1,23 @@
import { resolveSpanGaps } from '../resolvers';
import {
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
DashboardtypesHeatmapYScaleDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import {
HeatmapAxisScale,
HeatmapColorMode,
HeatmapColorPalette,
HeatmapColorScale,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import {
resolveHeatmapAxisScale,
resolveHeatmapColors,
resolveSpanGaps,
} from '../resolvers';
describe('resolveSpanGaps', () => {
it('parses a duration string into seconds when thresholding', () => {
@@ -33,3 +52,96 @@ describe('resolveSpanGaps', () => {
expect(resolveSpanGaps({ fillLessThan: '5m' })).toBe(300);
});
});
describe('resolveHeatmapAxisScale', () => {
it('maps each scale onto its own axis — log and symlog are not the same', () => {
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.auto)).toBe(
HeatmapAxisScale.Auto,
);
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.linear)).toBe(
HeatmapAxisScale.Linear,
);
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.log)).toBe(
HeatmapAxisScale.Log,
);
expect(resolveHeatmapAxisScale(DashboardtypesHeatmapYScaleDTO.symlog)).toBe(
HeatmapAxisScale.Symlog,
);
});
it('leaves an unset scale to the bucket bounds', () => {
expect(resolveHeatmapAxisScale(undefined)).toBe(HeatmapAxisScale.Auto);
});
it('follows linear bucketing when the panel has not chosen a scale', () => {
expect(
resolveHeatmapAxisScale(
undefined,
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
),
).toBe(HeatmapAxisScale.Linear);
expect(
resolveHeatmapAxisScale(
DashboardtypesHeatmapYScaleDTO.auto,
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
),
).toBe(HeatmapAxisScale.Linear);
});
it('leaves log bucketing to the bucket bounds', () => {
expect(
resolveHeatmapAxisScale(
undefined,
Querybuildertypesv5BucketOptionsLogDTOKind.log,
),
).toBe(HeatmapAxisScale.Auto);
});
it('lets an explicit scale outrank the query bucketing', () => {
expect(
resolveHeatmapAxisScale(
DashboardtypesHeatmapYScaleDTO.log,
Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
),
).toBe(HeatmapAxisScale.Log);
});
});
describe('resolveHeatmapColors', () => {
it('maps each wire enum onto its chart counterpart', () => {
expect(
resolveHeatmapColors({
mode: DashboardtypesHeatmapColorModeDTO.opacity,
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
palette: DashboardtypesHeatmapPaletteDTO.verdant,
fill: '#ff0000',
}),
).toStrictEqual({
mode: HeatmapColorMode.Opacity,
scale: HeatmapColorScale.Sqrt,
palette: HeatmapColorPalette.Verdant,
fill: '#ff0000',
});
});
it('omits every unset field, so the chart keeps its own defaults', () => {
expect(resolveHeatmapColors(undefined)).toStrictEqual({});
expect(resolveHeatmapColors({})).toStrictEqual({});
});
it("keeps a null count bound — the spec's way of asking for a derived one", () => {
expect(resolveHeatmapColors({ minCount: null, maxCount: 500 })).toStrictEqual(
{ minCount: null, maxCount: 500 },
);
});
it('clamps a step count the chart could not draw', () => {
expect(resolveHeatmapColors({ steps: 1 }).steps).toBe(2);
expect(resolveHeatmapColors({ steps: 500 }).steps).toBe(128);
expect(resolveHeatmapColors({ steps: 32 }).steps).toBe(32);
});
it('reads a zero step count as unset, which is how the wire sends none', () => {
expect(resolveHeatmapColors({ steps: 0 })).toStrictEqual({});
});
});

View File

@@ -1,5 +1,9 @@
import {
DashboardtypesFillModeDTO,
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
DashboardtypesHeatmapYScaleDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesLineInterpolationDTO,
DashboardtypesLineStyleDTO,
@@ -10,6 +14,12 @@ import {
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import {
HeatmapAxisScale,
HeatmapColorMode,
HeatmapColorPalette,
HeatmapColorScale,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
/**
* Bridges the V2 wire-format enums to the uPlotV2 chart enums. String values
@@ -45,3 +55,46 @@ export const LEGEND_POSITION_MAP: Record<
[DashboardtypesLegendPositionDTO.bottom]: LegendPosition.BOTTOM,
[DashboardtypesLegendPositionDTO.right]: LegendPosition.RIGHT,
};
export const HEATMAP_COLOR_MODE_MAP: Record<
DashboardtypesHeatmapColorModeDTO,
HeatmapColorMode
> = {
[DashboardtypesHeatmapColorModeDTO.palette]: HeatmapColorMode.Palette,
[DashboardtypesHeatmapColorModeDTO.opacity]: HeatmapColorMode.Opacity,
};
export const HEATMAP_COLOR_SCALE_MAP: Record<
DashboardtypesHeatmapColorScaleDTO,
HeatmapColorScale
> = {
[DashboardtypesHeatmapColorScaleDTO.log]: HeatmapColorScale.Log,
[DashboardtypesHeatmapColorScaleDTO.sqrt]: HeatmapColorScale.Sqrt,
[DashboardtypesHeatmapColorScaleDTO.linear]: HeatmapColorScale.Linear,
};
export const HEATMAP_PALETTE_MAP: Record<
DashboardtypesHeatmapPaletteDTO,
HeatmapColorPalette
> = {
[DashboardtypesHeatmapPaletteDTO.ice]: HeatmapColorPalette.Ice,
[DashboardtypesHeatmapPaletteDTO.moss]: HeatmapColorPalette.Moss,
[DashboardtypesHeatmapPaletteDTO.rust]: HeatmapColorPalette.Rust,
[DashboardtypesHeatmapPaletteDTO.graphite]: HeatmapColorPalette.Graphite,
[DashboardtypesHeatmapPaletteDTO.ember]: HeatmapColorPalette.Ember,
[DashboardtypesHeatmapPaletteDTO.lagoon]: HeatmapColorPalette.Lagoon,
[DashboardtypesHeatmapPaletteDTO.orchid]: HeatmapColorPalette.Orchid,
[DashboardtypesHeatmapPaletteDTO.verdant]: HeatmapColorPalette.Verdant,
[DashboardtypesHeatmapPaletteDTO.lava]: HeatmapColorPalette.Lava,
[DashboardtypesHeatmapPaletteDTO.beacon]: HeatmapColorPalette.Beacon,
};
export const HEATMAP_Y_SCALE_MAP: Record<
DashboardtypesHeatmapYScaleDTO,
HeatmapAxisScale
> = {
[DashboardtypesHeatmapYScaleDTO.auto]: HeatmapAxisScale.Auto,
[DashboardtypesHeatmapYScaleDTO.linear]: HeatmapAxisScale.Linear,
[DashboardtypesHeatmapYScaleDTO.log]: HeatmapAxisScale.Log,
[DashboardtypesHeatmapYScaleDTO.symlog]: HeatmapAxisScale.Symlog,
};

View File

@@ -1,13 +1,28 @@
import { rangeUtil } from '@grafana/data';
import {
DashboardtypesHeatmapYScaleDTO,
DashboardtypesLegendPositionDTO,
DashboardtypesPrecisionOptionDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
type DashboardtypesHeatmapColorsDTO,
type DashboardtypesSpanGapsDTO,
type Querybuildertypesv5BucketOptionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { clampColorSteps } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapAxisScale,
type HeatmapColorOptions,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { LEGEND_POSITION_MAP } from './enumMaps';
import {
HEATMAP_COLOR_MODE_MAP,
HEATMAP_COLOR_SCALE_MAP,
HEATMAP_PALETTE_MAP,
HEATMAP_Y_SCALE_MAP,
LEGEND_POSITION_MAP,
} from './enumMaps';
// Resolvers turning raw `spec` chart-appearance fields into runtime chart
// values, falling back to chart defaults for missing/unknown input.
@@ -65,3 +80,49 @@ export function resolveLegendPosition(
}
return LegendPosition.BOTTOM;
}
/**
* Row-height distribution of a heatmap's bucket axis. Missing/unknown resolves to
* `auto`, the one option that reads the bucket bounds rather than overriding them.
* Under `auto`, linear bucketing gets a linear row layout: its bounds are evenly
* spaced, and a log one would squeeze the axis into the bottom of the panel.
*/
export function resolveHeatmapAxisScale(
yScale: DashboardtypesHeatmapYScaleDTO | undefined,
bucketKind?: Querybuildertypesv5BucketOptionsDTO['kind'],
): HeatmapAxisScale {
const configured =
yScale && yScale in HEATMAP_Y_SCALE_MAP
? HEATMAP_Y_SCALE_MAP[yScale]
: HeatmapAxisScale.Auto;
if (configured !== HeatmapAxisScale.Auto) {
return configured;
}
return bucketKind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear
? HeatmapAxisScale.Linear
: HeatmapAxisScale.Auto;
}
/**
* `chartAppearance.colors` → the chart's colour options. Only fields the spec sets
* are returned: the chart spreads this over its own defaults, so an explicit
* `undefined` would erase one. `null` bounds pass through — that is the spec's own
* way of asking for a derived one.
*/
export function resolveHeatmapColors(
colors: DashboardtypesHeatmapColorsDTO | undefined,
): Partial<HeatmapColorOptions> {
if (!colors) {
return {};
}
return {
...(colors.mode && { mode: HEATMAP_COLOR_MODE_MAP[colors.mode] }),
...(colors.scale && { scale: HEATMAP_COLOR_SCALE_MAP[colors.scale] }),
...(colors.palette && { palette: HEATMAP_PALETTE_MAP[colors.palette] }),
...(colors.steps && { steps: clampColorSteps(colors.steps) }),
...(colors.minCount !== undefined && { minCount: colors.minCount }),
...(colors.maxCount !== undefined && { maxCount: colors.maxCount }),
...(colors.fill !== undefined && { fill: colors.fill }),
};
}

View File

@@ -6,7 +6,7 @@ import {
import {
buildQueryRangeRequest,
extractLegendMap,
getBarStepIntervalSeconds,
getBucketedStepIntervalSeconds,
hasRunnableQueries,
toQueryEnvelopes,
} from '../buildQueryRangeRequest';
@@ -51,10 +51,15 @@ const TIME_SERIES_CAPABILITIES = {
orderTiebreaker: false,
serverPaginated: false,
};
const BAR_CAPABILITIES = {
const BUCKETED_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
bucketedStepInterval: true,
};
const HEATMAP_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.heatmap,
bucketedStepInterval: true,
};
const TABLE_CAPABILITIES = {
...TIME_SERIES_CAPABILITIES,
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
@@ -289,10 +294,10 @@ describe('buildQueryRangeRequest', () => {
expect(spec.order).toStrictEqual(order);
});
it('injects the range-derived stepInterval into BAR builder queries without one', () => {
it('injects the range-derived stepInterval into builder queries without one', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics' }),
queryCapabilities: BAR_CAPABILITIES,
queryCapabilities: BUCKETED_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -300,14 +305,33 @@ describe('buildQueryRangeRequest', () => {
stepInterval?: number;
};
expect(spec.stepInterval).toBe(
getBarStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
getBucketedStepIntervalSeconds(START_MS, START_MS + HOUR_MS),
);
});
it('preserves a user-set stepInterval on BAR builder queries', () => {
// The bucket axis rides on the query it draws, so it reaches the request the same
// way every other query field does — nothing at request level carries it.
it("carries a query's bucket options into the payload", () => {
const bucketOptions = { kind: 'log', spec: { scale: 0 } };
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', signal: 'metrics', bucketOptions }),
queryCapabilities: HEATMAP_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
expect(request.requestType).toBe(Querybuildertypesv5RequestTypeDTO.heatmap);
const spec = (request.compositeQuery?.queries?.[0]?.spec ?? {}) as {
bucketOptions?: unknown;
};
expect(spec.bucketOptions).toStrictEqual(bucketOptions);
expect(request).not.toHaveProperty('bucketOptions');
});
it('preserves a user-set stepInterval', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A', stepInterval: 300 }),
queryCapabilities: BAR_CAPABILITIES,
queryCapabilities: BUCKETED_CAPABILITIES,
startMs: START_MS,
endMs: START_MS + HOUR_MS,
});
@@ -317,7 +341,7 @@ describe('buildQueryRangeRequest', () => {
expect(spec.stepInterval).toBe(300);
});
it('does not touch stepInterval for non-BAR panels', () => {
it('does not touch stepInterval for kinds that plot every point', () => {
const request = buildQueryRangeRequest({
queries: bareBuilderQuery({ name: 'A' }),
queryCapabilities: TIME_SERIES_CAPABILITIES,
@@ -331,7 +355,7 @@ describe('buildQueryRangeRequest', () => {
});
});
describe('getBarStepIntervalSeconds', () => {
describe('getBucketedStepIntervalSeconds', () => {
// V1 parity: getBarStepIntervalPoints in container/GridCardLayout/utils.ts
it.each([
[30, 60],
@@ -340,12 +364,12 @@ describe('getBarStepIntervalSeconds', () => {
[180, 120],
[300, 180],
])('%s min range → %s s step', (minutes, step) => {
expect(getBarStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
expect(getBucketedStepIntervalSeconds(0, minutes * 60 * 1000)).toBe(step);
});
it('caps long ranges at ~80 bars, rounded to 5-minute steps', () => {
it('caps long ranges at ~80 points, rounded to 5-minute steps', () => {
// 24h = 1440 min → 1440/80 = 18 → rounded up to 20 min → 1200 s
expect(getBarStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
expect(getBucketedStepIntervalSeconds(0, 24 * HOUR_MS)).toBe(1200);
});
});

View File

@@ -1,7 +1,9 @@
import type {
DashboardtypesQueryDTO,
Querybuildertypesv5CompositeQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -103,6 +105,102 @@ describe('persesQueryAdapters', () => {
});
});
describe('bucket options', () => {
const bucketOptions = {
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale: 0 },
};
const heatmapQuery = (): Query => ({
...initialQueriesMap[DataSource.METRICS],
builder: {
...initialQueriesMap[DataSource.METRICS].builder,
queryData: [
{
...initialQueriesMap[DataSource.METRICS].builder.queryData[0],
bucketOptions,
},
],
},
});
it('writes the axis onto the builder query spec', () => {
const result = toPerses(heatmapQuery(), PANEL_TYPES.HEATMAP);
expect(result[0].kind).toBe('heatmap');
const [envelope] = (
result[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
).queries as [Querybuildertypesv5QueryEnvelopeDTO];
expect(
(envelope.spec as { bucketOptions?: unknown }).bucketOptions,
).toStrictEqual(bucketOptions);
});
it('reads the axis back off the builder query spec', () => {
const restored = fromPerses(
toPerses(heatmapQuery(), PANEL_TYPES.HEATMAP),
PANEL_TYPES.HEATMAP,
);
expect(restored.builder.queryData[0].bucketOptions).toStrictEqual(
bucketOptions,
);
});
// Explicit-undefined, like every other unset field `createBaseSpec` emits; it
// drops out of the payload at JSON.stringify.
it("writes and reads a formula's own axis", () => {
const base = initialQueriesMap[DataSource.METRICS];
const withFormula: Query = {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], disabled: true }],
queryFormulas: [
{
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
bucketOptions,
},
],
},
};
const perses = toPerses(withFormula, PANEL_TYPES.HEATMAP);
const envelopes = (
perses[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
).queries as Querybuildertypesv5QueryEnvelopeDTO[];
const formulaEnvelope = envelopes.find(
(envelope) => envelope.type === 'builder_formula',
);
expect(
(formulaEnvelope?.spec as { bucketOptions?: unknown })?.bucketOptions,
).toStrictEqual(bucketOptions);
const restored = fromPerses(perses, PANEL_TYPES.HEATMAP);
expect(restored.builder.queryFormulas[0].bucketOptions).toStrictEqual(
bucketOptions,
);
});
it('emits no axis when none is set', () => {
const result = toPerses(
initialQueriesMap[DataSource.METRICS],
PANEL_TYPES.HEATMAP,
);
const [envelope] = (
result[0].spec.plugin.spec as Querybuildertypesv5CompositeQueryDTO
).queries as [Querybuildertypesv5QueryEnvelopeDTO];
expect(
(envelope.spec as { bucketOptions?: unknown }).bucketOptions,
).toBeUndefined();
});
});
describe('toPerses', () => {
it('wraps the query in a single signoz/CompositeQuery keyed to the panel request type', () => {
const result = toPerses(

View File

@@ -88,10 +88,11 @@ export function toQueryEnvelopes(
}
/**
* Step interval (seconds) for BAR panels capping the bar count (~80 max). Duplicated from V1
* `getBarStepIntervalPoints` per the V1/V2 split policy.
* Step interval (seconds) capping how many points a range comes back as (~80 max), for the
* kinds that draw one mark per point. Duplicated from V1 `getBarStepIntervalPoints` per the
* V1/V2 split policy.
*/
export function getBarStepIntervalSeconds(
export function getBucketedStepIntervalSeconds(
startMs: number,
endMs: number,
): number {
@@ -115,15 +116,16 @@ export function getBarStepIntervalSeconds(
}
/**
* BAR panels: builder queries without a user-set stepInterval get the range-derived one so bars
* align (V1 parity: `updateBarStepInterval`).
* Builder queries without a user-set stepInterval get the range-derived one, so a wide range
* comes back as a readable number of marks instead of one per raw point (V1 parity:
* `updateBarStepInterval`). An explicit interval is the user's call and is left alone.
*/
function withBarStepInterval(
function withBucketedStepInterval(
envelopes: Querybuildertypesv5QueryEnvelopeDTO[],
startMs: number,
endMs: number,
): Querybuildertypesv5QueryEnvelopeDTO[] {
const stepInterval = getBarStepIntervalSeconds(startMs, endMs);
const stepInterval = getBucketedStepIntervalSeconds(startMs, endMs);
return envelopes.map((envelope) => {
if (
envelope.type !==
@@ -253,7 +255,7 @@ export function buildQueryRangeRequest({
}: BuildQueryRangeRequestArgs): Querybuildertypesv5QueryRangeRequestDTO {
let envelopes = toQueryEnvelopes(queries);
if (bucketedStepInterval) {
envelopes = withBarStepInterval(envelopes, startMs, endMs);
envelopes = withBucketedStepInterval(envelopes, startMs, endMs);
}
if (orderTiebreaker) {
envelopes = withListOrderTiebreaker(envelopes);

View File

@@ -108,6 +108,8 @@ export function panelTypeToRequestType(
return Querybuildertypesv5RequestTypeDTO.scalar;
case PANEL_TYPES.LIST:
return Querybuildertypesv5RequestTypeDTO.raw;
case PANEL_TYPES.HEATMAP:
return Querybuildertypesv5RequestTypeDTO.heatmap;
case PANEL_TYPES.TRACE:
return Querybuildertypesv5RequestTypeDTO.trace;
default:

View File

@@ -25,6 +25,17 @@ export function getTimeSeriesResults(
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
}
/** The time-series tree with bucket bounds on each aggregation's `meta`. */
export function getHeatmapResults(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5TimeSeriesDataDTO[] {
if (response?.data?.type !== 'heatmap') {
return [];
}
return (response.data.data?.results ??
[]) as Querybuildertypesv5TimeSeriesDataDTO[];
}
export function getScalarResults(
response: QueryRangeV5200 | undefined,
): Querybuildertypesv5ScalarDataDTO[] {

View File

@@ -1,3 +1,4 @@
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { Format } from 'constants/formats/types';
import { EQueryType } from 'types/common/dashboard';
@@ -28,6 +29,8 @@ export interface IBuilderFormula {
having?: Having[];
stepInterval?: number;
orderBy?: OrderByPayload[];
/** Heatmap only, like `IBuilderQuery['bucketOptions']`. */
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
}
export type IBuilderTraceOperator = IBuilderQuery;
@@ -92,6 +95,12 @@ export type IBuilderQuery = {
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
builderQueryType?: BuilderQueryType;
/**
* Heatmap only, and request-level on the wire: the bucket axis is shared by every
* query in the request, and a heatmap admits exactly one enabled query, so the
* builder edits it alongside that query.
*/
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
};
export interface IClickHouseQuery {

View File

@@ -1,5 +1,6 @@
// ===================== Base Types =====================
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { ReduceOperators } from 'types/common/queryBuilder';
import { Warning } from '..';
@@ -230,6 +231,11 @@ export interface BaseBuilderQuery {
functions?: QueryFunction[];
legend?: string;
expression?: string; // for trace operator
/**
* The bucket axis to count this query's values into. Only a heatmap request reads
* it, and only from the one query it draws.
*/
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
}
export interface TraceBuilderQuery extends BaseBuilderQuery {
@@ -267,6 +273,12 @@ export interface QueryBuilderFormula {
limit?: number;
having?: Having;
legend?: string;
/**
* The bucket axis to count the formula's results into. Only a heatmap request reads
* it, and only from the one query it draws — which is the formula when its inputs are
* disabled.
*/
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
}
export interface QueryBuilderJoin {

View File

@@ -25,6 +25,9 @@ function convertFormulasToV5(
disabled: formulaData.disabled,
limit: formulaData.limit ?? undefined,
legend: formulaData.legend,
// Heatmap only; `handleQueryChange` is what keeps one off a formula whose
// panel type switched away.
bucketOptions: formulaData.bucketOptions,
order: formulaData.orderBy?.map(
(order: any): OrderBy => ({
key: {

View File

@@ -14,6 +14,7 @@ export const panelTypeToExplorerView: Record<PANEL_TYPES, ExplorerViews> = {
[PANEL_TYPES.BAR]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.PIE]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.HISTOGRAM]: ExplorerViews.TIMESERIES,
[PANEL_TYPES.HEATMAP]: ExplorerViews.TIMESERIES,
// Dashboard-only visualisation; explorers never offer it.
[PANEL_TYPES.TEXT]: ExplorerViews.LIST,
[PANEL_TYPES.EMPTY_WIDGET]: ExplorerViews.LIST,