mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-22 19:30:43 +01:00
Compare commits
15 Commits
feat/heatm
...
fix/heatma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e72b40b144 | ||
|
|
cd4791bc67 | ||
|
|
bdf991964b | ||
|
|
dc0da3eee7 | ||
|
|
41c0a3754c | ||
|
|
4c3469a5ca | ||
|
|
23cf713be4 | ||
|
|
9be4db51bd | ||
|
|
bb9c3fc095 | ||
|
|
a093eb041a | ||
|
|
308b05ec3b | ||
|
|
64193f0376 | ||
|
|
f0e7663d85 | ||
|
|
8a4965d1fb | ||
|
|
1041801cc1 |
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 } } });
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } },
|
||||
];
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user