Compare commits

...

15 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
34 changed files with 580 additions and 117 deletions

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

@@ -68,10 +68,14 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
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;
@@ -101,10 +105,10 @@
letter-spacing: -0.07px;
}
/** Bounds read as a sequence of values, so they take the value type in a flat pill. */
/** 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) var(--spacing-3);
border-radius: var(--radius-1);
padding: var(--spacing-1) 0;
font-family: 'Geist Mono';
font-size: 13px;
@@ -112,11 +116,9 @@
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
background: var(--l3-background);
}
.overflowBound {
background: transparent;
color: var(--l3-foreground);
}
@@ -127,13 +129,9 @@
color: var(--l3-foreground);
}
.hint {
margin: 0;
max-width: 72ch;
font-family: 'Geist Mono';
font-size: 12px;
line-height: 18px;
.hintIcon {
flex: none;
cursor: help;
color: var(--l3-foreground);
}

View File

@@ -1,20 +1,19 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from 'antd';
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 } from '@signozhq/icons';
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_BANDS_OPTIONS,
LOG_SCALE_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
bandsPerDoublingFromScale,
BucketKindOption,
formatUpperBound,
hasBoundsBeyondPreview,
@@ -24,7 +23,6 @@ import {
logBuckets,
logScaleOf,
previewUpperBounds,
scaleFromBandsPerDoubling,
} from './utils';
import styles from './BucketOptions.module.scss';
@@ -96,13 +94,13 @@ function BucketOptions({
[emitLinear, logScale, maxValue, numBuckets, onChange],
);
const handleBandsChange = useCallback(
const handleScaleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = scaleFromBandsPerDoubling(Number(value));
const nextScale = Number(value);
setLogScale(nextScale);
onChange(logBuckets(nextScale));
},
@@ -139,9 +137,9 @@ function BucketOptions({
[],
);
const bandItems = useMemo(
const scaleItems = useMemo(
() =>
LOG_BANDS_OPTIONS.map(({ value, label }) => ({
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
@@ -184,13 +182,13 @@ function BucketOptions({
{kind === 'log' && (
<div className={styles.field}>
<span className={styles.label}>Bands per doubling</span>
<span className={styles.label}>Scale</span>
<ToggleGroupSimple
type="single"
value={String(bandsPerDoublingFromScale(logScale))}
items={bandItems}
onChange={handleBandsChange}
testId="bucket-options-bands"
value={String(logScale)}
items={scaleItems}
onChange={handleScaleChange}
testId="bucket-options-scale"
/>
</div>
)}
@@ -218,7 +216,7 @@ function BucketOptions({
precision={0}
value={numBuckets}
onChange={handleNumBucketsChange}
placeholder={String(DEFAULT_NUM_BUCKETS)}
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
data-testid="bucket-options-num-buckets"
/>
</div>
@@ -236,7 +234,12 @@ function BucketOptions({
</div>
<div className={styles.bounds} data-testid="bucket-options-bounds">
<span className={styles.label}>Bounds</span>
<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) => (
@@ -253,8 +256,6 @@ function BucketOptions({
<span className={styles.muted}>Set a max value to see the bounds</span>
)}
</div>
<p className={styles.hint}>{BUCKET_KIND_HINTS[kind]}</p>
</div>
);
}

View File

@@ -7,11 +7,11 @@ import {
linearBuckets,
logBuckets,
previewUpperBounds,
scaleFromBandsPerDoubling,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[-2, 0.25],
[0, 1],
[1, 2],
[2, 4],
@@ -19,7 +19,6 @@ describe('bucket option scales', () => {
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
expect(scaleFromBandsPerDoubling(bands)).toBe(scale);
});
});

View File

@@ -11,16 +11,19 @@
*/
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;
/**
* The bands-per-doubling the toggle offers, coarsest first. Each is 2^scale for a
* scale in [0, MAX_LOG_SCALE]: a negative scale is a whole number of doublings per
* band instead, which has no bands-per-doubling label.
*/
export const LOG_BANDS_PER_DOUBLING = [1, 2, 4, 8, 16] as const;
/** 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;
@@ -32,15 +35,15 @@ export const BUCKET_KIND_OPTIONS = [
{ value: 'linear', label: 'Linear' },
];
export const LOG_BANDS_OPTIONS = LOG_BANDS_PER_DOUBLING.map((bands) => ({
value: String(bands),
label: String(bands),
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 16 bands per doubling, the finest the query can return.',
log: 'Bounds are spaced evenly on a log axis, so every band is the same height on screen and the tail stays readable. Fewer bands per doubling means fewer, coarser bands.',
'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

@@ -50,11 +50,9 @@ export const logScaleOf = (
? (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 scaleFromBandsPerDoubling = (bands: number): number =>
Math.round(Math.log2(bands));
export const kindOptionOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): BucketKindOption => {

View File

@@ -409,7 +409,7 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('sends no options for Auto', async () => {
@@ -424,11 +424,11 @@ describe('QueryAddOns', () => {
);
});
it('sends the scale the picked bands per doubling resolve to', async () => {
it('sends the picked scale', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '1' }));
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
@@ -436,6 +436,18 @@ describe('QueryAddOns', () => {
});
});
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 } } });

View File

@@ -74,7 +74,7 @@ describe('Formula bucket options', () => {
});
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '1' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('writes the picked axis onto the formula', async () => {

View File

@@ -58,18 +58,14 @@
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, which `createHatchPattern`
// strokes in the theme's own direction — light on dark, dark on light.
.hatchSwatch {
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,

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

@@ -1,4 +1,8 @@
import { formatCount, HeatmapContributionRow } from './heatmapTooltipContent';
import {
formatCount,
formatShare,
HeatmapContributionRow,
} from './heatmapTooltipContent';
import Styles from './HeatmapTooltip.module.scss';
@@ -10,6 +14,10 @@ export default function HeatmapContributionList({
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
<div className={Styles.columnHeader}>
<span>Group</span>
<span>Contribution</span>
</div>
{rows.map((row) => (
<div
key={row.label}
@@ -24,6 +32,12 @@ export default function HeatmapContributionList({
<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.rowShare}
data-testid="heatmap-tooltip-contribution-share"
>
{formatShare(row.share)}
</span>
</div>
))}
</div>

View File

@@ -116,6 +116,20 @@
@include custom-scrollbar;
}
.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(--muted-foreground);
}
.row {
display: flex;
align-items: center;
@@ -152,6 +166,19 @@
white-space: nowrap;
}
.rowShare {
flex: 0 0 auto;
min-width: 34px;
text-align: right;
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: 12px;
height: 12px;

View File

@@ -213,6 +213,27 @@ describe('HeatmapTooltip — grouped, nothing selected', () => {
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', () => {
renderTooltip();

View File

@@ -43,6 +43,8 @@ export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Fraction of the cell this group contributed, 0..1. */
share: number;
}
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
@@ -103,6 +105,15 @@ export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
/** `<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%';
}
return `${Math.round(share * 100)}%`;
}
/** Highest first, so the list reads in the same direction as the y axis. */
export function buildBucketRows({
counts,
@@ -158,15 +169,22 @@ export function buildContributionRows({
/** The grid's one colour; there is no per-series hue. */
color: string;
}): HeatmapContributionRow[] {
return series
.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
return {
label: entry.label,
color,
// Absent or null contributed nothing to the sum this breaks down.
count: point?.counts[row] ?? 0,
};
})
const counted = series.map((entry) => {
const point = entry.points.find((item) => item.timestamp === timestamp);
return {
label: entry.label,
color,
// Absent or null contributed nothing to the sum this breaks down.
count: point?.counts[row] ?? 0,
};
});
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

@@ -1,3 +1,5 @@
import { Color } from '@signozhq/design-tokens';
import {
clampColorSteps,
createHeatmapColorResolver,
@@ -224,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]);
});

View File

@@ -1,5 +1,6 @@
import {
canUseLogAxis,
cropHeatmapYAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
@@ -58,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', () => {
@@ -265,10 +267,10 @@ describe('resolveHeatmapYAxis — the scale auto picks', () => {
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.Auto);
return edges.slice(1).map((edge, index) => edge - edges[index]);
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
@@ -330,6 +332,13 @@ describe('resolveHeatmapYAxis — the scale auto picks', () => {
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.Auto);
@@ -383,10 +392,10 @@ describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
// Clock skew in ms — a field that straddles zero.
const SKEW = [-100, -10, 0, 10, 100];
/** Row heights in axis units, which map linearly to pixels. */
/** 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).map((edge, index) => edge - edges[index]);
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
}
it('log stays a plain log10 for a positive layout', () => {
@@ -459,6 +468,54 @@ describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
});
});
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;

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

View File

@@ -25,6 +25,13 @@ 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,
@@ -228,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: [],
@@ -161,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);
@@ -173,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,
),
};
}
@@ -225,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[],

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

@@ -14,7 +14,10 @@ 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 {
cropHeatmapYAxis,
resolveHeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
@@ -26,8 +29,10 @@ import { HeatmapChartProps } from 'lib/visualization/charts/types';
import { useHeatmapGroupLegend } from './useHeatmapGroupLegend';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} from './utils';
/** Vertical space the colour bar takes out of the container. */
@@ -85,7 +90,7 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
const onCellClickRef = useRef(onCellClick);
onCellClickRef.current = onCellClick;
const groups = useMemo(() => series.map((entry) => entry.label), [series]);
const groups = useMemo(() => resolveGroupOrder(series), [series]);
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
@@ -113,8 +118,8 @@ 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,
@@ -291,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

View File

@@ -3,6 +3,7 @@ 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';
@@ -10,8 +11,10 @@ import { PrecisionOptionsEnum } from 'components/Graph/types';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} from '../utils';
const GRID: HeatmapGrid = {
@@ -260,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

@@ -15,6 +15,7 @@ import {
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
import {
HeatmapGrid,
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import uPlot from 'uplot';
@@ -74,6 +75,30 @@ export function resolveBoundaryPrecision({
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.

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

@@ -21,7 +21,11 @@ export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
label: 'Log',
icon: 'scale-log',
},
{ value: DashboardtypesHeatmapColorScaleDTO.sqrt, label: 'Sqrt' },
{
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
label: 'Sqrt',
icon: 'scale-sqrt',
},
{
value: DashboardtypesHeatmapColorScaleDTO.linear,
label: 'Linear',

View File

@@ -22,7 +22,11 @@ import {
} from '../../utils/chartAppearance/resolvers';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { prepareHeatmapData, resolveHeatmapStep } from './prepareData';
import {
prepareHeatmapData,
resolveHeatmapStep,
resolveRequestedBucketKind,
} from './prepareData';
function HeatmapPanelRenderer({
panelId,
@@ -74,8 +78,12 @@ function HeatmapPanelRenderer({
);
const axisScale = useMemo(
() => resolveHeatmapAxisScale(spec.axes?.yScale),
[spec.axes?.yScale],
() =>
resolveHeatmapAxisScale(
spec.axes?.yScale,
resolveRequestedBucketKind(data.requestPayload),
),
[spec.axes?.yScale, data.requestPayload],
);
const decimalPrecision = useMemo(

View File

@@ -1,12 +1,18 @@
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. */
@@ -149,3 +155,53 @@ describe('resolveHeatmapStep', () => {
).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

@@ -1,4 +1,4 @@
import { Grid3X3 } from '@signozhq/icons';
import { LayoutGrid } from '@signozhq/icons';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
@@ -16,7 +16,7 @@ export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
kind: 'signoz/HeatmapPanel',
displayName: 'Heatmap',
mode: 'query',
icon: Grid3X3,
icon: LayoutGrid,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,

View File

@@ -1,4 +1,6 @@
import type {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5QueryRangeRequestDTO,
Querybuildertypesv5TimeSeriesDataDTO,
Querybuildertypesv5TimeSeriesDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -61,6 +63,25 @@ export function prepareHeatmapData({
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

View File

@@ -7,10 +7,11 @@ 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 } },
{ kind: SectionKind.ChartAppearance, controls: { colors: true } },
];

View File

@@ -3,6 +3,8 @@ import {
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
DashboardtypesHeatmapYScaleDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTOKind,
} from 'api/generated/services/sigNoz.schemas';
import {
HeatmapAxisScale,
@@ -70,6 +72,39 @@ describe('resolveHeatmapAxisScale', () => {
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', () => {

View File

@@ -3,8 +3,10 @@ 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';
@@ -82,14 +84,24 @@ export function resolveLegendPosition(
/**
* 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 {
if (yScale && yScale in HEATMAP_Y_SCALE_MAP) {
return HEATMAP_Y_SCALE_MAP[yScale];
const configured =
yScale && yScale in HEATMAP_Y_SCALE_MAP
? HEATMAP_Y_SCALE_MAP[yScale]
: HeatmapAxisScale.Auto;
if (configured !== HeatmapAxisScale.Auto) {
return configured;
}
return HeatmapAxisScale.Auto;
return bucketKind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear
? HeatmapAxisScale.Linear
: HeatmapAxisScale.Auto;
}
/**