Compare commits

..

7 Commits

Author SHA1 Message Date
Gaurav Tewari
084d9aad91 refactor: trace details and and header changes 2026-10-06 22:29:02 +05:30
Gaurav Tewari
2466aa166a feat: trace detail header & tooltip changes 2026-10-06 21:54:29 +05:30
Gaurav Tewari
82a588c15b feat: update badge color 2026-10-06 19:24:48 +05:30
Gaurav Tewari
13be8de648 feat: add support for badge and update ai icons 2026-10-06 19:01:40 +05:30
Gaurav Tewari
58bb656a11 chore: update tooltip design and support ai spans 2026-10-06 18:42:47 +05:30
Gaurav Tewari
f6f95d4f7c chore: another refactor approch 2026-10-06 15:50:13 +05:30
Gaurav Tewari
528cb41551 feat: add trace tab and initial refactor 2026-10-06 14:48:35 +05:30
185 changed files with 2303 additions and 13417 deletions

View File

@@ -3731,84 +3731,6 @@ components:
hide:
type: boolean
type: object
DashboardtypesHeatmapAxes:
properties:
"y":
$ref: '#/components/schemas/DashboardtypesHeatmapAxis'
type: object
DashboardtypesHeatmapAxis:
properties:
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -4162,7 +4084,6 @@ components:
mapping:
signoz/AreaChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec'
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -4181,7 +4102,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4194,7 +4114,6 @@ components:
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4220,18 +4139,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -9134,15 +9041,6 @@ components:
- alertType
- ruleType
type: object
RuletypesListableRuleViews:
properties:
views:
items:
$ref: '#/components/schemas/RuletypesRuleView'
type: array
required:
- views
type: object
RuletypesListableRules:
properties:
labels:
@@ -9240,16 +9138,6 @@ components:
- ruleType
- condition
type: object
RuletypesPostableRuleView:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
name:
type: string
required:
- name
- data
type: object
RuletypesQueryType:
enum:
- builder
@@ -9388,45 +9276,6 @@ components:
- promql_rule
- anomaly_rule
type: string
RuletypesRuleView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/RuletypesRuleViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
RuletypesRuleViewData:
properties:
order:
$ref: '#/components/schemas/RuletypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/RuletypesListSort'
states:
items:
type: string
type: array
version:
type: string
required:
- version
type: object
RuletypesScheduleType:
enum:
- hourly
@@ -21344,235 +21193,6 @@ paths:
tags:
- users
x-signoz-stability: alpha
/api/v2/rule_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListRuleViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesListableRuleViews'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List rule saved views
tags:
- rules
x-signoz-stability: alpha
post:
deprecated: false
description: Persists the calling user's rule listing state (query, states,
sort, order) as a named, reusable view shared across the org.
operationId: CreateRuleView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Create rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rule_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete rule saved view
tags:
- rules
x-signoz-stability: alpha
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateRuleView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RuletypesPostableRuleView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRuleView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update rule saved view
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules:
get:
deprecated: true

View File

@@ -2,8 +2,6 @@
// Mock for uplot library used in tests
export interface MockUPlotInstance {
/** Consumers read `root.parentElement` to detect a re-mounted container. */
root: HTMLDivElement;
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
@@ -19,20 +17,13 @@ export interface MockUPlotPaths {
}
// Create mock instance methods
const createMockUPlotInstance = (target?: HTMLElement): MockUPlotInstance => {
const root = document.createElement('div');
// Real uPlot mounts its root inside the target; without it a re-render reads
// `root.parentElement` off undefined and throws.
target?.appendChild(root);
return {
root,
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
};
};
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
@@ -62,16 +53,14 @@ const mockTzDate = jest.fn(
function MockUPlot(
_options: unknown,
_data: unknown,
target: HTMLElement,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance(target);
return createMockUPlotInstance();
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;
// Export the constructor as default
export default MockUPlot;

View File

@@ -19,9 +19,7 @@ import type {
import type {
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
DeleteRuleViewPathParameters,
GetRuleByID200,
GetRuleByIDPathParameters,
GetRuleHistoryFilterKeys200,
@@ -42,7 +40,6 @@ import type {
GetRuleHistoryTopContributors200,
GetRuleHistoryTopContributorsParams,
GetRuleHistoryTopContributorsPathParameters,
ListRuleViews200,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
@@ -50,11 +47,8 @@ import type {
PatchRuleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPostableRuleDTO,
RuletypesPostableRuleViewDTO,
TestRule200,
UpdateRuleByIDPathParameters,
UpdateRuleView200,
UpdateRuleViewPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -80,351 +74,6 @@ const withQueryKey = <T extends object, K>(
return result;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List rule saved views
*/
export const listRuleViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListRuleViews200>({
url: `/api/v2/rule_views`,
method: 'GET',
signal,
});
};
export const getListRuleViewsQueryKey = () => {
return [`/api/v2/rule_views`] as const;
};
export const getListRuleViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListRuleViewsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRuleViews>>> = ({
signal,
}) => listRuleViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListRuleViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listRuleViews>>
>;
export type ListRuleViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List rule saved views
*/
export function useListRuleViews<
TData = Awaited<ReturnType<typeof listRuleViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listRuleViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListRuleViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List rule saved views
*/
export const invalidateListRuleViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListRuleViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's rule listing state (query, states, sort, order) as a named, reusable view shared across the org.
* @summary Create rule saved view
*/
export const createRuleView = (
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateRuleView201>({
url: `/api/v2/rule_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getCreateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
const mutationKey = ['createRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createRuleView>>,
{ data?: BodyType<RuletypesPostableRuleViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createRuleView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createRuleView>>
>;
export type CreateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type CreateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create rule saved view
*/
export const useCreateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createRuleView>>,
TError,
{ data?: BodyType<RuletypesPostableRuleViewDTO> },
TContext
> => {
return useMutation(getCreateRuleViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete rule saved view
*/
export const deleteRuleView = (
{ id }: DeleteRuleViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/rule_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
const mutationKey = ['deleteRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteRuleView>>,
{ pathParams: DeleteRuleViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteRuleView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteRuleView>>
>;
export type DeleteRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete rule saved view
*/
export const useDeleteRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteRuleView>>,
TError,
{ pathParams: DeleteRuleViewPathParameters },
TContext
> => {
return useMutation(getDeleteRuleViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update rule saved view
*/
export const updateRuleView = (
{ id }: UpdateRuleViewPathParameters,
ruletypesPostableRuleViewDTO?: BodyType<RuletypesPostableRuleViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateRuleView200>({
url: `/api/v2/rule_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: ruletypesPostableRuleViewDTO,
signal,
});
};
export const getUpdateRuleViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
const mutationKey = ['updateRuleView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateRuleView>>,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateRuleView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateRuleViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateRuleView>>
>;
export type UpdateRuleViewMutationBody =
| BodyType<RuletypesPostableRuleViewDTO>
| undefined;
export type UpdateRuleViewMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update rule saved view
*/
export const useUpdateRuleView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateRuleView>>,
TError,
{
pathParams: UpdateRuleViewPathParameters;
data?: BodyType<RuletypesPostableRuleViewDTO>;
},
TContext
> => {
return useMutation(getUpdateRuleViewMutationOptions(options));
};
/**
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
* @deprecated

View File

@@ -5291,87 +5291,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesTextPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind {
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
export enum DashboardtypesHeatmapYScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesHeatmapAxisDTO {
scale?: DashboardtypesHeatmapYScaleDTO;
}
export interface DashboardtypesHeatmapAxesDTO {
y?: DashboardtypesHeatmapAxisDTO;
}
export enum DashboardtypesHeatmapColorModeDTO {
palette = 'palette',
opacity = 'opacity',
}
export enum DashboardtypesHeatmapPaletteDTO {
ice = 'ice',
moss = 'moss',
rust = 'rust',
graphite = 'graphite',
ember = 'ember',
lagoon = 'lagoon',
orchid = 'orchid',
verdant = 'verdant',
lava = 'lava',
beacon = 'beacon',
}
export enum DashboardtypesHeatmapColorScaleDTO {
log = 'log',
sqrt = 'sqrt',
linear = 'linear',
}
export interface DashboardtypesHeatmapColorsDTO {
/**
* @type string
*/
fill?: string;
/**
* @type number,null
*/
maxCount?: number | null;
/**
* @type number,null
*/
minCount?: number | null;
mode?: DashboardtypesHeatmapColorModeDTO;
palette?: DashboardtypesHeatmapPaletteDTO;
scale?: DashboardtypesHeatmapColorScaleDTO;
/**
* @type integer
*/
steps?: number;
}
export interface DashboardtypesHeatmapChartAppearanceDTO {
colors?: DashboardtypesHeatmapColorsDTO;
}
export interface DashboardtypesHeatmapPanelSpecDTO {
axes?: DashboardtypesHeatmapAxesDTO;
chartAppearance?: DashboardtypesHeatmapChartAppearanceDTO;
formatting?: DashboardtypesPanelFormattingDTO;
legend?: DashboardtypesLegendDTO;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO {
/**
* @enum signoz/HeatmapPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTOKind;
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5381,8 +5300,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6308,7 +6226,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
}
/**
* @nullable
@@ -10411,56 +10328,6 @@ export interface RuletypesListableRuleDTO {
updatedBy?: string;
}
export interface RuletypesRuleViewDataDTO {
order?: RuletypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: RuletypesListSortDTO;
/**
* @type array
*/
states?: string[];
/**
* @type string
*/
version: string;
}
export interface RuletypesRuleViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export interface RuletypesListableRuleViewsDTO {
/**
* @type array
*/
views: RuletypesRuleViewDTO[];
}
export interface RuletypesListableRulesDTO {
/**
* @type array
@@ -10629,14 +10496,6 @@ export interface RuletypesPostableRuleDTO {
version?: string;
}
export interface RuletypesPostableRuleViewDTO {
data: RuletypesRuleViewDataDTO;
/**
* @type string
*/
name: string;
}
export type RuletypesRuleDTOAnnotations = { [key: string]: string };
export type RuletypesRuleDTOLabels = { [key: string]: string };
@@ -13924,36 +13783,6 @@ export type GetUsersByRoleID200 = {
status: string;
};
export type ListRuleViews200 = {
data: RuletypesListableRuleViewsDTO;
/**
* @type string
*/
status: string;
};
export type CreateRuleView201 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteRuleViewPathParameters = {
id: string;
};
export type UpdateRuleViewPathParameters = {
id: string;
};
export type UpdateRuleView200 = {
data: RuletypesRuleViewDTO;
/**
* @type string
*/
status: string;
};
export type ListRules200 = {
/**
* @type array

View File

@@ -198,7 +198,6 @@ function createBaseSpec(
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
bucketOptions: queryData.bucketOptions,
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {

View File

@@ -5,10 +5,6 @@
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

@@ -1,142 +0,0 @@
/**
* Borrows the query builder's control metrics rather than the component defaults the
* toggle group and number input ship with: 36px tall, 2px radius, and the
* `--query-builder-v2-*` surface the selects above this row already paint on.
*/
.bucketOptions {
--toggle-group-radius: var(--radius-1);
--toggle-group-item-size: 36px;
--toggle-group-item-font-size: 13px;
--toggle-group-item-padding-left: var(--spacing-6);
--toggle-group-item-padding-right: var(--spacing-6);
// Repeated from `.query-add-ons` rather than inherited: the section also renders on a
// formula, which has no add-ons ancestor to pick the toggle palette up from.
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
--input-height: 36px;
--input-font-size: 13px;
--input-border-radius: var(--radius-1);
--input-border-color: var(--query-builder-v2-border-color, var(--l2-border));
--input-background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
--input-foreground: var(--query-builder-v2-color, var(--l2-foreground));
display: flex;
flex-direction: column;
gap: var(--spacing-6);
padding: var(--spacing-4);
box-sizing: border-box;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-radius: var(--radius-1);
background: var(--query-builder-v2-background-color, var(--l2-background));
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
/** One label paired with its control, at the same 10px rhythm as the aggregate rows. */
.field {
display: flex;
align-items: center;
gap: var(--spacing-5);
}
.bounds {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-6);
}
.label {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
color: var(--l3-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: var(--font-weight-medium);
line-height: 18px;
letter-spacing: 0.48px;
text-transform: uppercase;
white-space: nowrap;
}
/**
* The toggle group exposes a font-size token but no family, so each item's label carries
* the query builder's value type itself.
*/
.toggleLabel {
font-family: 'Geist Mono';
font-size: 13px;
font-weight: var(--font-weight-normal);
letter-spacing: -0.07px;
}
/** Surface and metrics come from the `--input-*` tokens above; only the family has none. */
.numberInput {
width: 104px;
font-family: 'Geist Mono';
letter-spacing: -0.07px;
}
/** Unfilled: a pill on the same row as the toggles above reads as another control,
* and the bounds are derived rather than set here. */
.bound {
padding: var(--spacing-1) 0;
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
color: var(--query-builder-v2-color, var(--l2-foreground));
}
.overflowBound {
color: var(--l3-foreground);
}
.muted {
font-family: 'Geist Mono';
font-size: 13px;
line-height: 20px;
color: var(--l3-foreground);
}
.hintIcon {
flex: none;
cursor: help;
color: var(--l3-foreground);
}
.closeButton {
margin-left: auto;
width: 24px;
height: 24px;
}

View File

@@ -1,279 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Tooltip } from 'antd';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { InputNumber } from '@signozhq/ui/input-number';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { ChevronUp, Info } from '@signozhq/icons';
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
BUCKET_KIND_HINTS,
BUCKET_KIND_OPTIONS,
DEFAULT_NUM_BUCKETS,
LOG_SCALE_OPTIONS,
MAX_NUM_BUCKETS,
} from './constants';
import {
formatUpperBound,
hasBoundsBeyondPreview,
isLinearBuckets,
kindOptionOf,
linearBuckets,
logBuckets,
logScaleOf,
previewUpperBounds,
} from './utils';
import styles from './BucketOptions.module.scss';
function BucketOptions({
bucketOptions,
unit,
onChange,
onClose,
}: {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/** The panel's y-axis unit, so the previewed bounds read the way the axis will. */
unit?: string;
onChange: (next: Querybuildertypesv5BucketOptionsDTO | undefined) => void;
/** Omitted where the section isn't dismissable, as on a formula. */
onClose?: () => void;
}): JSX.Element {
// A linear axis has no bounds to describe until it has a max value, so the picked
// kind is held here rather than read back off the emitted options: it has to survive
// the gap between choosing Linear and filling the field in.
const [kind, setKind] = useState<Querybuildertypesv5BucketsKindDTO>(() =>
kindOptionOf(bucketOptions),
);
const linearSpec =
bucketOptions && isLinearBuckets(bucketOptions)
? bucketOptions.spec
: undefined;
const [logScale, setLogScale] = useState<number>(() =>
logScaleOf(bucketOptions),
);
const [maxValue, setMaxValue] = useState<number | null>(
linearSpec?.maxValue ?? null,
);
const [numBuckets, setNumBuckets] = useState<number | null>(
linearSpec?.numBuckets ?? null,
);
const emitLinear = useCallback(
(nextMaxValue: number | null, nextNumBuckets: number | null): void => {
// An incomplete linear axis is sent as no axis at all rather than as a spec the
// request would reject.
if (nextMaxValue === null || nextMaxValue <= 0) {
onChange(undefined);
return;
}
onChange(linearBuckets(nextMaxValue, nextNumBuckets));
},
[onChange],
);
const handleKindChange = useCallback(
(value: string): void => {
// Radix clears the value when the active item is clicked again; a bucket axis is
// always one of the two, so keep the current pick instead.
if (!value) {
return;
}
const nextKind = value as Querybuildertypesv5BucketsKindDTO;
setKind(nextKind);
if (nextKind === Querybuildertypesv5BucketsKindDTO.log) {
onChange(logBuckets(logScale));
} else {
emitLinear(maxValue, numBuckets);
}
},
[emitLinear, logScale, maxValue, numBuckets, onChange],
);
const handleScaleChange = useCallback(
(value: string): void => {
if (!value) {
return;
}
const nextScale = Number(value);
setLogScale(nextScale);
onChange(logBuckets(nextScale));
},
[onChange],
);
const handleMaxValueChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setMaxValue(next);
emitLinear(next, numBuckets);
},
[emitLinear, numBuckets],
);
const handleNumBucketsChange = useCallback(
(value: number | string | null): void => {
const next = value === null || value === '' ? null : Number(value);
setNumBuckets(next);
emitLinear(maxValue, next);
},
[emitLinear, maxValue],
);
// The toggle group takes a ReactNode label, which is how each item picks up the
// query builder's value type — the component exposes no font-family token.
const kindItems = useMemo(
() =>
BUCKET_KIND_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
const scaleItems = useMemo(
() =>
LOG_SCALE_OPTIONS.map(({ value, label }) => ({
value,
label: <span className={styles.toggleLabel}>{label}</span>,
'aria-label': label,
})),
[],
);
// The kind toggle can be ahead of what has been emitted, so the preview describes
// the picked kind rather than the emitted options.
const previewedOptions = useMemo(():
| Querybuildertypesv5BucketOptionsDTO
| undefined => {
if (kind === Querybuildertypesv5BucketsKindDTO.log) {
return logBuckets(logScale);
}
if (kind === Querybuildertypesv5BucketsKindDTO.linear && maxValue !== null) {
return linearBuckets(maxValue, numBuckets);
}
return undefined;
}, [kind, logScale, maxValue, numBuckets]);
const bounds = useMemo(
() =>
kind === Querybuildertypesv5BucketsKindDTO.linear && !previewedOptions
? undefined
: previewUpperBounds(previewedOptions),
[kind, previewedOptions],
);
return (
<div className={styles.bucketOptions} data-testid="bucket-options">
<div className={styles.controls}>
<div className={styles.field}>
<span className={styles.label}>Bucket by</span>
<ToggleGroupSimple
type="single"
value={kind}
items={kindItems}
onChange={handleKindChange}
testId="bucket-options-kind"
/>
</div>
{kind === Querybuildertypesv5BucketsKindDTO.log && (
<div className={styles.field}>
<span className={styles.label}>Scale</span>
<ToggleGroupSimple
type="single"
value={String(logScale)}
items={scaleItems}
onChange={handleScaleChange}
testId="bucket-options-scale"
/>
</div>
)}
{kind === Querybuildertypesv5BucketsKindDTO.linear && (
<>
<div className={styles.field}>
<span className={styles.label}>Max value</span>
<InputNumber
className={styles.numberInput}
min={0}
value={maxValue}
onChange={handleMaxValueChange}
placeholder="Required"
status={maxValue !== null && maxValue <= 0 ? 'error' : undefined}
data-testid="bucket-options-max-value"
/>
</div>
<div className={styles.field}>
<span className={styles.label}>Buckets</span>
<InputNumber
className={styles.numberInput}
min={1}
max={MAX_NUM_BUCKETS}
precision={0}
value={numBuckets}
onChange={handleNumBucketsChange}
placeholder={`Default ${DEFAULT_NUM_BUCKETS}`}
data-testid="bucket-options-num-buckets"
/>
</div>
</>
)}
{onClose && (
<Button
color="secondary"
variant="ghost"
size="icon"
aria-label="Close bucket options"
className={styles.closeButton}
prefix={<ChevronUp size={16} />}
onClick={onClose}
data-testid="bucket-options-close"
/>
)}
</div>
<div className={styles.bounds} data-testid="bucket-options-bounds">
<span className={styles.label}>
Bounds
<Tooltip title={BUCKET_KIND_HINTS[kind]} placement="top">
<Info size={12} className={styles.hintIcon} />
</Tooltip>
</span>
{bounds ? (
<>
{bounds.map((bound) => (
<span className={styles.bound} key={bound}>
{formatUpperBound(bound, unit)}
</span>
))}
{hasBoundsBeyondPreview(previewedOptions) && (
<span className={styles.muted}>…</span>
)}
<span className={cx(styles.bound, styles.overflowBound)}>+Inf</span>
</>
) : (
<span className={styles.muted}>Set a max value to see the bounds</span>
)}
</div>
</div>
);
}
BucketOptions.defaultProps = {
bucketOptions: undefined,
unit: undefined,
onClose: undefined,
};
export default BucketOptions;

View File

@@ -1,88 +0,0 @@
import { MAX_LOG_SCALE } from '../constants';
import {
bandsPerDoublingFromScale,
formatUpperBound,
hasBoundsBeyondPreview,
kindOptionOf,
linearBuckets,
logBuckets,
previewUpperBounds,
} from '../utils';
describe('bucket option scales', () => {
it.each([
[-2, 0.25],
[0, 1],
[1, 2],
[2, 4],
[3, 8],
[4, 16],
])('scale %i is %i bands per doubling', (scale, bands) => {
expect(bandsPerDoublingFromScale(scale)).toBe(bands);
});
});
describe('kindOptionOf', () => {
it('reads no options as log, the server default', () => {
expect(kindOptionOf(undefined)).toBe('log');
});
it('reads the kind off the options', () => {
expect(kindOptionOf(logBuckets(MAX_LOG_SCALE))).toBe('log');
expect(kindOptionOf(linearBuckets(10, null))).toBe('linear');
});
});
describe('previewUpperBounds', () => {
it('doubles at one band per doubling', () => {
expect(previewUpperBounds(logBuckets(0))).toStrictEqual([
1, 2, 4, 8, 16, 32, 64, 128,
]);
});
it('falls back to the finest log axis when no options are set', () => {
const bounds = previewUpperBounds(undefined);
expect(bounds).toHaveLength(8);
// 16 bands per doubling: the eighth bound is 2^(7/16), still short of the first doubling.
expect(bounds?.[7]).toBeCloseTo(2 ** (7 / 16));
});
it('spaces a linear axis evenly by bucket width', () => {
expect(previewUpperBounds(linearBuckets(100, 10))).toStrictEqual(
[10, 20, 30, 40, 50, 60, 70, 80, 90, 100].slice(0, 8),
);
});
it('stops at the last bucket when the axis has fewer than the preview shows', () => {
expect(previewUpperBounds(linearBuckets(9, 3))).toStrictEqual([3, 6, 9]);
});
it('has no bounds to preview for a linear axis without a usable max value', () => {
expect(previewUpperBounds(linearBuckets(0, null))).toBeUndefined();
expect(previewUpperBounds(linearBuckets(-1, null))).toBeUndefined();
});
});
describe('hasBoundsBeyondPreview', () => {
it('is always true for a log axis, which has no top', () => {
expect(hasBoundsBeyondPreview(logBuckets(0))).toBe(true);
expect(hasBoundsBeyondPreview(undefined)).toBe(true);
});
it('tracks whether a linear axis runs past the preview', () => {
expect(hasBoundsBeyondPreview(linearBuckets(100, 4))).toBe(false);
expect(hasBoundsBeyondPreview(linearBuckets(100, 20))).toBe(true);
});
});
describe('formatUpperBound', () => {
it('reads a bound in the panel unit when one is set', () => {
expect(formatUpperBound(1, 'ms')).toContain('ms');
});
it('keeps three significant digits when unitless', () => {
expect(formatUpperBound(128, undefined)).toBe('128');
expect(formatUpperBound(2 ** (1 / 16), undefined)).toBe('1.04');
});
});

View File

@@ -1,54 +0,0 @@
import { Querybuildertypesv5BucketsKindDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Mirrors the limits `querybuildertypesv5` validates `bucketOptions` against. The
* builder keeps its own copy so an out-of-range axis is refused before the request
* rather than after it.
*/
/**
* A log axis spaces bounds at 2^scale bands per doubling. MaxLogScale is the
* resolution ClickHouse buckets at, so it is both the finest available and what an
* absent `bucketOptions` resolves to.
*/
export const MAX_LOG_SCALE = 4;
/** One band per 16x, the coarsest axis worth rendering. */
export const MIN_LOG_SCALE = -4;
export const MAX_NUM_BUCKETS = 512;
export const DEFAULT_NUM_BUCKETS = 60;
/** Every scale the request accepts, coarsest first. The bounds strip below says
* how coarse a given one is, so the numbers stand alone. */
export const LOG_SCALES = Array.from(
{ length: MAX_LOG_SCALE - MIN_LOG_SCALE + 1 },
(_, index) => MIN_LOG_SCALE + index,
);
/** How many leading upper bounds the bounds strip previews before eliding. */
export const PREVIEW_BOUND_COUNT = 8;
export const BUCKET_KIND_OPTIONS: {
value: Querybuildertypesv5BucketsKindDTO;
label: string;
}[] = [
{ value: Querybuildertypesv5BucketsKindDTO.log, label: 'Log' },
{ value: Querybuildertypesv5BucketsKindDTO.linear, label: 'Linear' },
];
export const LOG_SCALE_OPTIONS = LOG_SCALES.map((scale) => ({
value: String(scale),
label: String(scale),
}));
export const BUCKET_KIND_HINTS: Record<
Querybuildertypesv5BucketsKindDTO,
string
> = {
[Querybuildertypesv5BucketsKindDTO.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; scale 4, the finest, is the default.',
[Querybuildertypesv5BucketsKindDTO.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

@@ -1,120 +0,0 @@
import {
Querybuildertypesv5BucketOptionsDTO,
Querybuildertypesv5BucketOptionsLinearDTO,
Querybuildertypesv5BucketOptionsLinearDTOKind,
Querybuildertypesv5BucketOptionsLogDTO,
Querybuildertypesv5BucketOptionsLogDTOKind,
Querybuildertypesv5BucketsKindDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import {
DEFAULT_NUM_BUCKETS,
MAX_LOG_SCALE,
PREVIEW_BOUND_COUNT,
} from './constants';
export const isLinearBuckets = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO,
): bucketOptions is Querybuildertypesv5BucketOptionsLinearDTO =>
bucketOptions.kind === Querybuildertypesv5BucketOptionsLinearDTOKind.linear;
export const linearBuckets = (
maxValue: number,
numBuckets: number | null,
): Querybuildertypesv5BucketOptionsLinearDTO => ({
kind: Querybuildertypesv5BucketOptionsLinearDTOKind.linear,
spec: { maxValue, ...(numBuckets ? { numBuckets } : {}) },
});
export const logBuckets = (
scale: number,
): Querybuildertypesv5BucketOptionsLogDTO => ({
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale },
});
/**
* The log spec's scale, defaulted the way the server defaults it. A linear axis has
* no scale, so it reads as the default too.
*/
export const logScaleOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number =>
bucketOptions && !isLinearBuckets(bucketOptions)
? (bucketOptions.spec.scale ?? MAX_LOG_SCALE)
: MAX_LOG_SCALE;
/** A log axis places `2^scale` bounds per doubling. */
export const bandsPerDoublingFromScale = (scale: number): number => 2 ** scale;
export const kindOptionOf = (
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): Querybuildertypesv5BucketsKindDTO =>
bucketOptions && isLinearBuckets(bucketOptions)
? Querybuildertypesv5BucketsKindDTO.linear
: Querybuildertypesv5BucketsKindDTO.log;
/**
* The leading upper bounds the axis will carry. A log axis is anchored at 1 — band
* index 0's boundary — and a linear one at the top of its first band; both continue
* past what the strip shows, and everything above the last one lands in the overflow
* band the UI labels separately.
*
* `undefined` for a linear axis with no max value yet: without a top there is nothing
* to divide.
*/
export function previewUpperBounds(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): number[] | undefined {
if (bucketOptions && isLinearBuckets(bucketOptions)) {
const { maxValue, numBuckets = DEFAULT_NUM_BUCKETS } = bucketOptions.spec;
if (!Number.isFinite(maxValue) || maxValue <= 0 || numBuckets <= 0) {
return undefined;
}
const width = maxValue / numBuckets;
return Array.from(
{ length: Math.min(numBuckets, PREVIEW_BOUND_COUNT) },
(_, index) => (index + 1) * width,
);
}
const bands = bandsPerDoublingFromScale(logScaleOf(bucketOptions));
return Array.from(
{ length: PREVIEW_BOUND_COUNT },
(_, index) => 2 ** (index / bands),
);
}
/**
* Whether the strip elides bounds after the ones it shows. A linear axis with no more
* buckets than the strip holds ends where the strip does.
*/
export function hasBoundsBeyondPreview(
bucketOptions: Querybuildertypesv5BucketOptionsDTO | undefined,
): boolean {
if (!bucketOptions || !isLinearBuckets(bucketOptions)) {
return true;
}
return (
(bucketOptions.spec.numBuckets ?? DEFAULT_NUM_BUCKETS) > PREVIEW_BOUND_COUNT
);
}
/**
* A bound is a value on the panel's own axis, so it reads in the panel's unit when one
* is set. Unitless, three significant digits keep the tightly spaced bounds of a fine
* log axis distinguishable without printing the float in full.
*/
export function formatUpperBound(bound: number, unit?: string): string {
if (unit) {
return getYAxisFormattedValue(String(bound), unit);
}
return Number(bound.toPrecision(3)).toString();
}

View File

@@ -3,21 +3,14 @@ import { Button, Tooltip } from 'antd';
import cx from 'classnames';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GroupByFilter } from 'container/QueryBuilder/filters/GroupByFilter/GroupByFilter';
import { OrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter';
import { ReduceToFilter } from 'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import {
BarChart,
ChevronUp,
ExternalLink,
Grid3X3,
ScrollText,
} from '@signozhq/icons';
import { Querybuildertypesv5BucketOptionsDTO } from 'api/generated/services/sigNoz.schemas';
import { BarChart, ChevronUp, ExternalLink, ScrollText } from '@signozhq/icons';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -32,7 +25,6 @@ import {
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
import { buildDefaultLegendFromGroupBy } from './utils';
@@ -65,7 +57,6 @@ const ADD_ONS_KEYS_TO_QUERY_PATH: Omit<
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.Legend]: 'legend',
[QueryBuilderField.ReduceTo]: 'reduceTo',
[QueryBuilderField.BucketOptions]: 'bucketOptions',
};
const ADD_ONS: AddOn[] = [
@@ -125,22 +116,6 @@ const REDUCE_TO: AddOn = {
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
};
// Offered only by a heatmap over metrics: the bucket axis is what a heatmap plots
// against, and no other panel type sends one. A histogram brings its own buckets.
const HISTOGRAM_ATTRIBUTE_TYPES = new Set<string>([
ATTRIBUTE_TYPES.HISTOGRAM,
ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM,
]);
const BUCKET_OPTIONS: AddOn = {
icon: <Grid3X3 size={14} />,
label: 'Bucket by',
key: QueryBuilderField.BucketOptions,
description:
'Choose how the bucket axis is spaced — logarithmically, so every band is the same height and the tail stays readable, or linearly up to a max value. Left to Auto, the query picks the finest log axis it can return.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/',
};
const hasValue = (value: unknown): boolean =>
value != null && value !== '' && !(Array.isArray(value) && value.length === 0);
@@ -221,7 +196,7 @@ function QueryAddOns({
isForTraceOperator,
});
const { handleSetQueryData, currentQuery } = useQueryBuilder();
const { handleSetQueryData } = useQueryBuilder();
const supportedAddOns = useMemo((): AddOn[] => {
let addOns: AddOn[];
@@ -235,25 +210,8 @@ function QueryAddOns({
addOns = [...ADD_ONS];
}
if (showReduceTo) {
addOns = [...addOns, REDUCE_TO];
}
if (
panelType === PANEL_TYPES.HEATMAP &&
query.dataSource === DataSource.METRICS &&
!HISTOGRAM_ATTRIBUTE_TYPES.has(query.aggregateAttribute?.type ?? '')
) {
addOns = [...addOns, BUCKET_OPTIONS];
}
return addOns;
}, [
panelType,
query.dataSource,
query.aggregateAttribute?.type,
showReduceTo,
]);
return showReduceTo ? [...addOns, REDUCE_TO] : addOns;
}, [panelType, query.dataSource, showReduceTo]);
const resolvedFields = useMemo(
() =>
@@ -434,13 +392,6 @@ function QueryAddOns({
[handleChangeQueryData],
);
const handleChangeBucketOptions = useCallback(
(value: Querybuildertypesv5BucketOptionsDTO | undefined) => {
handleChangeQueryData('bucketOptions', value);
},
[handleChangeQueryData],
);
return (
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
@@ -608,19 +559,6 @@ function QueryAddOns({
/>
</div>
)}
{selectedViews.find(
(view) => view.key === QueryBuilderField.BucketOptions,
) && (
<div className="add-on-content" data-testid="bucket-options-content">
<BucketOptions
bucketOptions={query.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
onClose={(): void => handleRemoveView(QueryBuilderField.BucketOptions)}
/>
</div>
)}
</div>
)}

View File

@@ -1,4 +1,4 @@
import { ATTRIBUTE_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
fireEvent,
render,
@@ -26,10 +26,8 @@ jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): {
handleSetQueryData: typeof mockHandleSetQueryData;
currentQuery: { unit: string | undefined };
} => ({
handleSetQueryData: mockHandleSetQueryData,
currentQuery: { unit: undefined },
}),
}));
@@ -332,175 +330,4 @@ describe('QueryAddOns', () => {
expect.anything(),
);
});
describe('bucket options', () => {
function renderHeatmap(overrides: Partial<any> = {}): void {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS, ...overrides })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
}
it('is offered on a metrics heatmap only', () => {
renderHeatmap();
expect(
screen.getByTestId('query-add-on-bucket_options'),
).toBeInTheDocument();
});
it('is not offered on other panel types', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.METRICS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it('is not offered on a heatmap over another signal', () => {
render(
<QueryAddOns
query={baseQuery({ dataSource: DataSource.LOGS })}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.HEATMAP}
index={0}
isForTraceOperator={false}
/>,
);
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
});
it.each([ATTRIBUTE_TYPES.HISTOGRAM, ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM])(
'is not offered for a %s metric, which carries its own buckets',
(type) => {
renderHeatmap({
aggregateAttribute: { key: 'http_duration_bucket', type },
});
expect(
screen.queryByTestId('query-add-on-bucket_options'),
).not.toBeInTheDocument();
},
);
it("auto-opens on the query's own kind", () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
expect(screen.getByTestId('bucket-options-content')).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('shows the server default, log at scale 4, without sending it', async () => {
const user = userEvent.setup();
renderHeatmap();
await user.click(screen.getByTestId('query-add-on-bucket_options'));
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '4' })).toBeChecked();
expect(
screen.queryByRole('radio', { name: 'Auto' }),
).not.toBeInTheDocument();
expect(mockHandleChangeQueryData).not.toHaveBeenCalledWith(
'bucketOptions',
expect.anything(),
);
});
it('sends the picked scale', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 4 } } });
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
it('offers the coarser scales the request accepts below one band per doubling', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: '-4' }));
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: -4 },
});
});
it('previews the bounds the picked axis will carry', () => {
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
const bounds = within(screen.getByTestId('bucket-options-bounds'));
['1', '2', '4', '8', '16', '32', '64', '128', '+Inf'].forEach((bound) => {
expect(bounds.getByText(bound)).toBeInTheDocument();
});
});
it('sends nothing for a linear axis until it has a max value', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
undefined,
);
expect(
screen.getByText('Set a max value to see the bounds'),
).toBeInTheDocument();
});
it('sends the linear axis once a max value is filled in', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByRole('radio', { name: 'Linear' }));
await user.type(screen.getByTestId('bucket-options-max-value'), '500');
await waitFor(() => {
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith(
'bucketOptions',
{ kind: 'linear', spec: { maxValue: 500 } },
);
});
});
it('closes back to the toggle bar', async () => {
const user = userEvent.setup();
renderHeatmap({ bucketOptions: { kind: 'log', spec: { scale: 0 } } });
await user.click(screen.getByTestId('bucket-options-close'));
expect(
screen.queryByTestId('bucket-options-content'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -16,7 +16,6 @@ export enum QueryBuilderField {
Limit = 'limit',
Legend = 'legend_format',
ReduceTo = 'reduce_to',
BucketOptions = 'bucket_options',
// Builder level
Formula = 'formula',
AdditionalQueries = 'additional_queries',

View File

@@ -76,7 +76,6 @@ export const RAW_QUERY_FIELDS: Omit<
[QueryBuilderField.Limit]: { state: 'hidden' },
[QueryBuilderField.Legend]: { state: 'hidden' },
[QueryBuilderField.ReduceTo]: { state: 'hidden' },
[QueryBuilderField.BucketOptions]: { state: 'hidden' },
[QueryBuilderField.Formula]: { state: 'hidden' },
[QueryBuilderField.OrderBy]: { state: 'pinned' },
};

View File

@@ -32,8 +32,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.AREA]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,

View File

@@ -339,7 +339,6 @@ export enum PANEL_TYPES {
AREA = 'area',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -527,21 +527,6 @@ export const metricsHistogramSpaceAggregateOperatorOptions: SelectOption<
},
];
/**
* A heatmap's Y axis is the `le` labels themselves, so every percentile draws the grid a
* count already draws. Sum is also what the statement builder forces on a histogram
* heatmap whatever is asked for, so it is the only honest option to offer.
*/
export const metricsHeatmapHistogramSpaceAggregateOperatorOptions: SelectOption<
string,
string
>[] = [
{
value: MetricAggregateOperator.COUNT,
label: 'Count',
},
];
export const metricsEmptyTimeAggregateOperatorOptions: SelectOption<
string,
string

View File

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

View File

@@ -5,9 +5,22 @@
}
.banner {
display: flex;
align-items: flex-start;
gap: var(--spacing-5);
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 30%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 8%, transparent);
color: var(--bg-amber-300, var(--bg-amber-400));
margin-top: var(--spacing-4);
}
.bannerIcon {
flex-shrink: 0;
margin-top: var(--spacing-1);
}
.error {
padding: var(--spacing-6) var(--spacing-8);
border-radius: var(--radius-2);

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { Typography } from '@signozhq/ui/typography';
import { TriangleAlert } from '@signozhq/icons';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
@@ -80,10 +80,13 @@ function UnpricedModelsTab(): JSX.Element {
return (
<div className={styles.unpricedModelsTab}>
<Callout type="warning" size="small" showIcon className={styles.banner}>
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Callout>
<div className={styles.banner}>
<TriangleAlert size="sm" className={styles.bannerIcon} />
<Typography.Text as="span" size="small" color="warning">
Models detected in traces without pricing. Map each to a billing model or
create pricing so estimated cost can be computed.
</Typography.Text>
</div>
{isError && (
<div className={styles.error}>

View File

@@ -1,79 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { DataSourceDropdown } from '../DataSourceDropdown';
const TEST_ID = 'query-data-source-selector';
async function openDropdown(): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(TEST_ID);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
}
describe('DataSourceDropdown', () => {
// antd's virtual list renders only the first couple of options into jsdom, so
// each case asserts what the restriction admits and excludes, not the full list.
it('offers the signals beyond the current one when nothing restricts it', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Metrics' })).toBeInTheDocument();
});
it('offers only the signals the caller can visualize', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.METRICS}
allowedDataSources={[TelemetrytypesSignalDTO.metrics]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Metrics' }),
).resolves.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Logs' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Traces' }),
).not.toBeInTheDocument();
});
it('drops a signal that is not a data source a query can be built against', async () => {
render(
<DataSourceDropdown
data-testid={TEST_ID}
value={DataSource.LOGS}
allowedDataSources={[
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO[''],
]}
onChange={jest.fn()}
/>,
);
await openDropdown();
await expect(
screen.findByRole('option', { name: 'Logs' }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'Traces' })).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Metrics' }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,9 +1,7 @@
import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
@@ -26,12 +24,8 @@ export function Formula({
query,
isQBV2,
}: FormulaProps): JSX.Element {
const {
removeQueryBuilderEntityByIndex,
handleSetFormulaData,
panelType,
currentQuery,
} = useQueryBuilder();
const { removeQueryBuilderEntityByIndex, handleSetFormulaData } =
useQueryBuilder();
const { handleChangeFormulaData } = useQueryOperations({
index,
@@ -79,13 +73,6 @@ export function Formula({
[handleChangeFormulaData],
);
const handleChangeBucketOptions = useCallback(
(value: IBuilderFormula['bucketOptions']) => {
handleChangeFormulaData('bucketOptions', value);
},
[handleChangeFormulaData],
);
const handleQBV2OrderByChange = useCallback(
(value: string) => {
const [columnName, order] = value.split(' ');
@@ -176,17 +163,6 @@ export function Formula({
</div>
</Col>
)}
{/* A heatmap draws its one enabled query, which is the formula when its
inputs are disabled — so the formula states its own bucket axis. */}
{isQBV2 && panelType === PANEL_TYPES.HEATMAP && (
<Col span={24}>
<BucketOptions
bucketOptions={formula.bucketOptions}
unit={currentQuery.unit}
onChange={handleChangeBucketOptions}
/>
</Col>
)}
</Row>
)}
</Row>

View File

@@ -1,91 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen, userEvent } from 'tests/test-utils';
import type { IBuilderFormula } from 'types/api/queryBuilder/queryBuilderData';
import { Formula } from '../Formula';
const mockHandleChangeFormulaData = jest.fn();
let mockPanelType: PANEL_TYPES = PANEL_TYPES.HEATMAP;
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): Record<string, unknown> => ({
removeQueryBuilderEntityByIndex: jest.fn(),
handleSetFormulaData: jest.fn(),
panelType: mockPanelType,
currentQuery: { unit: undefined },
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilderOperations', () => ({
useQueryOperations: (): Record<string, unknown> => ({
handleChangeFormulaData: mockHandleChangeFormulaData,
}),
}));
jest.mock('../../QBEntityOptions/QBEntityOptions', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="qb-entity-options" />,
}));
function formula(overrides: Partial<IBuilderFormula> = {}): IBuilderFormula {
return {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
...overrides,
};
}
function renderFormula(overrides: Partial<IBuilderFormula> = {}): void {
render(
<Formula index={0} formula={formula(overrides)} query={{} as never} isQBV2 />,
);
}
describe('Formula bucket options', () => {
beforeEach(() => {
jest.clearAllMocks();
mockPanelType = PANEL_TYPES.HEATMAP;
});
it('offers a bucket axis on a heatmap', () => {
renderFormula();
expect(screen.getByTestId('bucket-options')).toBeInTheDocument();
});
it('is not dismissable — a formula has no add-on toggle bar to collapse into', () => {
renderFormula();
expect(screen.queryByTestId('bucket-options-close')).not.toBeInTheDocument();
});
it('offers no bucket axis on other panel types', () => {
mockPanelType = PANEL_TYPES.TIME_SERIES;
renderFormula();
expect(screen.queryByTestId('bucket-options')).not.toBeInTheDocument();
});
it('opens on the axis the formula already carries', () => {
renderFormula({
bucketOptions: { kind: 'log', spec: { scale: 0 } } as never,
});
expect(screen.getByRole('radio', { name: 'Log' })).toBeChecked();
expect(screen.getByRole('radio', { name: '0' })).toBeChecked();
});
it('writes the picked axis onto the formula', async () => {
const user = userEvent.setup();
renderFormula();
await user.click(screen.getByRole('radio', { name: '0' }));
expect(mockHandleChangeFormulaData).toHaveBeenCalledWith('bucketOptions', {
kind: 'log',
spec: { scale: 0 },
});
});
});

View File

@@ -30,7 +30,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
AREA: true,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -21,6 +21,4 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.BAR]: BarPanel,
[PANEL_TYPES.AREA]: TimeSeriesPanel,
[PANEL_TYPES.HISTOGRAM]: HistogramPanel,
// V2-only kind; it renders through the V2 panel registry.
[PANEL_TYPES.HEATMAP]: null,
};

View File

@@ -62,14 +62,14 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
legend: '',
};
const setupMockQueryBuilder = (panelType = 'time_series'): void => {
const setupMockQueryBuilder = (): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
handleSetQueryData: mockHandleSetQueryData,
handleSetFormulaData: mockHandleSetFormulaData,
removeQueryBuilderEntityByIndex: mockRemoveQueryBuilderEntityByIndex,
setLastUsedQuery: mockSetLastUsedQuery,
redirectWithQueryBuilderData: mockRedirectWithQueryBuilderData,
panelType,
panelType: 'time_series',
currentQuery: {
builder: {
queryData: [defaultMockQuery, defaultMockQuery],
@@ -332,105 +332,4 @@ describe('useQueryBuilderOperations - Empty Aggregate Attribute Type', () => {
);
});
});
describe('spaceAggregationOptions for a histogram metric', () => {
const histogramQuery: IBuilderQuery = {
...defaultMockQuery,
aggregateAttribute: {
key: 'signoz_latency',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
} as BaseAutocompleteData,
};
it('offers the percentiles on a time series panel', () => {
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([
MetricAggregateOperator.P50,
MetricAggregateOperator.P75,
MetricAggregateOperator.P90,
MetricAggregateOperator.P95,
MetricAggregateOperator.P99,
]);
});
it('offers count alone on a heatmap panel, whose Y axis is the `le` labels', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ query: histogramQuery });
expect(
result.current.spaceAggregationOptions.map((o) => o.value),
).toStrictEqual([MetricAggregateOperator.COUNT]);
});
});
describe('picking a histogram metric', () => {
const histogramAttribute: BaseAutocompleteData = {
key: 'http.client.duration.bucket',
dataType: DataTypes.Float64,
type: ATTRIBUTE_TYPES.HISTOGRAM,
};
it('defaults the spatial aggregation to p90 on a time series panel', () => {
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.P90,
}),
],
}),
);
});
it('defaults it to count on a heatmap panel, which offers nothing else', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({ entityVersion: ENTITY_VERSION_V5 });
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({
aggregations: [
expect.objectContaining({
spaceAggregation: MetricAggregateOperator.COUNT,
}),
],
}),
);
});
it('drops the bucket axis a gauge had chosen, since `le` is the axis now', () => {
setupMockQueryBuilder('heatmap');
const result = renderHookWithProps({
entityVersion: ENTITY_VERSION_V5,
query: {
...defaultMockQuery,
bucketOptions: { kind: 'log', spec: { scale: 2 } },
},
});
act(() => {
result.current.handleChangeAggregatorAttribute(histogramAttribute);
});
expect(mockHandleSetQueryData).toHaveBeenLastCalledWith(
0,
expect.objectContaining({ bucketOptions: undefined }),
);
});
});
});

View File

@@ -18,7 +18,6 @@ import {
} from 'constants/queryBuilder';
import {
metricsGaugeSpaceAggregateOperatorOptions,
metricsHeatmapHistogramSpaceAggregateOperatorOptions,
metricsHistogramSpaceAggregateOperatorOptions,
metricsSumSpaceAggregateOperatorOptions,
metricsUnknownSpaceAggregateOperatorOptions,
@@ -177,11 +176,6 @@ export const useQueryOperations: UseQueryOperations = ({
(aggregateAttribute?.type as ATTRIBUTE_TYPES) || ATTRIBUTE_TYPES.GAUGE,
});
const histogramSpaceAggregationOptions =
panelType === PANEL_TYPES.HEATMAP
? metricsHeatmapHistogramSpaceAggregateOperatorOptions
: metricsHistogramSpaceAggregateOperatorOptions;
switch (aggregateAttribute?.type) {
case ATTRIBUTE_TYPES.SUM:
setSpaceAggregationOptions(metricsSumSpaceAggregateOperatorOptions);
@@ -191,11 +185,11 @@ export const useQueryOperations: UseQueryOperations = ({
break;
case ATTRIBUTE_TYPES.HISTOGRAM:
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
break;
case ATTRIBUTE_TYPES.EXPONENTIAL_HISTOGRAM:
setSpaceAggregationOptions(histogramSpaceAggregationOptions);
setSpaceAggregationOptions(metricsHistogramSpaceAggregateOperatorOptions);
break;
default:
setSpaceAggregationOptions(metricsUnknownSpaceAggregateOperatorOptions);
@@ -304,17 +298,10 @@ export const useQueryOperations: UseQueryOperations = ({
timeAggregation: '',
metricName: newQuery.aggregateAttribute?.key || '',
temporality: '',
// A heatmap cell holds a count of observations per `le` band, which is
// the one option the kind offers — a percentile default would sit in the
// selector with nothing behind it.
spaceAggregation:
panelType === PANEL_TYPES.HEATMAP
? MetricAggregateOperator.COUNT
: MetricAggregateOperator.P90,
spaceAggregation: MetricAggregateOperator.P90,
reduceTo: ReduceOperators.AVG,
},
];
newQuery.bucketOptions = undefined;
} else {
newQuery.aggregations = [
{
@@ -401,7 +388,6 @@ export const useQueryOperations: UseQueryOperations = ({
index,
handleMetricAggregateAtributeTypes,
previousMetricInfo,
panelType,
],
);

View File

@@ -222,31 +222,6 @@ describe('useGetYAxisUnit', () => {
expect(result.current.isError).toBe(false);
});
it('resolves the unit on the first render, without a settling pass', () => {
// The real `useGetMetrics` rebuilds its array on every render; a hook that
// stored the unit would need an extra render to settle, and would schedule one
// after every render of the panel editor.
mockUseGetMetrics.mockImplementation(() => ({
isLoading: false,
isError: false,
metrics: [MOCK_METRIC_1],
}));
let renderCount = 0;
const { result, rerender } = renderHook(() => {
renderCount += 1;
return useGetYAxisUnit();
});
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(1);
rerender();
expect(result.current.yAxisUnit).toBe(UniversalYAxisUnit.BYTES);
expect(renderCount).toBe(2);
});
it('should return undefined when metrics have different units', async () => {
mockUseGetMetrics.mockReturnValueOnce({
isLoading: false,

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import {
getMetricUnits,
useGetMetrics,
@@ -46,6 +46,7 @@ function useGetYAxisUnit(
},
): UseGetYAxisUnitResult {
const { stagedQuery } = useQueryBuilder();
const [yAxisUnit, setYAxisUnit] = useState<string | undefined>();
const metricNames: string[] | null = useMemo(() => {
// If the query type is not QUERY_BUILDER, return null
@@ -94,13 +95,27 @@ function useGetYAxisUnit(
[units],
);
const yAxisUnit = useMemo(() => {
// A single shared unit is the only thing a single axis can carry; metrics that
// disagree, or that carry no unit at all, leave the axis unitless.
if (units.length === 0 || !areAllMetricUnitsSame) {
return undefined;
useEffect(() => {
// If there are no metrics, set the y-axis unit to undefined
if (units.length === 0) {
setYAxisUnit(undefined);
// If there is one metric and it has a non-empty unit, set the y-axis unit to it
} else if (units.length === 1 && units[0] !== '') {
setYAxisUnit(units[0]);
// If all metrics have the same non-empty unit, set the y-axis unit to it
} else if (areAllMetricUnitsSame) {
if (units[0] !== '') {
setYAxisUnit(units[0]);
} else {
setYAxisUnit(undefined);
}
// If there is more than one metric and they have different units, set the y-axis unit to undefined
} else if (units.length > 1 && !areAllMetricUnitsSame) {
setYAxisUnit(undefined);
// If there is one metric and it has an empty unit, set the y-axis unit to undefined
} else if (units.length === 1 && units[0] === '') {
setYAxisUnit(undefined);
}
return units[0] || undefined;
}, [units, areAllMetricUnitsSame]);
return { yAxisUnit, isLoading, isError };

View File

@@ -1,78 +0,0 @@
import { Querybuildertypesv5BucketOptionsLogDTOKind } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import type {
IBuilderFormula,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { handleQueryChange } from '../panelQuery';
const bucketOptions = {
kind: Querybuildertypesv5BucketOptionsLogDTOKind.log,
spec: { scale: 0 },
};
function queryWithFormulaAxis(): Query {
const base = initialQueriesMap[DataSource.METRICS];
const formula: IBuilderFormula = {
queryName: 'F1',
expression: 'A',
legend: '',
disabled: false,
bucketOptions,
};
return {
...base,
builder: {
...base.builder,
queryData: [{ ...base.builder.queryData[0], bucketOptions }],
queryFormulas: [formula],
},
};
}
describe('handleQueryChange bucket options', () => {
it('keeps both axes when the panel stays a heatmap', () => {
const result = handleQueryChange(
PANEL_TYPES.HEATMAP,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toStrictEqual(
bucketOptions,
);
expect(result.builder.queryFormulas[0].bucketOptions).toStrictEqual(
bucketOptions,
);
});
// The request rejects an axis on any type but heatmap, so a switch has to shed it
// from the query (via the field allowlist) and from the formula (by hand).
it('drops both axes when switching to another panel type', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryData[0].bucketOptions).toBeUndefined();
expect(result.builder.queryFormulas[0].bucketOptions).toBeUndefined();
});
it('leaves the rest of the formula intact', () => {
const result = handleQueryChange(
PANEL_TYPES.TIME_SERIES,
queryWithFormulaAxis(),
PANEL_TYPES.HEATMAP,
);
expect(result.builder.queryFormulas[0]).toMatchObject({
queryName: 'F1',
expression: 'A',
disabled: false,
});
});
});

View File

@@ -8,11 +8,7 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import { cloneDeep, isEqual, set, unset } from 'lodash-es';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
panelTypeDataSourceFormValuesMap,
@@ -99,24 +95,6 @@ export const getIsQueryModified = (
);
};
/**
* Formulas are carried across a panel-type switch whole, not rebuilt from
* `panelTypeDataSourceFormValuesMap` the way `queryData` is, so a bucket axis has to be
* dropped by hand. The request rejects one on any type but heatmap.
*/
const withHeatmapBucketOptions = (
formulas: IBuilderFormula[],
newPanelType: keyof PartialPanelTypes,
): IBuilderFormula[] => {
if (newPanelType === PANEL_TYPES.HEATMAP) {
return formulas;
}
return (formulas ?? []).map(
({ bucketOptions: _bucketOptions, ...rest }) => rest,
);
};
export function handleQueryChange(
newPanelType: keyof PartialPanelTypes,
supersetQuery: Query,
@@ -160,10 +138,6 @@ export function handleQueryChange(
return tempQuery;
}),
queryFormulas: withHeatmapBucketOptions(
supersetQuery.builder.queryFormulas,
newPanelType,
),
queryTraceOperator:
newPanelType === PANEL_TYPES.LIST
? []

View File

@@ -15,7 +15,6 @@ export type PartialPanelTypes = {
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
export type BuilderField = keyof IBuilderQuery;
@@ -46,8 +45,6 @@ const IS_CARRIED = {
legend: true,
// `dataSource` is appended by the provider; the rest drive surfaces this switch
// does not reach.
// Heatmap only; listed for it below.
bucketOptions: false,
dataSource: false,
temporality: false,
pageSize: false,
@@ -115,16 +112,6 @@ const RAW_ROWS_METRICS: readonly BuilderField[] = omit(
'functions',
);
// `functions` and `having` are dropped rather than carried: the heatmap request
// rejects both. Logs and traces keep the metrics aggregations too, because the map is
// keyed by the query's own signal, which a switch can still be holding.
const HEATMAP: readonly BuilderField[] = omit(
SERIES_METRICS,
'functions',
'having',
);
const HEATMAP_METRICS: readonly BuilderField[] = [...HEATMAP, 'bucketOptions'];
/** Each cell gets its own copy; a shared instance would let cells contaminate
* each other. */
function bySource(
@@ -146,7 +133,6 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.AREA]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),

View File

@@ -1,74 +0,0 @@
.container {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 4px 12px 8px;
box-sizing: border-box;
}
.label {
flex: 0 0 auto;
font-size: 11px;
line-height: 16px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.track {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 2px;
border: 1px solid var(--l2-border);
}
.marker {
position: absolute;
top: -3px;
bottom: -3px;
width: 2px;
transform: translateX(-1px);
// Reads against the panel through the 3px it overhangs the track at either end,
// which is what carries it where the ramp happens to match it.
background: var(--popover-foreground);
border-radius: 1px;
}
.caption {
flex: 0 0 auto;
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.keys {
display: flex;
flex: 0 0 auto;
gap: 12px;
align-items: center;
}
.key {
display: flex;
gap: 5px;
align-items: center;
font-size: 11px;
color: var(--muted-foreground);
}
// 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;
background-image: repeating-linear-gradient(
45deg,
transparent 0 2px,
var(--muted-foreground) 2px 3px
);
}

View File

@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import Styles from './ColorBar.module.scss';
export interface ColorBarProps {
/** Low to high, drawn as hard-edged segments so the bar shows the same set of
* colours as the cells. */
ramp: string[];
minLabel: string;
maxLabel: string;
/** 0..1. `null` hides the marker. */
markerPosition?: number | null;
/** What the colour encodes, e.g. "count". */
label?: string;
/** Keys the one state a ramp cannot express: a hatched data gap. */
showNoDataKey?: boolean;
'data-testid'?: string;
}
/** What a colour means, plus a marker for the value under the cursor. */
export default function ColorBar({
ramp,
minLabel,
maxLabel,
markerPosition = null,
label,
showNoDataKey = false,
'data-testid': testId = 'color-bar',
}: ColorBarProps): JSX.Element | null {
const gradient = useMemo(() => {
if (ramp.length === 0) {
return undefined;
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => {
const from = (index / ramp.length) * 100;
const to = ((index + 1) / ramp.length) * 100;
return [`${color} ${from}%`, `${color} ${to}%`];
});
return `linear-gradient(to right, ${stops.join(', ')})`;
}, [ramp]);
if (gradient === undefined) {
return null;
}
const clampedMarker =
markerPosition === null ? null : Math.min(Math.max(markerPosition, 0), 1);
return (
<div className={Styles.container} data-testid={testId}>
{label && <span className={Styles.caption}>{label}</span>}
<span className={Styles.label}>{minLabel}</span>
<div className={Styles.track} style={{ background: gradient }}>
{clampedMarker !== null && (
<span
className={Styles.marker}
style={{ left: `${clampedMarker * 100}%` }}
data-testid={`${testId}-marker`}
/>
)}
</div>
<span className={Styles.label}>{maxLabel}</span>
{showNoDataKey && (
<div className={Styles.keys} data-testid={`${testId}-state-keys`}>
<span className={Styles.key}>
<span className={Styles.hatchSwatch} />
no data
</span>
</div>
)}
</div>
);
}

View File

@@ -1,85 +0,0 @@
import { render, screen } from '@testing-library/react';
import ColorBar from '../ColorBar';
const RAMP = ['#111111', '#555555', '#999999', '#dddddd'];
describe('ColorBar', () => {
it('renders the domain labels', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="1,204" />);
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('renders nothing without a ramp', () => {
const { container } = render(
<ColorBar ramp={[]} minLabel="0" maxLabel="0" />,
);
expect(container).toBeEmptyDOMElement();
});
it('hides the marker when nothing is hovered', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByTestId('color-bar-marker')).not.toBeInTheDocument();
});
it('positions the marker at the hovered value', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={0.25} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '25%' });
});
it('clamps a marker outside the ramp to its ends', () => {
const { rerender } = render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={-2} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '0%' });
rerender(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" markerPosition={4} />,
);
expect(screen.getByTestId('color-bar-marker')).toHaveStyle({ left: '100%' });
});
it('keys the one state a colour ramp cannot express', () => {
render(
<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" showNoDataKey={true} />,
);
expect(screen.getByText('no data')).toBeInTheDocument();
});
it('leaves the no-data key out unless the data has a gap', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" />);
expect(screen.queryByText('no data')).not.toBeInTheDocument();
});
it('captions what the colour encodes', () => {
render(<ColorBar ramp={RAMP} minLabel="0" maxLabel="10" label="count" />);
expect(screen.getByText('count')).toBeInTheDocument();
});
it('renders hard-edged segments so the bar matches the drawn cells', () => {
render(
<ColorBar
ramp={['#111111', '#dddddd']}
minLabel="0"
maxLabel="10"
data-testid="scale"
/>,
);
const track = screen.getByTestId('scale').querySelector('div');
expect(track).toHaveStyle({
background:
'linear-gradient(to right, #111111 0%, #111111 50%, #dddddd 50%, #dddddd 100%)',
});
});
});

View File

@@ -1,31 +0,0 @@
import cx from 'classnames';
import { formatCount } from './heatmapTooltipContent';
import { HeatmapBucketRow } from './types';
import Styles from './HeatmapTooltip.module.scss';
/** The buckets either side of the hovered one, so a mode reads as a shape rather
* than a single number. */
export default function HeatmapBucketList({
rows,
}: {
rows: HeatmapBucketRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-buckets">
{rows.map((bucket) => (
<div
key={bucket.row}
className={cx(Styles.row, { [Styles.rowHovered]: bucket.isHovered })}
data-hovered={bucket.isHovered}
data-testid="heatmap-tooltip-bucket-row"
>
<span className={Styles.rowLabel}>{bucket.label}</span>
<span className={Styles.rowSeparator} />
<span className={Styles.rowValue}>{formatCount(bucket.count)}</span>
</div>
))}
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { formatCount, formatShare } from './heatmapTooltipContent';
import { HeatmapContributionRow } from './types';
import Styles from './HeatmapTooltip.module.scss';
/** Only shown when the cell sums more than one group. */
export default function HeatmapContributionList({
rows,
}: {
rows: HeatmapContributionRow[];
}): JSX.Element {
return (
<div className={Styles.rows} data-testid="heatmap-tooltip-contribution">
<div className={Styles.columnHeader}>
<span>Group</span>
<span>Contribution</span>
</div>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="heatmap-tooltip-contribution-row"
>
<span
className={Styles.marker}
style={{ borderColor: row.color, backgroundColor: row.color }}
data-is-legend-marker={true}
/>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowSeparator} style={{ borderColor: row.color }} />
<span className={Styles.rowValue}>{formatCount(row.count)}</span>
<span
className={Styles.rowShare}
data-testid="heatmap-tooltip-contribution-share"
>
{formatShare(row.share)}
</span>
</div>
))}
</div>
);
}

View File

@@ -1,200 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
// Surface matches the shared Tooltip exactly — same tokens, same radius, no
// shadow (the plugin's portal wrapper is transparent and paints nothing). Text
// follows the theme through the popover/muted pair; the fixed vanilla ramp reads
// as white-on-white in light mode.
//
// Padding lives on the sections rather than here, also matching the shared
// tooltip: TooltipFooter draws its own dashed top border, background and bottom
// corner radius, so it has to reach the container edges.
.container {
// The list pays this less each row's own inset, so its text lands on it too.
--gutter: 14px;
--row-inset: 6px;
font-family: 'Inter';
font-size: 12px;
background: var(--l2-background);
-webkit-font-smoothing: antialiased;
color: var(--l2-foreground);
border-radius: 6px;
border: 1px solid var(--l2-border);
display: flex;
flex-direction: column;
min-width: 220px;
&.pinned {
border-color: var(--ring);
}
}
// Separates the cell identity from whichever question the second block answers.
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.identity {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
padding: var(--spacing-8) var(--gutter) 0;
font-size: var(--font-size-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.filter {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
padding: var(--spacing-3) var(--gutter) 0;
}
// Names the group the grid is under, as the legend draws a shown series; it is
// not a colour key.
.filterMarker {
width: 12px;
height: 12px;
border-radius: var(--radius);
border: 1.5px solid currentColor;
background: currentColor;
box-sizing: border-box;
flex-shrink: 0;
}
.filterLabel {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-4) var(--gutter);
}
.titleBucket,
.titleCount {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 700;
letter-spacing: -0.01em;
color: var(--popover-foreground);
}
.titleCount {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-4) calc(var(--gutter) - var(--row-inset))
var(--spacing-6);
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.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;
gap: var(--spacing-2);
padding: var(--row-inset);
border-radius: var(--radius);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
// The hovered bucket is the one the cursor is on; lift it out of the neighbours.
.rowHovered {
background: var(--l3-background);
font-weight: 700;
}
.rowLabel,
.rowValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.rowLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
text-align: right;
white-space: nowrap;
}
.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;
border-radius: var(--radius);
border-style: solid;
border-width: 1.5px;
box-sizing: border-box;
flex-shrink: 0;
}
.rowSeparator,
.titleSeparator {
flex: 1;
border-width: 0.5px;
border-style: dashed;
border-color: currentColor;
min-width: 24px;
opacity: 0.5;
}

View File

@@ -1,180 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import {
resolveColumnIndex,
resolveRowIndex,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { useTimezone } from 'providers/Timezone';
import { HeatmapTooltipProps } from '../../../types';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import HeatmapBucketList from './HeatmapBucketList';
import HeatmapContributionList from './HeatmapContributionList';
import {
buildBucketRows,
buildContributionRows,
formatBucketLabel,
formatColumnRange,
formatCount,
resolveTooltipBody,
} from './heatmapTooltipContent';
import { HeatmapTooltipBody } from './types';
import Styles from './HeatmapTooltip.module.scss';
/**
* The cell identity is the same in every state; the second block answers whichever
* question the panel state leaves open (see `resolveTooltipBody`). Purpose-built
* rather than composed from the shared `Tooltip`, which renders a flat list of
* series values — none of these states is that shape.
*
* The cell comes from the live cursor, not a prop: uPlot's `cursor.idx` snaps to
* the nearest timestamp, so half of every column would report its neighbour.
*/
export default function HeatmapTooltip({
uPlotInstance,
yAxis,
step,
series,
visibleGroups,
groupColor,
yAxisUnit,
decimalPrecision,
timezone,
isPinned,
dismiss,
renderTooltipFooter,
}: HeatmapTooltipProps): JSX.Element | null {
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
// Read outside the memo: uPlot mutates the same instance on every move, so
// keying off the instance alone would freeze the cell.
const { left = -10, top = -10 } = uPlotInstance.cursor;
const cell = useMemo(() => {
if (left < 0 || top < 0) {
return null;
}
const timestamps = uPlotInstance.data[0] as ArrayLike<number>;
const column = resolveColumnIndex(
timestamps,
uPlotInstance.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, uPlotInstance.posToVal(top, 'y'));
if (column === null || row === null) {
return null;
}
return {
row,
column,
timestamp: timestamps[column],
count:
(uPlotInstance.data[row + 1] as Array<number | null> | undefined)?.[
column
] ?? null,
};
}, [left, top, uPlotInstance, yAxis, step]);
// The cell sums the enabled groups, so those are what a breakdown must cover.
const visible = useMemo(
() => series.filter((entry) => visibleGroups.includes(entry.label)),
[series, visibleGroups],
);
const body = resolveTooltipBody(visible.length);
const bucketRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Buckets) {
return [];
}
return buildBucketRows({
counts: uPlotInstance.data.slice(1) as Array<
ArrayLike<number | null> | undefined
>,
yAxis,
row: cell.row,
column: cell.column,
yAxisUnit,
decimalPrecision,
});
}, [cell, body, uPlotInstance, yAxis, yAxisUnit, decimalPrecision]);
const contributionRows = useMemo(() => {
if (!cell || body !== HeatmapTooltipBody.Contribution) {
return [];
}
return buildContributionRows({
series: visible,
timestamp: cell.timestamp,
row: cell.row,
color: groupColor,
});
}, [cell, body, visible, groupColor]);
if (!cell) {
return null;
}
// A single enabled group out of several means the legend has isolated it.
const isolated =
series.length > 1 && visible.length === 1 ? visible[0] : undefined;
const filterLabel = isolated?.label ?? '';
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="heatmap-tooltip"
>
<div className={Styles.identity}>
<div className={Styles.header}>
<span data-testid="heatmap-tooltip-range">
{formatColumnRange({
start: cell.timestamp,
step,
timezone: resolvedTimezone,
})}
</span>
{isPinned && <TooltipPinnedBadge />}
</div>
{filterLabel && (
<div
className={Styles.filter}
style={{ color: groupColor }}
data-testid="heatmap-tooltip-filter"
>
<span className={Styles.filterMarker} />
<span className={Styles.filterLabel}>{filterLabel}</span>
</div>
)}
<div className={Styles.title}>
<span className={Styles.titleBucket} data-testid="heatmap-tooltip-bucket">
{formatBucketLabel({
yAxis,
row: cell.row,
yAxisUnit,
decimalPrecision,
})}
</span>
<span className={Styles.titleSeparator} />
<span className={Styles.titleCount} data-testid="heatmap-tooltip-count">
{formatCount(cell.count)}
</span>
</div>
</div>
<span className={Styles.divider} data-testid="heatmap-tooltip-divider" />
{body === HeatmapTooltipBody.Contribution ? (
<HeatmapContributionList rows={contributionRows} />
) : (
<HeatmapBucketList rows={bucketRows} />
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -1,305 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapAxisScale,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { render, RenderResult, screen } from 'tests/test-utils';
import type uPlot from 'uplot';
import HeatmapTooltip from '../HeatmapTooltip';
const BOUNDS = [100, 500, 1000, 2500];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const TIMESTAMPS = [1_700_000_000, 1_700_000_300];
const STEP = 300;
const PLOT_SIZE = 500;
const ROW_COUNT = BOUNDS.length + 1;
/** Row 2 is the 500ms–1s bucket the design mock hovers. */
const HOVERED_ROW = 2;
function seriesFor(
group: string,
countsAtHoveredRow: [number, number],
): HeatmapSeries {
return {
label: `{service.name="${group}"}`,
points: TIMESTAMPS.map((timestamp, column) => ({
timestamp,
counts: Array.from({ length: ROW_COUNT }, (_, row) =>
row === HOVERED_ROW ? countsAtHoveredRow[column] : row * 10,
),
})),
};
}
const GROUPED: HeatmapSeries[] = [
seriesFor('checkout', [355, 300]),
seriesFor('frontend', [86, 80]),
seriesFor('cart', [14, 10]),
seriesFor('payments', [0, 0]),
];
/** Grid counts, matching what the renderer would have been handed. */
function gridData(rowTotals: number[]): uPlot.AlignedData {
return [
TIMESTAMPS,
...Array.from({ length: ROW_COUNT }, (_, row) => [
rowTotals[row] ?? row * 40,
rowTotals[row] ?? row * 40,
]),
] as unknown as uPlot.AlignedData;
}
// Totals chosen to match the mock: 2 / 92 / 455 / 269 / 10 bottom-up.
const ROW_TOTALS = [10, 269, 455, 92, 2];
function createFakePlot(): uPlot {
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
// Aim the cursor at the middle of the hovered row, first column.
const rowMid = (Y_AXIS.edges[HOVERED_ROW] + Y_AXIS.edges[HOVERED_ROW + 1]) / 2;
const top = PLOT_SIZE * (1 - (rowMid - Y_AXIS.min) / ySpan);
return {
data: gridData(ROW_TOTALS),
cursor: { left: PLOT_SIZE * 0.25, top },
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_SIZE) * xSpan
: Y_AXIS.min + ((PLOT_SIZE - pos) / PLOT_SIZE) * ySpan,
} as unknown as uPlot;
}
function renderTooltip(
overrides: Partial<React.ComponentProps<typeof HeatmapTooltip>> = {},
): RenderResult {
return render(
<HeatmapTooltip
id="panel-1"
uPlotInstance={createFakePlot()}
dataIndexes={[]}
seriesIndex={null}
isPinned={false}
dismiss={jest.fn()}
viaSync={false}
yAxis={Y_AXIS}
step={STEP}
series={GROUPED}
visibleGroups={GROUPED.map((entry) => entry.label)}
groupColor="#fcfdbf"
yAxisUnit="ms"
{...overrides}
/>,
);
}
describe('HeatmapTooltip — cell identity', () => {
it('heads with the time span the column covers, not a single instant', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-range').textContent).toMatch(
/^\d{2}\/\d{2} \d{2}:\d{2} → \d{2}:\d{2}$/,
);
});
it('names the hovered bucket and its count', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-bucket')).toHaveTextContent(
'500 ms – 1 s',
);
expect(screen.getByTestId('heatmap-tooltip-count')).toHaveTextContent('455');
});
it('marks the surface as pinned so the border picks up the ring', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'true',
);
});
it('names a pinned tooltip in its header, as the shared tooltip does', () => {
renderTooltip({ isPinned: true });
expect(screen.getByTestId('uplot-tooltip-status')).toBeInTheDocument();
});
it('leaves the header unbadged while unpinned', () => {
renderTooltip();
expect(screen.queryByTestId('uplot-tooltip-status')).not.toBeInTheDocument();
});
it('is unpinned by default', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip')).toHaveAttribute(
'data-pinned',
'false',
);
});
it('separates the cell identity from the block below it', () => {
renderTooltip();
expect(screen.getByTestId('heatmap-tooltip-divider')).toBeInTheDocument();
});
it('renders a footer when the panel supplies one', () => {
renderTooltip({
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('press P');
});
it('tells the footer when the tooltip is pinned', () => {
renderTooltip({
isPinned: true,
renderTooltipFooter: ({ isPinned }): JSX.Element => (
<div data-testid="footer">{isPinned ? 'pinned' : 'press P'}</div>
),
});
expect(screen.getByTestId('footer')).toHaveTextContent('pinned');
});
it('renders nothing when the cursor is off the plot', () => {
const plot = createFakePlot();
(plot as { cursor: unknown }).cursor = { left: -10, top: -10 };
const { container } = renderTooltip({ uPlotInstance: plot });
expect(container).toBeEmptyDOMElement();
});
});
describe('HeatmapTooltip — grouped, nothing selected', () => {
it('breaks the cell down by group instead of showing neighbours', () => {
renderTooltip();
expect(
screen.getByTestId('heatmap-tooltip-contribution'),
).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-buckets'),
).not.toBeInTheDocument();
});
it('names each row as the legend does and orders by contribution', () => {
renderTooltip();
const rows = screen
.getAllByTestId('heatmap-tooltip-contribution-row')
.map((row) => row.textContent);
expect(rows[0]).toContain('checkout');
expect(rows[0]).toContain('355');
expect(rows[1]).toContain('frontend');
expect(rows[2]).toContain('cart');
});
it('still lists a group that contributed nothing', () => {
renderTooltip();
const rows = screen.getAllByTestId('heatmap-tooltip-contribution-row');
expect(rows).toHaveLength(GROUPED.length);
expect(rows[3]).toHaveTextContent('payments');
expect(rows[3]).toHaveTextContent('0');
});
it('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();
expect(
screen.queryByTestId('heatmap-tooltip-filter'),
).not.toBeInTheDocument();
});
});
describe('HeatmapTooltip — grouped, one enabled', () => {
const selected = { visibleGroups: ['{service.name="checkout"}'] };
it('returns to neighbouring buckets, since contribution is already answered', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-buckets')).toBeInTheDocument();
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
it('names the active filter', () => {
renderTooltip(selected);
expect(screen.getByTestId('heatmap-tooltip-filter')).toHaveTextContent(
'{service.name="checkout"}',
);
});
});
describe('HeatmapTooltip — no grouping', () => {
const ungrouped = {
series: [{ label: '', points: GROUPED[0].points }],
visibleGroups: [''],
};
it('shows neighbouring buckets, highest first', () => {
renderTooltip(ungrouped);
const rows = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.map((row) => row.textContent);
// Two buckets either side of 500ms – 1s, reading down the y axis.
expect(rows).toHaveLength(5);
expect(rows[0]).toContain('> 2.5 s');
expect(rows[2]).toContain('500 ms – 1 s');
expect(rows[4]).toContain('≤ 100 ms');
});
it('marks the hovered bucket among its neighbours', () => {
renderTooltip(ungrouped);
const hovered = screen
.getAllByTestId('heatmap-tooltip-bucket-row')
.filter((row) => row.dataset.hovered === 'true');
expect(hovered).toHaveLength(1);
expect(hovered[0]).toHaveTextContent('500 ms – 1 s');
});
it('never breaks down a single series', () => {
renderTooltip(ungrouped);
expect(
screen.queryByTestId('heatmap-tooltip-contribution'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,65 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { HeatmapAxisScale } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { buildBucketRows, formatColumnRange } from '../heatmapTooltipContent';
const TIMEZONE = 'UTC';
/** 2026-09-02T05:30:00Z. */
const START = 1_788_327_000;
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
const CLOSE_Y_AXIS = resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log);
const COUNTS = CLOSE_Y_AXIS.rows.map((_, row) => [row]);
describe('formatColumnRange', () => {
it('dates the column once while it stays inside a day', () => {
expect(
formatColumnRange({ start: START, step: 9 * 3600, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 14:30');
});
it('carries the date across a column that spans days', () => {
expect(
formatColumnRange({ start: START, step: 2 * 86_400, timezone: TIMEZONE }),
).toBe('09/02 05:30 → 09/04 05:30');
});
it('adds seconds for a sub-minute column, which times alone cannot separate', () => {
expect(
formatColumnRange({ start: START, step: 30, timezone: TIMEZONE }),
).toBe('09/02 05:30:00 → 05:30:30');
});
it('reads the day in the panel timezone, not UTC', () => {
// 05:30Z is the previous evening in Los Angeles, so the same column reads as
// crossing a date boundary there and not in UTC.
expect(
formatColumnRange({
start: START,
step: 9 * 3600,
timezone: 'America/Los_Angeles',
}),
).toBe('09/01 22:30 → 09/02 07:30');
});
});
describe('buildBucketRows', () => {
it('identifies a row by its place on the axis, which its label cannot', () => {
const rows = buildBucketRows({
counts: COUNTS,
yAxis: CLOSE_Y_AXIS,
row: 3,
column: 0,
yAxisUnit: 'ms',
decimalPrecision: 2,
});
expect(new Set(rows.map((row) => row.label)).size).toBeLessThan(rows.length);
expect(rows.map((row) => row.row)).toStrictEqual([5, 4, 3, 2, 1]);
expect(rows.map((row) => row.count)).toStrictEqual([5, 4, 3, 2, 1]);
});
});

View File

@@ -1,165 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { formatRowLabel } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import 'utils/timeUtils';
import {
HeatmapBucketRow,
HeatmapContributionRow,
HeatmapTooltipBody,
} from './types';
/** Rows shown either side of the hovered one. */
const NEIGHBOUR_SPAN = 2;
/** Below this, the header needs seconds to distinguish columns. */
const SUB_MINUTE_STEP = 60;
export const NO_DATA_LABEL = 'no data';
export function resolveTooltipBody(visibleCount: number): HeatmapTooltipBody {
// One enabled group contributes the whole cell, so there is nothing to break
// down — whether the query is ungrouped or the legend has isolated a group.
return visibleCount > 1
? HeatmapTooltipBody.Contribution
: HeatmapTooltipBody.Buckets;
}
/** A cell is an interval, so a single instant would misreport what it contains.
* The start carries the date — the x axis prints one only where the day turns
* over — and the end repeats it only across midnight. */
export function formatColumnRange({
start,
step,
timezone,
}: {
/** Column start, in seconds. */
start: number;
/** Column width, in seconds. */
step: number;
timezone: string;
}): string {
const time =
step < SUB_MINUTE_STEP
? DATE_TIME_FORMATS.TIME_SECONDS
: DATE_TIME_FORMATS.TIME;
const dated = `${DATE_TIME_FORMATS.DATE_SHORT} ${time}`;
const from = dayjs(start * 1000).tz(timezone);
const to = dayjs((start + step) * 1000).tz(timezone);
const toFormat = to.isSame(from, 'day') ? time : dated;
return `${from.format(dated)} → ${to.format(toFormat)}`;
}
/** Formatted with the panel's unit. */
export function formatBucketLabel({
yAxis,
row,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
row: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): string {
const bucket = yAxis.rows[row];
if (!bucket) {
return '';
}
return formatRowLabel(bucket, (value) =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision),
);
}
export function formatCount(count: number | null): string {
return count === null ? NO_DATA_LABEL : count.toLocaleString();
}
/** `<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,
yAxis,
row,
column,
yAxisUnit,
decimalPrecision,
}: {
/** Row-major, as the renderer draws them. */
counts: Array<ArrayLike<number | null> | undefined>;
yAxis: HeatmapYAxis;
row: number;
column: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): HeatmapBucketRow[] {
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const rows: HeatmapBucketRow[] = [];
for (let offset = NEIGHBOUR_SPAN; offset >= -NEIGHBOUR_SPAN; offset -= 1) {
const index = row + offset;
const bucket = yAxis.rows[index];
if (!bucket) {
continue;
}
rows.push({
row: index,
label: formatRowLabel(bucket, formatBucketValue),
count: counts[index]?.[column] ?? null,
isHovered: offset === 0,
});
}
return rows;
}
/**
* Largest first. Groups that contributed nothing are still listed — that is an
* answer, and dropping the row makes the list look truncated.
*/
export function buildContributionRows({
series,
timestamp,
row,
color,
}: {
/** Only the groups the legend has enabled — they are what the cell sums. */
series: HeatmapSeries[];
/** Column start, in seconds. */
timestamp: number;
row: number;
/** The grid's one colour; there is no per-series hue. */
color: string;
}): HeatmapContributionRow[] {
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,24 +0,0 @@
/** Which question the second block answers: "which group?" for a cell summed
* across several, "how does this bucket compare?" for one that is one series. */
export enum HeatmapTooltipBody {
Buckets = 'buckets',
Contribution = 'contribution',
}
export interface HeatmapBucketRow {
/** The bucket's row on the y axis. Labels are not unique — two boundaries can
* round to the same text — so this is what identifies a row. */
row: number;
label: string;
/** `null` where the bucket has no observation in that column, never a 0. */
count: number | null;
isHovered: boolean;
}
export interface HeatmapContributionRow {
label: string;
color: string;
count: number;
/** Fraction of the cell this group contributed, 0..1. */
share: number;
}

View File

@@ -16,3 +16,15 @@
.pinnedItem {
padding: var(--spacing-4);
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -1,13 +1,13 @@
import { useMemo } from 'react';
import cx from 'classnames';
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { Pin } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
import Styles from './TooltipHeader.module.scss';
@@ -67,7 +67,14 @@ export default function TooltipHeader({
{showTooltipHeader && headerTitle && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{headerTitle}</span>
{isPinned && <TooltipPinnedBadge />}
{isPinned && (
<div className={cx(Styles.status)} data-testid="uplot-tooltip-status">
<>
<Pin size={12} />
<span>Pinned</span>
</>
</div>
)}
</div>
)}

View File

@@ -1,11 +0,0 @@
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--callout-primary-title);
flex-shrink: 0;
}

View File

@@ -1,12 +0,0 @@
import { Pin } from '@signozhq/icons';
import Styles from './TooltipPinnedBadge.module.scss';
export default function TooltipPinnedBadge(): JSX.Element {
return (
<div className={Styles.status} data-testid="uplot-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -104,21 +103,6 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
export interface HistogramTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {}
/** Not part of `TooltipProps`: it renders its own container, since none of its
* states is the flat series list the shared `Tooltip` draws. */
export interface HeatmapTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
/** Needed to break a summed cell down by contribution. */
series: HeatmapSeries[];
/** Groups the legend has enabled; the cell sums exactly these. */
visibleGroups: string[];
/** Same colour the legend and the densest cells use. */
groupColor: string;
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -148,7 +148,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
show = true,
side = 2, // bottom by default
space,
splits,
gap = 5, // default gap is 5
} = this.props;
@@ -180,9 +179,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (values) {
axisConfig.values = values;
}
if (splits) {
axisConfig.splits = splits;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}

View File

@@ -46,12 +46,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
// Special handling for time scales (X axis)
if (time) {
// A range supplied outright wins: marks spanning an interval have to reach
// the end of their last column, past the last timestamp min/max carry.
if (range) {
return { [scaleKey]: { time: true, auto: false, range } };
}
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;

View File

@@ -78,8 +78,6 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Pixels between the ticks and their labels; also feeds the y axis width calculation. */
gap?: number;
/** Explicit axis thickness. Left unset, the y axis sizes itself to its widest label. */

View File

@@ -1,290 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import {
clampColorSteps,
createHeatmapColorResolver,
DEFAULT_COLOR_STEPS,
DEFAULT_HEATMAP_COLORS,
getMaxCount,
getSmallestPositiveCount,
MAX_COLOR_STEPS,
MIN_OPACITY_ALPHA,
normalizeCount,
resolveCountDomain,
} from '../colorScale';
import { HeatmapColorMode, HeatmapColorScale } from '../types';
const SERIES_COLOR = '#4e74f8';
describe('getMaxCount', () => {
it('ignores null cells', () => {
expect(
getMaxCount([
[1, null, 9],
[null, 4],
]),
).toBe(9);
});
it('returns 0 for an empty or all-null grid', () => {
expect(getMaxCount([])).toBe(0);
expect(getMaxCount([[null, null]])).toBe(0);
});
it('ignores non-finite counts', () => {
expect(getMaxCount([[3, Number.POSITIVE_INFINITY, Number.NaN]])).toBe(3);
});
});
describe('getSmallestPositiveCount', () => {
it('ignores nulls, zeros and non-finite counts', () => {
expect(
getSmallestPositiveCount([
[0, null, 4],
[Number.NaN, 2, -3],
]),
).toBe(2);
});
it('returns null when nothing is above zero', () => {
expect(getSmallestPositiveCount([[0, null]])).toBeNull();
});
});
describe('resolveCountDomain', () => {
it('floors at 0 on auto so a zero count sits at the bottom of the scale', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[5, 20]]),
).toStrictEqual({
min: 0,
max: 20,
logFloor: 5,
});
});
it('honours explicit clamps', () => {
expect(
resolveCountDomain({ minCount: 10, maxCount: 100 }, [[5, 20]]),
).toStrictEqual({
min: 10,
max: 100,
logFloor: 5,
});
});
it('collapses a max at or below min', () => {
expect(
resolveCountDomain({ minCount: 50, maxCount: 10 }, [[5]]),
).toStrictEqual({
min: 50,
max: 50,
logFloor: 5,
});
});
it('takes the log floor from the smallest positive count, below 1 included', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[0, 0.02, 0.8]])
.logFloor,
).toBeCloseTo(0.02, 6);
});
it('keeps the log floor within MAX_LOG_DECADES of the max', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[1, 1e9]]).logFloor,
).toBe(1e3);
});
it('falls back to a floor of 1 for a grid without a positive count', () => {
expect(
resolveCountDomain({ minCount: null, maxCount: null }, [[null, 0]]).logFloor,
).toBe(1);
});
});
describe('normalizeCount', () => {
const domain = { min: 0, max: 1000, logFloor: 1 };
it('spreads low counts on a log scale where a linear one washes them out', () => {
const log = (count: number): number =>
normalizeCount({ count, domain, scale: HeatmapColorScale.Log });
expect(log(10)).toBeCloseTo(1 / 3, 5);
expect(log(20)).toBeCloseTo(Math.log10(20) / 3, 5);
expect(
normalizeCount({ count: 10, domain, scale: HeatmapColorScale.Linear }),
).toBeCloseTo(0.01, 5);
});
it('puts 0 and 1 at the bottom of a log scale', () => {
expect(
normalizeCount({ count: 0, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
expect(
normalizeCount({ count: 1, domain, scale: HeatmapColorScale.Log }),
).toBe(0);
});
it('reaches the top of the scale at max on every scale', () => {
[
HeatmapColorScale.Log,
HeatmapColorScale.Sqrt,
HeatmapColorScale.Linear,
].forEach((scale) => {
expect(normalizeCount({ count: 1000, domain, scale })).toBeCloseTo(1, 6);
});
});
it('takes the square root of the linear position on a sqrt scale', () => {
expect(
normalizeCount({
count: 250,
domain: { min: 0, max: 1000, logFloor: 1 },
scale: HeatmapColorScale.Sqrt,
}),
).toBeCloseTo(0.5, 6);
});
it('clamps counts outside the domain', () => {
const scale = HeatmapColorScale.Linear;
expect(normalizeCount({ count: -5, domain, scale })).toBe(0);
expect(normalizeCount({ count: 5000, domain, scale })).toBe(1);
});
it('returns the bottom of the scale when min equals max', () => {
expect(
normalizeCount({
count: 7,
domain: { min: 7, max: 7, logFloor: 1 },
scale: HeatmapColorScale.Log,
}),
).toBe(0);
});
it('spreads a log domain that sits entirely below a count of 1', () => {
const fractional = { min: 0, max: 1, logFloor: 0.001 };
const log = (count: number): number =>
normalizeCount({
count,
domain: fractional,
scale: HeatmapColorScale.Log,
});
expect(log(0.001)).toBe(0);
expect(log(0.1)).toBeCloseTo(2 / 3, 5);
expect(log(1)).toBeCloseTo(1, 6);
});
it('reads a log scale linearly when the floor reaches the top of the domain', () => {
const domainAtFloor = { min: 0, max: 1, logFloor: 1 };
expect(
normalizeCount({
count: 1,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(1);
expect(
normalizeCount({
count: 0.5,
domain: domainAtFloor,
scale: HeatmapColorScale.Log,
}),
).toBe(0.5);
});
});
describe('clampColorSteps', () => {
it('clamps to the supported range', () => {
expect(clampColorSteps(1)).toBe(2);
expect(clampColorSteps(500)).toBe(MAX_COLOR_STEPS);
expect(clampColorSteps(32)).toBe(32);
});
it('falls back to the default for a non-finite value', () => {
expect(clampColorSteps(Number.NaN)).toBe(DEFAULT_COLOR_STEPS);
});
});
describe('createHeatmapColorResolver', () => {
const build = (
overrides: Partial<typeof DEFAULT_HEATMAP_COLORS> = {},
isDarkMode = true,
): ReturnType<typeof createHeatmapColorResolver> =>
createHeatmapColorResolver({
options: { ...DEFAULT_HEATMAP_COLORS, ...overrides },
domain: { min: 0, max: 1000, logFloor: 1 },
isDarkMode,
seriesColor: SERIES_COLOR,
});
it('leaves null cells uncoloured so they can be hatched', () => {
const resolver = build();
expect(resolver.colorFor(null)).toBeNull();
expect(resolver.positionOf(null)).toBeNull();
});
it('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]);
});
it('emits one ramp entry per step', () => {
expect(build({ steps: 8 }).ramp).toHaveLength(8);
});
it('maps the max count to the top of the ramp', () => {
const resolver = build({ steps: 8 });
expect(resolver.colorFor(1000)).toBe(resolver.ramp[7]);
});
it('picks different stops per theme so low counts stay near the surface', () => {
expect(build({}, true).ramp[0]).not.toBe(build({}, false).ramp[0]);
});
it('varies alpha in opacity mode, never below the visibility floor', () => {
const resolver = build({ mode: HeatmapColorMode.Opacity, steps: 4 });
expect(resolver.ramp[0]).toBe(`rgba(78, 116, 248, ${MIN_OPACITY_ALPHA})`);
// `color` drops the alpha channel from the string once it reaches 1.
expect(resolver.ramp[3]).toBe('rgb(78, 116, 248)');
});
it('prefers an explicit opacity fill over the series colour', () => {
const resolver = build({
mode: HeatmapColorMode.Opacity,
fill: '#e5484d',
steps: 2,
});
expect(resolver.ramp[1]).toBe('rgb(229, 72, 77)');
});
it('reports the domain it applied', () => {
expect(build().domain).toStrictEqual({ min: 0, max: 1000, logFloor: 1 });
});
});

View File

@@ -1,706 +0,0 @@
import {
canUseLogAxis,
cropHeatmapYAxis,
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
resolveColumnIndex,
resolveHeatmapYAxis,
resolveRowIndex,
} from '../geometry';
import { HeatmapAxisScale } from '../types';
const BOUNDS = [128, 256, 1024, 4096];
describe('canUseLogAxis', () => {
it('accepts strictly positive bounds', () => {
expect(canUseLogAxis(BOUNDS)).toBe(true);
});
it('rejects a zero or negative bound', () => {
expect(canUseLogAxis([0, 128])).toBe(false);
expect(canUseLogAxis([-1, 128])).toBe(false);
});
it('rejects empty bounds', () => {
expect(canUseLogAxis([])).toBe(false);
});
});
describe('resolveHeatmapYAxis', () => {
it('turns N bounds into N+1 rows with underflow and overflow at the ends', () => {
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(rows).toHaveLength(BOUNDS.length + 1);
expect(rows[0]).toMatchObject({
upper: 128,
isUnderflow: true,
isOverflow: false,
});
expect(rows[1]).toMatchObject({ lower: 128, upper: 256 });
expect(rows[4]).toMatchObject({
lower: 4096,
isOverflow: true,
isUnderflow: false,
});
});
it('exposes one edge per row boundary, ascending', () => {
const { rows, edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(edges).toHaveLength(rows.length + 1);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('places bounds in log space so row heights are log-proportional', () => {
const { splits, min, max } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
// 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) ** (2 / 3), 6);
});
it('keeps bounds in value space on a linear axis', () => {
const { splits, min } = resolveHeatmapYAxis(
[10, 20, 30],
HeatmapAxisScale.Linear,
);
expect(splits).toStrictEqual([10, 20, 30]);
// Mean gap is 10, and the underflow edge never crosses zero.
expect(min).toBe(0);
});
it('sorts and de-duplicates bounds', () => {
const { rows, splits } = resolveHeatmapYAxis(
[256, 128, 256, Number.NaN],
HeatmapAxisScale.Log,
);
expect(splits).toStrictEqual([Math.log10(128), Math.log10(256)]);
expect(rows).toHaveLength(3);
});
it('gives a single bound an underflow and an overflow row', () => {
const { rows, edges } = resolveHeatmapYAxis([100], HeatmapAxisScale.Log);
expect(rows).toHaveLength(2);
expect(rows[0].isUnderflow).toBe(true);
expect(rows[1].isOverflow).toBe(true);
expect(edges).toHaveLength(3);
});
it('degrades to an empty axis with no bounds', () => {
expect(resolveHeatmapYAxis([], HeatmapAxisScale.Log).rows).toStrictEqual([]);
});
it('puts the overflow label on the row"s upper edge, clear of the last boundary', () => {
const { overflowSplit, edges } = resolveHeatmapYAxis(
BOUNDS,
HeatmapAxisScale.Log,
);
// A full row above the last boundary tick, so the two labels cannot collide.
expect(overflowSplit).toBe(edges[edges.length - 1]);
});
});
describe('resolveRowIndex', () => {
const { edges } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
it('finds the row containing a value', () => {
expect(resolveRowIndex(edges, 200)).toBe(1);
expect(resolveRowIndex(edges, 2000)).toBe(3);
});
it('assigns a boundary to the row it opens', () => {
expect(resolveRowIndex(edges, 256)).toBe(2);
});
it('returns the last row on the top edge', () => {
expect(resolveRowIndex(edges, edges[edges.length - 1])).toBe(
edges.length - 2,
);
});
it('returns null outside the grid', () => {
expect(resolveRowIndex(edges, edges[0] - 1)).toBeNull();
expect(resolveRowIndex(edges, edges[edges.length - 1] + 1)).toBeNull();
});
it('returns null without at least one row', () => {
expect(resolveRowIndex([5], 5)).toBeNull();
});
});
describe('resolveColumnIndex', () => {
const timestamps = [100, 160, 220, 280];
const step = 60;
it('resolves by containment, not proximity', () => {
// 155 is nearer to 160, but the observations at 155 belong to column 0.
expect(resolveColumnIndex(timestamps, 155, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 160, step)).toBe(1);
});
it('includes the column start and excludes its end', () => {
expect(resolveColumnIndex(timestamps, 100, step)).toBe(0);
expect(resolveColumnIndex(timestamps, 159.9, step)).toBe(0);
});
it('covers the trailing column using the step, not the next timestamp', () => {
expect(resolveColumnIndex(timestamps, 330, step)).toBe(3);
expect(resolveColumnIndex(timestamps, 340, step)).toBeNull();
});
it('returns null before the first column', () => {
expect(resolveColumnIndex(timestamps, 99, step)).toBeNull();
});
it('returns null with no columns', () => {
expect(resolveColumnIndex([], 100, step)).toBeNull();
});
it('leaves the last column open when the step is unknown', () => {
expect(resolveColumnIndex(timestamps, 10_000, 0)).toBe(3);
});
});
describe('formatRowLabel', () => {
const format = (value: number): string => `${value}ms`;
const { rows } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
it('labels the underflow row by its only real bound', () => {
expect(formatRowLabel(rows[0], format)).toBe('≤ 128ms');
});
it('labels the overflow row by its only real bound', () => {
expect(formatRowLabel(rows[rows.length - 1], format)).toBe('> 4096ms');
});
it('labels an interior row as a range', () => {
expect(formatRowLabel(rows[1], format)).toBe('128ms – 256ms');
});
});
describe('decimateAxisSplits', () => {
const splits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const domain = { min: 0, max: 10 };
it('keeps every tick when they all fit', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 400, minGapPx: 18 }),
).toStrictEqual(splits);
});
it('thins to whatever fits at the available height', () => {
// 11 ticks over 100px is 10px apart; an 18px floor keeps every other one.
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 100, minGapPx: 18 }),
).toStrictEqual([0, 2, 4, 6, 8, 10]);
});
it('always keeps the topmost tick, so the overflow edge survives thinning', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 40,
minGapPx: 18,
});
expect(thinned[thinned.length - 1]).toBe(10);
});
it('returns ascending positions', () => {
const thinned = decimateAxisSplits({
...domain,
splits,
plotHeight: 60,
minGapPx: 18,
});
expect([...thinned].sort((a, b) => a - b)).toStrictEqual(thinned);
});
it('thins by pixel distance, not index, so uneven rows are handled', () => {
// Three boundaries bunched at the bottom of a wide linear domain: only the
// first and the far-away last are far enough apart to both get labels.
expect(
decimateAxisSplits({
splits: [1, 2, 3, 1000],
min: 0,
max: 1000,
plotHeight: 200,
minGapPx: 18,
}),
).toStrictEqual([3, 1000]);
});
it('leaves the tick set alone when it cannot measure', () => {
expect(
decimateAxisSplits({ ...domain, splits, plotHeight: 0, minGapPx: 18 }),
).toStrictEqual(splits);
expect(
decimateAxisSplits({
splits,
min: 5,
max: 5,
plotHeight: 400,
minGapPx: 18,
}),
).toStrictEqual(splits);
});
});
describe('resolveHeatmapYAxis — the scale auto picks', () => {
// The OTel SDK default explicit bucket boundaries, which start at zero.
const OTEL = [
0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000,
];
// Clock skew in ms — a logs/traces field that straddles zero.
const SKEW = [-1000, -100, -10, -1, 0, 1, 10, 100, 1000];
const PLOT_HEIGHT = 250;
/** Row heights in axis units, less the deliberately taller overflow row. */
function rowHeights(bounds: number[]): number[] {
const { edges } = resolveHeatmapYAxis(bounds, HeatmapAxisScale.Auto);
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
}
/** Shortest row, in pixels, for a plot of `PLOT_HEIGHT`. */
function shortestRowPx(bounds: number[], scale: HeatmapAxisScale): number {
const { edges } = resolveHeatmapYAxis(bounds, scale);
const span = edges[edges.length - 1] - edges[0];
const heights = edges
.slice(1)
.map((edge, index) => ((edge - edges[index]) / span) * PLOT_HEIGHT);
return Math.min(...heights);
}
it('keeps a zero boundary on a log axis instead of giving up to linear', () => {
const { splits } = resolveHeatmapYAxis([0, 5, 10], HeatmapAxisScale.Auto);
// A linear fallback would leave the boundaries untransformed.
expect(splits).not.toStrictEqual([0, 5, 10]);
});
it('is what an all-positive layout does NOT get — that stays a plain log', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Auto);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('gives every row a usable height for the OTel default boundaries', () => {
// Linear squeezes the 0–100ms buckets — where the data is — under a pixel.
expect(shortestRowPx(OTEL, HeatmapAxisScale.Linear)).toBeLessThan(1);
expect(shortestRowPx(OTEL, HeatmapAxisScale.Auto)).toBeGreaterThan(4);
});
it('gives the row above zero one bucket of height, not a whole decade', () => {
const heights = rowHeights(OTEL);
const { rows } = resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto);
const nearZero = rows.findIndex((row) => row.lower === 0 && row.upper === 5);
const positive = OTEL.filter((bound) => bound > 0);
const bucket =
(Math.log10(positive[positive.length - 1]) - Math.log10(positive[0])) /
(positive.length - 1);
expect(heights[nearZero]).toBeCloseTo(bucket, 6);
});
it('reads a lone zero boundary as a log layout, not a zero-crossing one', () => {
expect(resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Auto).edges).toStrictEqual(
resolveHeatmapYAxis(OTEL, HeatmapAxisScale.Log).edges,
);
});
it('turns to symlog once a boundary reaches below zero', () => {
expect(resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Auto).edges).toStrictEqual(
resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Symlog).edges,
);
});
it('places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('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);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('round-trips a boundary back to its bucket value', () => {
const { splits, toBucketValue } = resolveHeatmapYAxis(
SKEW,
HeatmapAxisScale.Auto,
);
expect(
splits.map((split) => Math.round(toBucketValue(split) * 1e6) / 1e6),
).toStrictEqual(SKEW);
});
it('derives the linear threshold from the smallest non-zero boundary', () => {
// Threshold 10 puts -10 at -1 and 0 at 0 in axis space.
const { edges, rows } = resolveHeatmapYAxis(
[-100, -10, 0, 10, 100],
HeatmapAxisScale.Auto,
);
const crossing = rows.findIndex(
(row) => row.lower === -10 && row.upper === 0,
);
expect(edges[crossing]).toBeCloseTo(-1, 6);
expect(edges[crossing + 1]).toBeCloseTo(0, 6);
});
it('leaves an all-positive layout on a plain log axis', () => {
const { splits } = resolveHeatmapYAxis(
[128, 256, 1024],
HeatmapAxisScale.Auto,
);
expect(splits).toStrictEqual([128, 256, 1024].map((b) => Math.log10(b)));
});
it('falls back to linear when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Auto);
expect(splits).toStrictEqual([0]);
});
});
describe('resolveHeatmapYAxis — an explicitly chosen scale', () => {
// The low end of the OTel SDK defaults: a zero bucket, then positive bounds.
const ZERO_HEAD = [0, 5, 10, 25];
// Clock skew in ms — a field that straddles zero.
const SKEW = [-100, -10, 0, 10, 100];
/** Row heights in axis units, less the deliberately taller overflow row. */
function rowHeights(bounds: number[], scale: HeatmapAxisScale): number[] {
const { edges } = resolveHeatmapYAxis(bounds, scale);
return edges.slice(1, -1).map((edge, index) => edge - edges[index]);
}
it('log stays a plain log10 for a positive layout', () => {
const { splits } = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
expect(splits).toStrictEqual(BOUNDS.map((bound) => Math.log10(bound)));
});
it('log keeps its own answer for a zero bucket rather than becoming a symlog', () => {
const { splits } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
// One bucket below the smallest positive bound, where a symlog would put it
// a whole decade below.
const gap = (Math.log10(25) - Math.log10(5)) / 2;
expect(splits[0]).toBeCloseTo(Math.log10(5) - gap, 6);
expect(splits.slice(1)).toStrictEqual([5, 10, 25].map((b) => Math.log10(b)));
});
it('log spends a bucket on the zero-crossing row where symlog spends a decade', () => {
const gap = (Math.log10(25) - Math.log10(5)) / 2;
// Row 1 is (0, 5] — the row above the zero bucket.
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Log)[1]).toBeCloseTo(gap, 6);
expect(rowHeights(ZERO_HEAD, HeatmapAxisScale.Symlog)[1]).toBeCloseTo(1, 6);
});
it('log leaves every edge ascending with a zero bucket in the layout', () => {
const { edges } = resolveHeatmapYAxis(ZERO_HEAD, HeatmapAxisScale.Log);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log squashes several non-positive boundaries onto one edge — what symlog is for', () => {
const { edges } = resolveHeatmapYAxis(SKEW, HeatmapAxisScale.Log);
// -100, -10 and 0 have no logarithm and share the floor.
expect(edges[1]).toBe(edges[2]);
expect(edges[2]).toBe(edges[3]);
expect([...edges].sort((a, b) => a - b)).toStrictEqual(edges);
});
it('log has nothing to compress without a positive boundary and stays linear', () => {
const { splits } = resolveHeatmapYAxis([-5, 0], HeatmapAxisScale.Log);
expect(splits).toStrictEqual([-5, 0]);
});
it('symlog is a choice for a positive layout too, and is not the log axis', () => {
const positive = [1, 10, 100];
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Log).splits,
).toStrictEqual([0, 1, 2]);
// Threshold 1: the boundaries sit a decade apart, one unit above the linear band.
expect(
resolveHeatmapYAxis(positive, HeatmapAxisScale.Symlog).splits,
).toStrictEqual([1, 2, 3]);
});
it('symlog places boundaries either side of zero symmetrically', () => {
const heights = rowHeights(SKEW, HeatmapAxisScale.Symlog);
expect(Math.max(...heights) - Math.min(...heights)).toBeCloseTo(0, 6);
});
it('symlog has no magnitude to scale against when every boundary is zero', () => {
const { splits } = resolveHeatmapYAxis([0], HeatmapAxisScale.Symlog);
expect(splits).toStrictEqual([0]);
});
});
describe('cropHeatmapYAxis', () => {
// Four bounds, so five rows: underflow, three buckets, overflow.
const AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Log);
const counts = (perRow: Array<number | null>): Array<Array<number | null>> =>
perRow.map((count) => [count]);
it('stops one row above the highest occupied one', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.max).toBe(AXIS.edges[3]);
expect(cropped.splits).toStrictEqual(AXIS.splits.slice(0, 3));
});
it('drops the infinity tick once its row is off the top', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.overflowSplit).toBeNull();
});
it('keeps the rows intact, so a bucket is never relabelled', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([3, 7, 0, 0, 0]));
expect(cropped.rows).toStrictEqual(AXIS.rows);
expect(cropped.edges).toStrictEqual(AXIS.edges);
});
it('leaves the axis alone when the top row is occupied', () => {
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 0, 2]))).toStrictEqual(AXIS);
});
it('leaves the axis alone when the row below the top is occupied', () => {
expect(cropHeatmapYAxis(AXIS, counts([0, 0, 0, 9, 0]))).toStrictEqual(AXIS);
});
it('treats zero and no-data cells alike — neither occupies a row', () => {
const cropped = cropHeatmapYAxis(AXIS, counts([4, 0, null, null, null]));
expect(cropped.max).toBe(AXIS.edges[2]);
});
it('leaves an entirely empty grid alone, having nothing to crop against', () => {
expect(
cropHeatmapYAxis(AXIS, counts([null, null, null, null, null])),
).toStrictEqual(AXIS);
});
});
describe('resolveColumnAlignedSplits', () => {
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
/** uPlot's `tzDate` for a fixed-offset zone: the returned date's local fields
* read as that zone's wall clock, whatever the machine's own zone is. */
const zoneAt =
(offsetSeconds: number) =>
(timestamp: number): Date => {
const browserOffset =
-new Date(timestamp * 1e3).getTimezoneOffset() * MINUTE;
return new Date((timestamp + offsetSeconds - browserOffset) * 1e3);
};
const UTC = zoneAt(0);
/** IST, whose half-hour offset is what pulls ticks off round local times. */
const IST = zoneAt(5.5 * HOUR);
/** 2024-03-11T00:00:00Z, a Monday. */
const MIDNIGHT_UTC = 1_710_115_200;
it('lands every tick on a column edge', () => {
const step = 90;
const anchor = 1_700_000_010;
const splits = resolveColumnAlignedSplits({
anchor,
step,
incr: 5 * MINUTE,
min: anchor,
max: anchor + 40 * step,
});
expect(splits.length).toBeGreaterThan(1);
splits.forEach((split) => {
expect((split - anchor) % step).toBe(0);
});
});
it('rounds the increment up to a whole number of columns', () => {
const splits = resolveColumnAlignedSplits({
anchor: 0,
step: 90,
incr: 5 * MINUTE,
min: 0,
max: HOUR,
toDate: UTC,
});
// 300s asked for, 360s is the next multiple of the 90s column.
expect(splits[1] - splits[0]).toBe(360);
});
it('covers the visible range without overshooting it', () => {
const splits = resolveColumnAlignedSplits({
anchor: 1000,
step: 100,
incr: 200,
min: 1050,
max: 1650,
toDate: UTC,
});
expect(splits[0]).toBeGreaterThanOrEqual(1050);
expect(splits[splits.length - 1]).toBeLessThanOrEqual(1650);
expect(splits[0] - 200).toBeLessThan(1050);
});
it("starts hourly ticks on the timezone's own hour, not the epoch's", () => {
const args = {
anchor: MIDNIGHT_UTC,
step: 5 * MINUTE,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * HOUR,
};
// 05:30 IST is midnight UTC, so the two disagree by the half hour.
expect(resolveColumnAlignedSplits({ ...args, toDate: UTC })).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
MIDNIGHT_UTC + 3 * HOUR,
]);
expect(resolveColumnAlignedSplits({ ...args, toDate: IST })).toStrictEqual([
MIDNIGHT_UTC + 0.5 * HOUR,
MIDNIGHT_UTC + 1.5 * HOUR,
MIDNIGHT_UTC + 2.5 * HOUR,
]);
});
it('gives up the round local time when no column edge carries one', () => {
// Hour-wide columns start on the UTC hour, so 00:00 IST is mid-cell and
// the nearest edge — 00:30 IST — is as close as the grid gets.
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: HOUR,
incr: HOUR,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 2 * HOUR,
toDate: IST,
});
expect(splits).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + HOUR,
MIDNIGHT_UTC + 2 * HOUR,
]);
});
it('puts a daily tick on the local day boundary', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 15 * MINUTE,
incr: DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 3 * DAY,
toDate: IST,
});
// 18:30 UTC the previous day is IST midnight, and 15m columns carry it.
expect(splits).toHaveLength(3);
splits.forEach((split) => {
expect(IST(split).getHours()).toBe(0);
expect(IST(split).getMinutes()).toBe(0);
});
});
it('walks month ticks as calendar dates, snapped to column edges', () => {
const splits = resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: DAY,
incr: 28 * DAY,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 120 * DAY,
toDate: UTC,
});
// Month starts, not a drifting 28-day cadence that repeats a month name.
expect(
splits.map((split) => new Date(split * 1e3).toISOString()),
).toStrictEqual([
'2024-04-01T00:00:00.000Z',
'2024-05-01T00:00:00.000Z',
'2024-06-01T00:00:00.000Z',
'2024-07-01T00:00:00.000Z',
]);
});
it("falls back to uPlot's own increment without a column width", () => {
expect(
resolveColumnAlignedSplits({
anchor: MIDNIGHT_UTC,
step: 0,
incr: 5 * MINUTE,
min: MIDNIGHT_UTC,
max: MIDNIGHT_UTC + 15 * MINUTE,
toDate: UTC,
}),
).toStrictEqual([
MIDNIGHT_UTC,
MIDNIGHT_UTC + 5 * MINUTE,
MIDNIGHT_UTC + 10 * MINUTE,
MIDNIGHT_UTC + 15 * MINUTE,
]);
});
it('has nothing to place on an empty or inverted range', () => {
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: MINUTE,
min: 10,
max: 10,
}),
).toStrictEqual([]);
expect(
resolveColumnAlignedSplits({
anchor: 0,
step: MINUTE,
incr: 0,
min: 0,
max: 100,
}),
).toStrictEqual([]);
});
});

View File

@@ -1,176 +0,0 @@
import { resolveHeatmapGrid } from '../grid';
import { HeatmapSeries } from '../types';
const BUCKETS = [10, 20];
const STEP = 60;
/** Two groups over two columns, each missing a value the other reports. */
const TWO_GROUPS: HeatmapSeries[] = [
{
label: 'cart',
points: [
{ timestamp: 60, counts: [1, 2, 3] },
{ timestamp: 120, counts: [null, 5, 6] },
],
},
{
label: 'checkout',
points: [
{ timestamp: 60, counts: [10, 20, 30] },
{ timestamp: 120, counts: [40, null, 60] },
],
},
];
function resolve(
overrides: Partial<Parameters<typeof resolveHeatmapGrid>[0]> = {},
): ReturnType<typeof resolveHeatmapGrid> {
return resolveHeatmapGrid({
buckets: BUCKETS,
step: STEP,
series: TWO_GROUPS,
...overrides,
});
}
describe('resolveHeatmapGrid', () => {
it('pivots per-timestamp count arrays into one row per bucket', () => {
const { counts } = resolve({ series: [TWO_GROUPS[0]] });
// 2 boundaries describe 3 rows; each row spans both columns.
expect(counts).toStrictEqual([
[1, null],
[2, 5],
[3, 6],
]);
});
it('carries the bounds and step through untouched', () => {
const { bounds, step } = resolve();
expect(bounds).toStrictEqual(BUCKETS);
expect(step).toBe(STEP);
});
it('sums every group for the combined view', () => {
const { counts } = resolve();
expect(counts[0]).toStrictEqual([11, 40]);
expect(counts[2]).toStrictEqual([33, 66]);
});
it('keeps one group"s count where the other has no data', () => {
const { counts } = resolve();
// cart is null at 120 in row 0 while checkout reports 40.
expect(counts[0][1]).toBe(40);
// checkout is null at 120 in row 1 while cart reports 5.
expect(counts[1][1]).toBe(5);
});
it('reports a cell as no-data only when every group is missing it', () => {
const { counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [null, null] }] },
{ label: 'b', points: [{ timestamp: 60, counts: [null, null] }] },
],
});
expect(counts).toStrictEqual([[null], [null]]);
});
it('distinguishes a zero count from no data', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [0, null] }] }],
});
expect(counts[0][0]).toBe(0);
expect(counts[1][0]).toBeNull();
});
it('sums only the groups the legend has enabled', () => {
const { counts } = resolve({ visibleGroups: ['cart'] });
expect(counts[0]).toStrictEqual([1, null]);
expect(counts[2]).toStrictEqual([3, 6]);
});
it('sums every group when the legend passes nothing', () => {
const { counts } = resolve({ visibleGroups: undefined });
expect(counts[0]).toStrictEqual([11, 40]);
});
it('ignores an enabled label that left the result', () => {
const { counts } = resolve({ visibleGroups: ['cart', 'gone'] });
expect(counts[0]).toStrictEqual([1, null]);
});
it('empties the grid when every group is excluded', () => {
const { timestamps, counts } = resolve({ visibleGroups: [] });
expect(timestamps).toStrictEqual([]);
expect(counts.every((row) => row.length === 0)).toBe(true);
});
it('unions timestamps when groups do not align', () => {
const { timestamps, counts } = resolve({
buckets: [10],
series: [
{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] },
{ label: 'b', points: [{ timestamp: 180, counts: [3, 4] }] },
],
});
expect(timestamps).toStrictEqual([60, 180]);
expect(counts[0]).toStrictEqual([1, 3]);
});
it('sorts columns ascending regardless of response order', () => {
const { timestamps } = resolve({
buckets: [10],
series: [
{
label: 'a',
points: [
{ timestamp: 180, counts: [1, 2] },
{ timestamp: 60, counts: [3, 4] },
],
},
],
});
expect(timestamps).toStrictEqual([60, 180]);
});
it('pads rows the response left short', () => {
const { counts } = resolve({
buckets: [10, 20, 30],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2] }] }],
});
expect(counts).toStrictEqual([[1], [2], [null], [null]]);
});
it('ignores counts beyond the bucket rows', () => {
const { counts } = resolve({
buckets: [10],
series: [{ label: 'a', points: [{ timestamp: 60, counts: [1, 2, 99] }] }],
});
expect(counts).toStrictEqual([[1], [2]]);
});
it('degrades to an empty grid with no buckets or no series', () => {
expect(resolve({ buckets: [] })).toStrictEqual({
bounds: [],
timestamps: [],
step: 0,
counts: [],
});
expect(resolve({ series: [] }).counts).toStrictEqual([]);
});
});

View File

@@ -1,314 +0,0 @@
import type uPlot from 'uplot';
import { DEFAULT_HEATMAP_COLORS } from '../colorScale';
import { resolveHeatmapYAxis } from '../geometry';
import { createHeatmapHooks } from '../heatmapPlugin';
import { HeatmapAxisScale, HeatmapCell } from '../types';
const BOUNDS = [100, 1000];
const Y_AXIS = resolveHeatmapYAxis(BOUNDS, HeatmapAxisScale.Linear);
const TIMESTAMPS = [1000, 1060, 1120];
const STEP = 60;
const PLOT_WIDTH = 300;
const PLOT_HEIGHT = 300;
// Three rows for two bounds, three columns; row 1 column 1 is a data gap.
const DATA = [
TIMESTAMPS,
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
] as unknown as uPlot.AlignedData;
interface FakeContext {
fillRect: jest.Mock;
fills: string[];
}
interface FakePlot {
plot: uPlot;
context: FakeContext;
setSeries: jest.Mock;
over: HTMLDivElement;
}
function createFakePlot(cursor: { left: number; top: number }): FakePlot {
const over = document.createElement('div');
Object.defineProperty(over, 'clientWidth', { value: PLOT_WIDTH });
Object.defineProperty(over, 'clientHeight', { value: PLOT_HEIGHT });
const fills: string[] = [];
const fillRect = jest.fn();
const context = { fills, fillRect };
const setSeries = jest.fn();
const xSpan = TIMESTAMPS[TIMESTAMPS.length - 1] + STEP - TIMESTAMPS[0];
const ySpan = Y_AXIS.max - Y_AXIS.min;
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
createPattern: jest.fn(() => null),
set fillStyle(value: string) {
fills.push(value);
},
fillRect: (...args: number[]): void => {
fillRect(...args);
},
};
const plot = {
data: DATA,
cursor,
over,
setSeries,
ctx,
bbox: { left: 0, top: 0, width: PLOT_WIDTH, height: PLOT_HEIGHT },
scales: { x: { min: TIMESTAMPS[0], max: TIMESTAMPS[2] + STEP } },
// x grows left to right; y is inverted, so the highest bucket is at the top.
valToPos: (value: number, scaleKey: string): number =>
scaleKey === 'x'
? ((value - TIMESTAMPS[0]) / xSpan) * PLOT_WIDTH
: PLOT_HEIGHT - ((value - Y_AXIS.min) / ySpan) * PLOT_HEIGHT,
posToVal: (pos: number, scaleKey: string): number =>
scaleKey === 'x'
? TIMESTAMPS[0] + (pos / PLOT_WIDTH) * xSpan
: Y_AXIS.min + ((PLOT_HEIGHT - pos) / PLOT_HEIGHT) * ySpan,
};
return { plot: plot as unknown as uPlot, context, setSeries, over };
}
/** 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,
): ReturnType<typeof createHeatmapHooks> {
return createHeatmapHooks({
yAxis: Y_AXIS,
step: STEP,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
dimOnHover,
onHoverChange,
});
}
describe('heatmap renderer — lifecycle', () => {
it('mounts the hover overlay into the plot overlay and tears it down', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).not.toBeNull();
hooks.destroy(plot);
expect(
over.querySelector('[data-testid="heatmap-hover-overlay"]'),
).toBeNull();
});
});
describe('heatmap renderer — draw', () => {
it('paints every cell of every visible column', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
hooks.init(plot);
hooks.draw(plot);
// 3 rows x 3 columns, less the one null cell that has no hatch pattern
// available under jsdom.
expect(context.fillRect).toHaveBeenCalledTimes(8);
});
it('gives a zero count the bottom-of-scale fill rather than skipping it', () => {
const hooks = createHooks();
const zeroed = [TIMESTAMPS, [0, 0, 0], [0, 0, 0], [0, 0, 0]];
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = zeroed;
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).toHaveBeenCalledTimes(9);
expect(new Set(context.fills).size).toBe(1);
});
it('skips columns outside the current x range', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { scales: unknown }).scales = {
x: { min: TIMESTAMPS[0], max: TIMESTAMPS[0] + STEP },
};
hooks.init(plot);
hooks.draw(plot);
// Only the first two columns overlap the range; the third starts past its end.
// 2 columns x 3 rows, less the null cell in column 1.
expect(context.fillRect).toHaveBeenCalledTimes(5);
});
it('draws nothing without columns', () => {
const hooks = createHooks();
const { plot, context } = createFakePlot({ left: -10, top: -10 });
(plot as { data: unknown }).data = [[]];
hooks.init(plot);
hooks.draw(plot);
expect(context.fillRect).not.toHaveBeenCalled();
});
});
describe('heatmap renderer — hover', () => {
it('focuses the hovered row and reports the cell under the cursor', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
// Left third of the plot is column 0; the top third is the overflow row.
const { plot, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({ row: 2, column: 0, count: 7 });
expect(setSeries).toHaveBeenCalledWith(3, { focus: true });
});
it('reports a data gap as a null count instead of zero', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({
left: PLOT_WIDTH / 2,
top: topOfRow(1),
});
hooks.init(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledWith({
row: 1,
column: 1,
count: null,
});
});
it('does not re-report the same cell', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenCalledTimes(1);
});
it('shows the overlay over the hovered cell and dims around it', () => {
const hooks = createHooks(undefined, true);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.display).toBe('block');
// Column 0 spans the left third of a 300px plot.
expect(overlay?.lastElementChild).toHaveStyle({
left: '0px',
width: '100px',
});
});
it('clips the overlay to the plot area', () => {
const hooks = createHooks();
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
// An end cell whose bucket or time slice is only partly in view is positioned
// past the axis; the plot area's edge is where the highlight has to stop.
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.style.overflow).toBe('hidden');
});
it('collapses the dim rects when dimming is off', () => {
const hooks = createHooks(undefined, false);
const { plot, over } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
const overlay = over.querySelector<HTMLDivElement>(
'[data-testid="heatmap-hover-overlay"]',
);
expect(overlay?.firstElementChild).toHaveStyle({
width: '0px',
height: '0px',
});
});
it('releases focus and hides the overlay when the cursor leaves', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot, over, setSeries } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { cursor: { left: number; top: number } }).cursor = {
left: -10,
top: -10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
expect(setSeries).toHaveBeenLastCalledWith(null, { focus: true });
expect(
over.querySelector<HTMLDivElement>('[data-testid="heatmap-hover-overlay"]')
?.style.display,
).toBe('none');
});
it('clears the hover when the cursor is inside the plot but past the last column', () => {
const onHoverChange = jest.fn();
const hooks = createHooks(onHoverChange);
const { plot } = createFakePlot({ left: 10, top: 10 });
hooks.init(plot);
hooks.setCursor(plot);
(plot as { data: unknown }).data = [[], [], [], []];
(plot as { cursor: { left: number; top: number } }).cursor = {
left: 10,
top: 10,
};
hooks.setCursor(plot);
expect(onHoverChange).toHaveBeenLastCalledWith(null);
});
});

View File

@@ -1,67 +0,0 @@
import { getPaletteStops } from '../palettes';
import { HeatmapColorPalette } from '../types';
const ALL_PALETTES = Object.values(HeatmapColorPalette);
/** Perceived brightness, good enough to tell a ramp's ends apart. */
function luminance(hex: string): number {
const value = parseInt(hex.slice(1), 16);
// eslint-disable-next-line no-bitwise
const [r, g, b] = [(value >> 16) & 255, (value >> 8) & 255, value & 255];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
describe('getPaletteStops', () => {
it.each(ALL_PALETTES)('%s is a full ramp of valid colours', (palette) => {
const stops = getPaletteStops(palette, true);
expect(stops).toHaveLength(9);
stops.forEach((stop) => expect(stop).toMatch(/^#[0-9a-f]{6}$/));
});
it.each(ALL_PALETTES)(
'%s climbs from dark to bright on a dark panel',
(palette) => {
const stops = getPaletteStops(palette, true);
// Low counts must sit near the surface, whichever direction the ramp is
// stored in — otherwise empty cells become the loudest thing on screen.
expect(luminance(stops[0])).toBeLessThan(luminance(stops[stops.length - 1]));
},
);
it.each(ALL_PALETTES)(
'%s falls from pale to saturated on a light panel',
(palette) => {
const stops = getPaletteStops(palette, false);
expect(luminance(stops[0])).toBeGreaterThan(
luminance(stops[stops.length - 1]),
);
},
);
it.each(ALL_PALETTES)('%s uses the same colours in both themes', (palette) => {
// Only the polarity flips; the palette itself is theme-independent.
expect([...getPaletteStops(palette, false)].reverse()).toStrictEqual(
getPaletteStops(palette, true),
);
});
it('never mutates the stored ramp when reversing it', () => {
const first = getPaletteStops(HeatmapColorPalette.Lava, false);
const second = getPaletteStops(HeatmapColorPalette.Lava, false);
expect(first).toStrictEqual(second);
});
it('offers a neutral ramp for panels that already spend colour elsewhere', () => {
const stops = getPaletteStops(HeatmapColorPalette.Graphite, true);
// Every stop is a grey: red, green and blue channels stay equal.
stops.forEach((stop) => {
expect(stop.slice(1, 3)).toBe(stop.slice(3, 5));
expect(stop.slice(3, 5)).toBe(stop.slice(5, 7));
});
});
});

View File

@@ -1,257 +0,0 @@
import { Color as DesignToken } from '@signozhq/design-tokens';
import Color from 'color';
import { DEFAULT_HEATMAP_PALETTE, getPaletteStops } from './palettes';
import {
HeatmapColorMode,
HeatmapColorOptions,
HeatmapColorScale,
} from './types';
export const MIN_COLOR_STEPS = 2;
export const MAX_COLOR_STEPS = 128;
export const DEFAULT_COLOR_STEPS = 64;
/** Without a floor, the lowest counts read as "no data". */
export const MIN_OPACITY_ALPHA = 0.1;
/** Log's bottom when the grid holds no positive count to take it from. */
const DEFAULT_LOG_FLOOR = 1;
/** Widest span a single ramp is stretched over on a log scale. */
const MAX_LOG_DECADES = 6;
/** Used when neither an explicit fill nor a series colour is available. */
export const DEFAULT_OPACITY_FILL = DesignToken.BG_ROBIN_500;
/** 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,
minCount: null,
maxCount: null,
palette: DEFAULT_HEATMAP_PALETTE,
steps: DEFAULT_COLOR_STEPS,
fill: '',
};
export interface CountDomain {
min: number;
max: number;
/** Bottom decade of the log scale: the smallest count it separates from zero.
* Always positive, since log has no bottom otherwise. */
logFloor: number;
}
/** Highest count, ignoring `null`. 0 for an empty grid. */
export function getMaxCount(counts: Array<Array<number | null>>): number {
let max = 0;
for (const row of counts) {
for (const count of row) {
if (count !== null && Number.isFinite(count) && count > max) {
max = count;
}
}
}
return max;
}
/** Smallest count above zero, ignoring `null`. `null` for a grid without one. */
export function getSmallestPositiveCount(
counts: Array<Array<number | null>>,
): number | null {
let smallest: number | null = null;
for (const row of counts) {
for (const count of row) {
if (
count !== null &&
Number.isFinite(count) &&
count > 0 &&
(smallest === null || count < smallest)
) {
smallest = count;
}
}
}
return smallest;
}
/**
* The grid's own resolution: whole counts floor at 1, while a heatmap of rates or
* ratios can live entirely below it, where a fixed floor of 1 would flatten every
* cell onto the bottom colour. Capped at `MAX_LOG_DECADES` so one stray tiny cell
* cannot stretch the ramp over a range nothing else occupies.
*/
function resolveLogFloor(
counts: Array<Array<number | null>>,
max: number,
): number {
const smallest = getSmallestPositiveCount(counts) ?? DEFAULT_LOG_FLOOR;
return Math.max(smallest, max / 10 ** MAX_LOG_DECADES);
}
/** Explicit clamps win; otherwise 0 to the grid's highest count. */
export function resolveCountDomain(
options: Pick<HeatmapColorOptions, 'minCount' | 'maxCount'>,
counts: Array<Array<number | null>>,
): CountDomain {
const min = options.minCount ?? 0;
const max = options.maxCount ?? getMaxCount(counts);
const logFloor = resolveLogFloor(counts, max);
return max > min ? { min, max, logFloor } : { min, max: min, logFloor };
}
/** Position on the colour scale, 0..1. A degenerate domain collapses to 0 so an
* all-zero grid renders at the bottom rather than disappearing. */
export function normalizeCount({
count,
domain,
scale,
}: {
count: number;
domain: CountDomain;
scale: HeatmapColorScale;
}): number {
const { min, max } = domain;
if (!(max > min)) {
return 0;
}
const clamped = Math.min(Math.max(count, min), max);
if (scale === HeatmapColorScale.Log) {
// Anything at or below the floor sits at the bottom; log cannot place it.
const bottom = Math.max(min, domain.logFloor);
const logMin = Math.log10(bottom);
const logMax = Math.log10(max);
if (logMax > logMin) {
return (Math.log10(Math.max(clamped, bottom)) - logMin) / (logMax - logMin);
}
// The floor already reaches the top of the domain, so there is no span to
// spread logarithmically. Fall through rather than flatten the whole grid.
}
const linear = (clamped - min) / (max - min);
return scale === HeatmapColorScale.Sqrt ? Math.sqrt(linear) : linear;
}
export function clampColorSteps(steps: number): number {
if (!Number.isFinite(steps)) {
return DEFAULT_COLOR_STEPS;
}
return Math.min(Math.max(Math.round(steps), MIN_COLOR_STEPS), MAX_COLOR_STEPS);
}
/** Colour at `t` (0..1) along a multi-stop ramp. */
function sampleStops(stops: string[], t: number): string {
if (stops.length === 0) {
return 'transparent';
}
if (stops.length === 1) {
return stops[0];
}
const scaled = Math.min(Math.max(t, 0), 1) * (stops.length - 1);
const lower = Math.min(Math.floor(scaled), stops.length - 2);
return Color(stops[lower])
.mix(Color(stops[lower + 1]), scaled - lower)
.hex();
}
/**
* Colour the densest cells are drawn with — the palette's extreme, or the opacity
* fill at full strength. Depends only on the options, not on the data, so callers
* can read it before a grid exists.
*/
export function resolveExtremeColor({
options,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
isDarkMode: boolean;
seriesColor: string;
}): string {
if (options.mode === HeatmapColorMode.Opacity) {
return options.fill || seriesColor || DEFAULT_OPACITY_FILL;
}
const stops = getPaletteStops(options.palette, isDarkMode);
return stops[stops.length - 1] ?? DEFAULT_OPACITY_FILL;
}
export interface HeatmapColorResolver {
/** `null` for a `null` count, which must be hatched. */
colorFor: (count: number | null) => string | null;
/** 0..1, or `null` for a `null` count. */
positionOf: (count: number | null) => number | null;
/** Low to high. The colour bar renders exactly these. */
ramp: string[];
domain: CountDomain;
}
/** Palette mode walks a sequential ramp; opacity mode varies the alpha of one
* fill, so the grid matches its group's legend swatch. */
export function createHeatmapColorResolver({
options,
domain,
isDarkMode,
seriesColor,
}: {
options: HeatmapColorOptions;
domain: CountDomain;
isDarkMode: boolean;
/** Opacity-mode fill when `options.fill` is empty. */
seriesColor: string;
}): HeatmapColorResolver {
const steps = clampColorSteps(options.steps);
const positions = Array.from({ length: steps }, (_, index) =>
steps === 1 ? 0 : index / (steps - 1),
);
let ramp: string[];
if (options.mode === HeatmapColorMode.Opacity) {
const base = Color(options.fill || seriesColor || DEFAULT_OPACITY_FILL);
ramp = positions.map((t) =>
base
.alpha(MIN_OPACITY_ALPHA + t * (1 - MIN_OPACITY_ALPHA))
.rgb()
.string(),
);
} else {
const stops = getPaletteStops(options.palette, isDarkMode);
ramp = positions.map((t) => sampleStops(stops, t));
}
const positionOf = (count: number | null): number | null => {
if (count === null || !Number.isFinite(count)) {
return null;
}
return normalizeCount({ count, domain, scale: options.scale });
};
// 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;
}
const index = Math.min(Math.floor(t * steps), steps - 1);
return ramp[index];
},
ramp,
domain,
};
}

View File

@@ -1,537 +0,0 @@
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: [],
splits: [],
overflowSplit: null,
toBucketValue: (axisValue: number): number => axisValue,
min: 0,
max: 1,
};
/** Ascending, finite, de-duplicated boundaries. */
function normalizeBounds(bounds: number[]): number[] {
const sorted = bounds
.filter((bound) => Number.isFinite(bound))
.sort((a, b) => a - b);
return sorted.filter(
(bound, index) => index === 0 || bound !== sorted[index - 1],
);
}
/** True when a plain log axis can place every boundary. */
export function canUseLogAxis(bounds: number[]): boolean {
return bounds.length > 0 && bounds.every((bound) => bound > 0);
}
interface AxisTransform {
toAxisValue: (value: number) => number;
toBucketValue: (axisValue: number) => number;
}
const LINEAR_TRANSFORM: AxisTransform = {
toAxisValue: (value) => value,
toBucketValue: (axisValue) => axisValue,
};
const LOG_TRANSFORM: AxisTransform = {
toAxisValue: (value) => Math.log10(value),
toBucketValue: (axisValue) => 10 ** axisValue,
};
/**
* Where "near zero" starts, taken as the smallest non-zero boundary magnitude. The
* bucket layout already declares it, so it never needs to be configured.
*/
function resolveLinearThreshold(bounds: number[]): number {
let threshold = Number.POSITIVE_INFINITY;
for (const bound of bounds) {
const magnitude = Math.abs(bound);
if (magnitude > 0 && magnitude < threshold) {
threshold = magnitude;
}
}
return Number.isFinite(threshold) ? threshold : 1;
}
/**
* Symmetric log: linear within ±threshold, logarithmic beyond, mirrored across
* zero. Bucketing an arbitrary logs/traces field can straddle zero — clock skew,
* deltas, balances — which a plain log cannot place at all, and which a linear axis
* squeezes into sub-pixel rows exactly where the interesting data sits.
*
* The gradient kink at ±threshold is invisible here: the threshold *is* a boundary,
* so it lands on a row edge, and row edges are already discrete.
*/
function createSymlogTransform(threshold: number): AxisTransform {
return {
toAxisValue: (value) =>
Math.abs(value) <= threshold
? value / threshold
: Math.sign(value) * (1 + Math.log10(Math.abs(value) / threshold)),
toBucketValue: (axisValue) =>
Math.abs(axisValue) <= 1
? axisValue * threshold
: Math.sign(axisValue) * threshold * 10 ** (Math.abs(axisValue) - 1),
};
}
/** Symmetric log about the threshold the bucket layout implies. All-zero
* boundaries have no magnitude to scale against and stay linear. */
function resolveSymlogTransform(bounds: number[]): AxisTransform {
if (!bounds.some((bound) => bound !== 0)) {
return LINEAR_TRANSFORM;
}
return createSymlogTransform(resolveLinearThreshold(bounds));
}
/** One typical bucket, in axis space — the mean ratio between adjacent positive
* boundaries, which on a geometric layout is exactly one bucket. */
function resolveLogGap(positive: number[]): number {
const axisFirst = Math.log10(positive[0]);
const axisLast = Math.log10(positive[positive.length - 1]);
const gap =
positive.length > 1
? (axisLast - axisFirst) / (positive.length - 1)
: Math.log10(FALLBACK_LOG_RATIO);
return gap > 0 ? gap : Math.log10(FALLBACK_LOG_RATIO);
}
/**
* Plain log10, with the boundaries a logarithm has no answer for — zero and
* below — pinned one bucket beneath the smallest positive one. They keep their
* own rows, ticks and labels; only their height is synthetic, and it is the
* height of a bucket rather than the decade a symmetric log would spend on them.
*
* Several of them share that one edge, which squashes them together: a layout
* that straddles zero wants `Symlog`. This is the scale for the one non-positive
* boundary an explicit-bounds histogram routinely carries — its zero bucket.
*/
function createFloorLogTransform(positive: number[]): AxisTransform {
const floor = Math.log10(positive[0]) - resolveLogGap(positive);
return {
toAxisValue: (value) => (value > 0 ? Math.log10(value) : floor),
toBucketValue: (axisValue) => 10 ** axisValue,
};
}
function resolveAxisTransform(
bounds: number[],
scale: HeatmapAxisScale,
): AxisTransform {
if (scale === HeatmapAxisScale.Linear) {
return LINEAR_TRANSFORM;
}
if (scale === HeatmapAxisScale.Symlog) {
return resolveSymlogTransform(bounds);
}
if (canUseLogAxis(bounds)) {
return LOG_TRANSFORM;
}
// Only a negative boundary straddles zero; the lone zero an explicit-bounds
// histogram carries does not.
if (scale === HeatmapAxisScale.Auto && bounds.some((bound) => bound < 0)) {
return resolveSymlogTransform(bounds);
}
const positive = bounds.filter((bound) => bound > 0);
return positive.length > 0
? createFloorLogTransform(positive)
: LINEAR_TRANSFORM;
}
/**
* The open-ended rows still need a height, so each gets the grid's typical bucket
* width — the mean gap in axis space, which on a geometric layout is exactly one
* bucket ratio. Linear stays in value space so it can refuse to cross zero.
*/
function resolveOuterEdges(
bounds: number[],
transform: AxisTransform,
isLinear: boolean,
): { lower: number; upper: number } {
const first = bounds[0];
const last = bounds[bounds.length - 1];
if (isLinear) {
const gap = bounds.length > 1 ? (last - first) / (bounds.length - 1) : 0;
const safeGap = gap > 0 ? gap : Math.abs(first) || 1;
// Never extend below zero unless the boundaries already do.
const lower = first > 0 ? Math.max(0, first - safeGap) : first - safeGap;
return { lower, upper: last + safeGap * OVERFLOW_ROW_HEIGHT_RATIO };
}
const axisFirst = transform.toAxisValue(first);
const axisLast = transform.toAxisValue(last);
const fallback = Math.log10(FALLBACK_LOG_RATIO);
const gap =
bounds.length > 1 ? (axisLast - axisFirst) / (bounds.length - 1) : fallback;
const safeGap = gap > 0 ? gap : fallback;
return {
lower: transform.toBucketValue(axisFirst - safeGap),
upper: transform.toBucketValue(
axisLast + safeGap * OVERFLOW_ROW_HEIGHT_RATIO,
),
};
}
/** N boundaries produce N+1 rows: an underflow row below the first, and the
* `+Inf` overflow row above the last. */
export function resolveHeatmapYAxis(
bounds: number[],
scale: HeatmapAxisScale,
): HeatmapYAxis {
const normalized = normalizeBounds(bounds);
if (normalized.length === 0) {
return EMPTY_Y_AXIS;
}
const transform = resolveAxisTransform(normalized, scale);
const isLinear = transform === LINEAR_TRANSFORM;
const { toAxisValue, toBucketValue } = transform;
const { lower, upper } = resolveOuterEdges(normalized, transform, isLinear);
const last = normalized[normalized.length - 1];
const rows: HeatmapRow[] = [
{ lower, upper: normalized[0], isUnderflow: true, isOverflow: false },
];
for (let index = 1; index < normalized.length; index += 1) {
rows.push({
lower: normalized[index - 1],
upper: normalized[index],
isUnderflow: false,
isOverflow: false,
});
}
rows.push({ lower: last, upper, isUnderflow: false, isOverflow: true });
const edges = [
toAxisValue(lower),
...normalized.map(toAxisValue),
toAxisValue(upper),
];
return {
rows,
edges,
splits: normalized.map(toAxisValue),
overflowSplit: toAxisValue(upper),
toBucketValue,
min: edges[0],
max: edges[edges.length - 1],
};
}
/** 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[],
axisValue: number,
): number | null {
if (edges.length < 2) {
return null;
}
if (axisValue < edges[0] || axisValue > edges[edges.length - 1]) {
return null;
}
let low = 0;
let high = edges.length - 2;
while (low <= high) {
const mid = (low + high) >> 1;
if (axisValue < edges[mid]) {
high = mid - 1;
} else if (axisValue >= edges[mid + 1]) {
low = mid + 1;
} else {
return mid;
}
}
// Exactly on the top edge.
return edges.length - 2;
}
/**
* A containment test, not a nearest-timestamp lookup: uPlot's own `cursor.idx`
* snaps to the closest boundary and would report the next column as soon as the
* cursor passed a cell's midpoint.
*/
export function resolveColumnIndex(
timestamps: ArrayLike<number>,
xValue: number,
step: number,
): number | null {
if (timestamps.length === 0) {
return null;
}
let low = 0;
let high = timestamps.length - 1;
let candidate = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (timestamps[mid] <= xValue) {
candidate = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
if (candidate < 0) {
return null;
}
const width = step > 0 ? step : Number.POSITIVE_INFINITY;
return xValue < timestamps[candidate] + width ? candidate : null;
}
/** The open-ended rows are labelled by their one real boundary; the synthetic
* edge is a drawing device, not a value. */
export function formatRowLabel(
row: HeatmapRow,
formatValue: (value: number) => string,
): string {
if (row.isOverflow) {
return `> ${formatValue(row.lower)}`;
}
if (row.isUnderflow) {
return `≤ ${formatValue(row.upper)}`;
}
return `${formatValue(row.lower)} – ${formatValue(row.upper)}`;
}
/**
* Drops boundary ticks that would overlap. Filters by pixel distance rather than
* index, since linear rows are not the same height, and walks down from the top
* so the `∞` edge survives whatever else is dropped.
*/
export function decimateAxisSplits({
splits,
min,
max,
plotHeight,
minGapPx,
}: {
/** Candidates in axis space, ascending. */
splits: number[];
min: number;
max: number;
/** Plotting area height, in CSS pixels. */
plotHeight: number;
minGapPx: number;
}): number[] {
if (splits.length < 2 || plotHeight <= 0 || minGapPx <= 0 || !(max > min)) {
return splits;
}
const pixelsPerUnit = plotHeight / (max - min);
const kept: number[] = [];
let lastPosition = 0;
for (let index = splits.length - 1; index >= 0; index -= 1) {
// Axis values grow upward, pixel offsets downward.
const position = (max - splits[index]) * pixelsPerUnit;
if (kept.length === 0 || position - lastPosition >= minGapPx) {
kept.push(splits[index]);
lastPosition = position;
}
}
return kept.reverse();
}
/** Where uPlot switches from a fixed increment to a calendar walk. */
const MONTH_INCR_SECONDS = 3600 * 24 * 28;
const YEAR_INCR_SECONDS = 3600 * 24 * 365;
/** Shifts a timestamp into the axis timezone, as uPlot's `tzDate` does: the
* returned date's *local* fields read as that timezone's wall clock. */
type ToAxisDate = (timestamp: number) => Date;
const BROWSER_DATE: ToAxisDate = (timestamp) => new Date(timestamp * 1e3);
/**
* Real epoch seconds of the midnight at or before `timestamp`, in the axis
* timezone. The browser's own offset cancels: it is inside the shifted date's
* fields and inside the correction.
*/
function resolveDayOrigin(timestamp: number, toDate: ToAxisDate): number {
const shifted = toDate(timestamp);
const midnight = new Date(
shifted.getFullYear(),
shifted.getMonth(),
shifted.getDate(),
);
const correction = Math.floor(timestamp) - Math.floor(shifted.getTime() / 1e3);
return Math.floor(midnight.getTime() / 1e3) + correction;
}
function fromAxisDate(wall: Date, toDate: ToAxisDate): number {
const wallTs = Math.floor(wall.getTime() / 1e3);
return wallTs + (wallTs - Math.floor(toDate(wallTs).getTime() / 1e3));
}
function snapToColumnEdge(value: number, phase: number, width: number): number {
return phase + Math.round((value - phase) / width) * width;
}
/**
* Month and year ticks, walked as calendar dates the way uPlot walks them — no
* fixed increment expresses a month. Their spacing is uneven to begin with, so
* each tick is snapped to its own nearest column edge.
*/
function resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
}: {
incr: number;
min: number;
max: number;
toDate: ToAxisDate;
phase: number;
columnWidth: number;
}): number[] {
const isYear = incr >= YEAR_INCR_SECONDS;
const monthsPerTick = Math.max(
1,
isYear
? Math.round(incr / YEAR_INCR_SECONDS) * 12
: Math.round(incr / MONTH_INCR_SECONDS),
);
const start = toDate(min);
const baseYear = start.getFullYear();
const baseMonth = isYear ? 0 : start.getMonth();
const splits: number[] = [];
for (let index = 0; ; index += 1) {
const wall = new Date(baseYear, baseMonth + monthsPerTick * index, 1);
const value = snapToColumnEdge(
fromAxisDate(wall, toDate),
phase,
columnWidth,
);
if (value > max) {
break;
}
if (value >= min && value !== splits[splits.length - 1]) {
splits.push(value);
}
}
return splits;
}
/**
* Time ticks placed on column edges, so a vertical grid line falls in the gap
* between two cells instead of through one. uPlot's increment is rounded up to a
* whole number of columns, and the sequence starts at the column edge nearest
* the timezone's midnight — the closest the grid can get to the ticks uPlot
* would have drawn. Where midnight is itself an edge, they are those ticks.
*/
export function resolveColumnAlignedSplits({
anchor,
step,
incr,
min,
max,
toDate = BROWSER_DATE,
}: {
/** Any column start: every edge sits at `anchor + n * step`. */
anchor: number;
/** Column width in seconds. */
step: number;
/** Increment uPlot picked for the axis, in seconds. */
incr: number;
min: number;
max: number;
toDate?: ToAxisDate;
}): number[] {
if (!(incr > 0) || !(max > min)) {
return [];
}
const columnWidth = step > 0 ? step : incr;
const phase = step > 0 ? ((anchor % step) + step) % step : 0;
if (incr >= MONTH_INCR_SECONDS) {
return resolveCalendarSplits({
incr,
min,
max,
toDate,
phase,
columnWidth,
});
}
const tickIncr = Math.ceil(incr / columnWidth) * columnWidth;
const origin = snapToColumnEdge(
resolveDayOrigin(min, toDate),
phase,
columnWidth,
);
const splits: number[] = [];
for (let index = Math.ceil((min - origin) / tickIncr); ; index += 1) {
const value = origin + index * tickIncr;
if (value > max) {
break;
}
splits.push(value);
}
return splits;
}

View File

@@ -1,77 +0,0 @@
import { HeatmapGrid, HeatmapSeries } from './types';
const EMPTY_GRID: HeatmapGrid = {
bounds: [],
timestamps: [],
step: 0,
counts: [],
};
/** Groups the legend currently has enabled. `undefined` means all of them. */
function resolveVisible(
series: HeatmapSeries[],
visibleGroups: string[] | undefined,
): HeatmapSeries[] {
if (visibleGroups === undefined) {
return series;
}
const allowed = new Set(visibleGroups);
return series.filter((entry) => allowed.has(entry.label));
}
/**
* Pivots the response's column-major counts into the row-major grid the renderer
* draws, and sums the enabled groups — counts are additive, so the sum is exact and
* needs no extra request. A cell is `null` only when no group contributed to it.
*/
export function resolveHeatmapGrid({
buckets,
step,
series,
visibleGroups,
}: {
buckets: number[];
/** Column width in seconds. */
step: number;
series: HeatmapSeries[];
/** Labels the legend has enabled. `undefined` sums every group. */
visibleGroups?: string[];
}): HeatmapGrid {
if (buckets.length === 0 || series.length === 0) {
return EMPTY_GRID;
}
const selected = resolveVisible(series, visibleGroups);
// Groups are not guaranteed to share timestamps, so the columns are their union.
const timestampSet = new Set<number>();
selected.forEach((entry) => {
entry.points.forEach((point) => timestampSet.add(point.timestamp));
});
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
const columnOf = new Map(timestamps.map((value, index) => [value, index]));
// N boundaries describe N+1 rows: the underflow row and the `+Inf` overflow row.
const rowCount = buckets.length + 1;
const counts: Array<Array<number | null>> = Array.from(
{ length: rowCount },
() => new Array<number | null>(timestamps.length).fill(null),
);
selected.forEach((entry) => {
entry.points.forEach((point) => {
const column = columnOf.get(point.timestamp);
if (column === undefined) {
return;
}
point.counts.forEach((count, row) => {
if (row >= rowCount || count === null || count === undefined) {
return;
}
counts[row][column] = (counts[row][column] ?? 0) + count;
});
});
});
return { bounds: buckets, timestamps, step, counts };
}

View File

@@ -1,178 +0,0 @@
import uPlot from 'uplot';
import {
createHeatmapColorResolver,
HeatmapColorResolver,
resolveCountDomain,
} from './colorScale';
import { resolveColumnIndex, resolveRowIndex } from './geometry';
import {
createHoverOverlay,
HeatmapHoverOverlay,
showHoverOverlay,
} from './hoverOverlay';
import { createHatchPattern, drawCells, drawOverflowBoundary } from './paint';
import { HeatmapCell, HeatmapColorOptions, HeatmapYAxis } from './types';
export interface HeatmapRenderOptions {
yAxis: HeatmapYAxis;
/** Column width in seconds. */
step: number;
colors: HeatmapColorOptions;
isDarkMode: boolean;
/** Opacity-mode fill when `colors.fill` is empty. */
seriesColor: string;
/** Default true. */
dimOnHover?: boolean;
/** `null` when the cursor leaves. */
onHoverChange?: (cell: HeatmapCell | null) => void;
}
/**
* Registered through `UPlotConfigBuilder.addHook`, not as a `uPlot.Plugin`: uPlot
* appends plugin hooks *after* the hook arrays, and `setCursor` must run before
* TooltipPlugin's so the focused row is resolved when the tooltip positions
* itself. As a plugin it trails a frame and the tooltip flashes at the origin.
*/
export interface HeatmapHooks {
init: (u: uPlot) => void;
draw: (u: uPlot) => void;
setCursor: (u: uPlot) => void;
destroy: (u: uPlot) => void;
}
export function createHeatmapHooks({
yAxis,
step,
colors,
isDarkMode,
seriesColor,
dimOnHover = true,
onHoverChange,
}: HeatmapRenderOptions): HeatmapHooks {
let overlay: HeatmapHoverOverlay | null = null;
let hovered: HeatmapCell | null = null;
let hatchPattern: CanvasPattern | null = null;
// On auto, the domain comes from the data, but these hooks are captured once at
// config-build time. Resolving lazily keeps a refetch on uPlot's `setData` path
// rather than forcing a rebuild.
let cachedData: uPlot.AlignedData | null = null;
let cachedResolver: HeatmapColorResolver | null = null;
function getResolver(u: uPlot): HeatmapColorResolver {
if (cachedResolver && cachedData === u.data) {
return cachedResolver;
}
cachedResolver = createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(
colors,
u.data.slice(1) as Array<Array<number | null>>,
),
isDarkMode,
seriesColor,
});
cachedData = u.data;
return cachedResolver;
}
function clearHover(u: uPlot): void {
if (overlay) {
overlay.container.style.display = 'none';
}
if (hovered === null) {
return;
}
hovered = null;
u.setSeries(null, { focus: true });
onHoverChange?.(null);
}
return {
init: (u: uPlot): void => {
overlay = createHoverOverlay(isDarkMode);
u.over.appendChild(overlay.container);
},
draw: (u: uPlot): void => {
const timestamps = u.data[0] as ArrayLike<number> | undefined;
if (!timestamps?.length || yAxis.rows.length === 0) {
return;
}
const { ctx } = u;
hatchPattern ??= createHatchPattern(ctx, isDarkMode);
ctx.save();
ctx.beginPath();
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
ctx.clip();
drawCells({ u, yAxis, step, resolver: getResolver(u), hatchPattern });
ctx.restore();
drawOverflowBoundary({ u, yAxis, isDarkMode });
},
setCursor: (u: uPlot): void => {
const { left = -10, top = -10 } = u.cursor;
if (left < 0 || top < 0) {
clearHover(u);
return;
}
const column = resolveColumnIndex(
u.data[0] as ArrayLike<number>,
u.posToVal(left, 'x'),
step,
);
const row = resolveRowIndex(yAxis.edges, u.posToVal(top, 'y'));
if (column === null || row === null) {
clearHover(u);
return;
}
const count =
(u.data[row + 1] as Array<number | null> | undefined)?.[column] ?? null;
// The count is part of the identity: a refetch swaps the data under a
// stationary cursor, and the cell it points at then means something else.
if (
hovered?.row === row &&
hovered?.column === column &&
hovered?.count === count
) {
return;
}
hovered = { row, column, count };
// Drives TooltipPlugin, which only shows a tooltip for a focused series.
// uPlot's own focus is disabled here: it picks the series nearest in value
// space, and a heatmap's value is a colour, not a y coordinate.
u.setSeries(row + 1, { focus: true });
if (overlay) {
showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim: dimOnHover,
});
}
onHoverChange?.(hovered);
},
destroy: (): void => {
overlay?.container.remove();
overlay = null;
hatchPattern = null;
cachedData = null;
cachedResolver = null;
// A rebuilt plot starts with no cursor, so a listener still holding this
// cell would keep drawing a hover that no longer exists.
if (hovered !== null) {
hovered = null;
onHoverChange?.(null);
}
},
};
}

View File

@@ -1,122 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapYAxis } from './types';
const HIGHLIGHT_BORDER_WIDTH = 1;
/** ~55% alpha. */
const DIM_ALPHA = '8C';
export interface HeatmapHoverOverlay {
container: HTMLDivElement;
highlight: HTMLDivElement;
/** Four corner rects whose complement is the hovered row/column cross. */
dims: HTMLDivElement[];
}
function createOverlayElement(): HTMLDivElement {
const element = document.createElement('div');
element.style.position = 'absolute';
element.style.pointerEvents = 'none';
return element;
}
function setRect(
element: HTMLDivElement,
left: number,
top: number,
width: number,
height: number,
): void {
element.style.left = `${left}px`;
element.style.top = `${top}px`;
element.style.width = `${Math.max(0, width)}px`;
element.style.height = `${Math.max(0, height)}px`;
}
/** Kept out of the canvas so moving between cells repositions a few nodes
* instead of repainting the grid. */
export function createHoverOverlay(isDarkMode: boolean): HeatmapHoverOverlay {
const container = createOverlayElement();
container.style.inset = '0';
container.style.display = 'none';
// The plot area's edge, as the canvas clip is to the cells: a cell at either end
// runs past the axis when its bucket or its time slice is only partly in view,
// and the highlight would otherwise be drawn over the axis and the panel.
container.style.overflow = 'hidden';
container.setAttribute('data-testid', 'heatmap-hover-overlay');
const dimColor = `${
isDarkMode ? Color.BG_INK_500 : Color.BG_VANILLA_100
}${DIM_ALPHA}`;
const dims = Array.from({ length: 4 }, () => {
const dim = createOverlayElement();
dim.style.background = dimColor;
container.appendChild(dim);
return dim;
});
const highlight = createOverlayElement();
highlight.style.border = `${HIGHLIGHT_BORDER_WIDTH}px solid ${
isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_300
}`;
highlight.style.boxSizing = 'border-box';
container.appendChild(highlight);
return { container, highlight, dims };
}
/** Positions the highlight, and the four corner rects so only the hovered row
* and column stay at full contrast. */
export function showHoverOverlay({
overlay,
u,
yAxis,
step,
row,
column,
dim,
}: {
overlay: HeatmapHoverOverlay;
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
row: number;
column: number;
dim: boolean;
}): void {
const timestamps = u.data[0] as ArrayLike<number>;
const width = u.over.clientWidth;
const height = u.over.clientHeight;
const cellLeft = u.valToPos(timestamps[column], 'x');
const cellRight = u.valToPos(timestamps[column] + step, 'x');
const cellTop = u.valToPos(yAxis.edges[row + 1], 'y');
const cellBottom = u.valToPos(yAxis.edges[row], 'y');
setRect(
overlay.highlight,
cellLeft,
cellTop,
cellRight - cellLeft,
cellBottom - cellTop,
);
const [topLeft, topRight, bottomLeft, bottomRight] = overlay.dims;
if (dim) {
setRect(topLeft, 0, 0, cellLeft, cellTop);
setRect(topRight, cellRight, 0, width - cellRight, cellTop);
setRect(bottomLeft, 0, cellBottom, cellLeft, height - cellBottom);
setRect(
bottomRight,
cellRight,
cellBottom,
width - cellRight,
height - cellBottom,
);
} else {
overlay.dims.forEach((element) => setRect(element, 0, 0, 0, 0));
}
overlay.container.style.display = 'block';
}

View File

@@ -1,133 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import uPlot from 'uplot';
import { HeatmapColorResolver } from './colorScale';
import { HeatmapYAxis } from './types';
/** Cells at least this wide/tall keep a hairline separator. */
const MIN_CELL_SIZE_FOR_GAP = 4;
const HATCH_TILE_SIZE = 6;
const OVERFLOW_DASH: [number, number] = [4, 3];
/** Hatch for `null` cells: a gap must never share the bottom-of-scale fill, or a
* scrape outage reads as a quiet period. */
export function createHatchPattern(
ctx: CanvasRenderingContext2D,
isDarkMode: boolean,
): CanvasPattern | null {
const pxRatio = uPlot.pxRatio;
const size = Math.max(2, Math.round(HATCH_TILE_SIZE * pxRatio));
const tile = document.createElement('canvas');
tile.width = size;
tile.height = size;
const tileCtx = tile.getContext('2d');
if (!tileCtx) {
return null;
}
tileCtx.strokeStyle = isDarkMode
? `${Color.BG_VANILLA_400}59`
: `${Color.BG_INK_300}40`;
tileCtx.lineWidth = Math.max(1, pxRatio);
tileCtx.beginPath();
// Three strokes keep the pattern continuous across tile seams.
tileCtx.moveTo(0, size);
tileCtx.lineTo(size, 0);
tileCtx.moveTo(-size / 2, size / 2);
tileCtx.lineTo(size / 2, -size / 2);
tileCtx.moveTo(size / 2, size * 1.5);
tileCtx.lineTo(size * 1.5, size / 2);
tileCtx.stroke();
return ctx.createPattern(tile, 'repeat');
}
/** One canvas pass. Offscreen columns are skipped rather than clipped. */
// eslint-disable-next-line sonarjs/cognitive-complexity
export function drawCells({
u,
yAxis,
step,
resolver,
hatchPattern,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
step: number;
resolver: HeatmapColorResolver;
hatchPattern: CanvasPattern | null;
}): void {
const { ctx } = u;
const timestamps = u.data[0] as ArrayLike<number>;
const { rows, edges } = yAxis;
const pxRatio = uPlot.pxRatio;
const xMin = u.scales.x.min ?? timestamps[0];
const xMax = u.scales.x.max ?? timestamps[timestamps.length - 1] + step;
const rowEdgePositions = edges.map((edge) => u.valToPos(edge, 'y', true));
for (let column = 0; column < timestamps.length; column += 1) {
const columnStart = timestamps[column];
const columnEnd = columnStart + step;
if (columnEnd < xMin || columnStart > xMax) {
continue;
}
const left = u.valToPos(columnStart, 'x', true);
const rawWidth = u.valToPos(columnEnd, 'x', true) - left;
const gapX = rawWidth > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const width = Math.max(1, rawWidth - gapX);
for (let row = 0; row < rows.length; row += 1) {
const top = rowEdgePositions[row + 1];
const rawHeight = rowEdgePositions[row] - top;
const gapY = rawHeight > MIN_CELL_SIZE_FOR_GAP * pxRatio ? pxRatio : 0;
const count = (u.data[row + 1] as Array<number | null> | undefined)?.[
column
];
const fill = resolver.colorFor(count ?? null);
if (fill === null && hatchPattern === null) {
continue;
}
ctx.fillStyle = fill ?? (hatchPattern as CanvasPattern);
ctx.fillRect(left, top, width, Math.max(1, rawHeight - gapY));
}
}
}
/** The `+Inf` row is unbounded, so its height is a drawing convenience and
* should not be compared with the real buckets. */
export function drawOverflowBoundary({
u,
yAxis,
isDarkMode,
}: {
u: uPlot;
yAxis: HeatmapYAxis;
isDarkMode: boolean;
}): void {
const overflowIndex = yAxis.rows.length - 1;
if (overflowIndex < 1 || !yAxis.rows[overflowIndex].isOverflow) {
return;
}
// 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));
ctx.save();
ctx.setLineDash(OVERFLOW_DASH);
ctx.lineWidth = Math.max(1, uPlot.pxRatio);
ctx.strokeStyle = isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_300;
ctx.beginPath();
ctx.moveTo(u.bbox.left, y);
ctx.lineTo(u.bbox.left + u.bbox.width, y);
ctx.stroke();
ctx.restore();
}

View File

@@ -1,169 +0,0 @@
import { HeatmapColorPalette } from './types';
export const DEFAULT_HEATMAP_PALETTE = HeatmapColorPalette.Lava;
interface PaletteDefinition {
/** Evenly spaced, one end of the ramp to the other. */
stops: string[];
/** `true` when `stops[0]` is the dark end. */
darkFirst: boolean;
}
/**
* Stop values come from the long-established public palette families —
* ColorBrewer for the hue ramps, matplotlib's perceptual set for the rest.
*/
const PALETTES: Record<HeatmapColorPalette, PaletteDefinition> = {
[HeatmapColorPalette.Ice]: {
darkFirst: false,
stops: [
'#f7fbff',
'#deebf7',
'#c3dbee',
'#9cc8e2',
'#6daed5',
'#4391c6',
'#2271b4',
'#0c5198',
'#08306b',
],
},
[HeatmapColorPalette.Moss]: {
darkFirst: false,
stops: [
'#f7fcf5',
'#e3f4de',
'#c6e8bf',
'#a0d89b',
'#73c378',
'#45aa5d',
'#228b45',
'#066b2d',
'#00441b',
],
},
[HeatmapColorPalette.Rust]: {
darkFirst: false,
stops: [
'#fff5f0',
'#feddcf',
'#fcbaa1',
'#fc9273',
'#f9694c',
'#eb3d2f',
'#cb1c1e',
'#a10e15',
'#67000d',
],
},
[HeatmapColorPalette.Graphite]: {
darkFirst: false,
stops: [
'#ffffff',
'#efefef',
'#d8d8d8',
'#bbbbbb',
'#979797',
'#737373',
'#505050',
'#262626',
'#000000',
],
},
[HeatmapColorPalette.Ember]: {
darkFirst: false,
stops: [
'#ffffcc',
'#ffeda0',
'#fed676',
'#feb250',
'#fd893c',
'#f8502b',
'#e11e20',
'#b90424',
'#800026',
],
},
[HeatmapColorPalette.Lagoon]: {
darkFirst: false,
stops: [
'#ffffd9',
'#eaf7b8',
'#c1e7b5',
'#81cebb',
'#45b4c2',
'#248fbd',
'#2260a9',
'#20378d',
'#081d58',
],
},
[HeatmapColorPalette.Orchid]: {
darkFirst: false,
stops: [
'#fff7f3',
'#fddfdc',
'#fcc3c3',
'#fa9cb4',
'#f369a3',
'#da3495',
'#ad0a81',
'#7b0176',
'#49006a',
],
},
[HeatmapColorPalette.Verdant]: {
darkFirst: true,
stops: [
'#440154',
'#472d7b',
'#3b528b',
'#2c728e',
'#21918c',
'#28ae80',
'#5ec962',
'#addc30',
'#fde725',
],
},
[HeatmapColorPalette.Lava]: {
darkFirst: true,
stops: [
'#000004',
'#1d1147',
'#51127c',
'#832681',
'#b73779',
'#e75263',
'#fc8961',
'#fec488',
'#fcfdbf',
],
},
[HeatmapColorPalette.Beacon]: {
darkFirst: true,
stops: [
'#002051',
'#11366c',
'#3c4d6e',
'#62646f',
'#7f7c75',
'#9a9478',
'#bbaf71',
'#e2cb5c',
'#fdea45',
],
},
};
/** Stops oriented low-count first for the active theme. At the wrong polarity,
* empty cells become the loudest thing on screen. */
export function getPaletteStops(
palette: HeatmapColorPalette,
isDarkMode: boolean,
): string[] {
const definition = PALETTES[palette];
return definition.darkFirst === isDarkMode
? definition.stops
: [...definition.stops].reverse();
}

View File

@@ -1,122 +0,0 @@
export enum HeatmapColorScale {
Log = 'log',
Sqrt = 'sqrt',
Linear = 'linear',
}
export enum HeatmapColorMode {
Palette = 'palette',
Opacity = 'opacity',
}
/** Sequential ramps only: colour means "count", so a midpoint or hue cycle would
* read as a threshold that does not exist. */
export enum HeatmapColorPalette {
Ice = 'ice',
Moss = 'moss',
Rust = 'rust',
Graphite = 'graphite',
Ember = 'ember',
Lagoon = 'lagoon',
Orchid = 'orchid',
Verdant = 'verdant',
Lava = 'lava',
Beacon = 'beacon',
}
export interface HeatmapColorOptions {
mode: HeatmapColorMode;
scale: HeatmapColorScale;
/** `null` derives it, which is always 0 — a count of 0 belongs at the bottom. */
minCount: number | null;
/** `null` derives it from the grid's highest count. */
maxCount: number | null;
palette: HeatmapColorPalette;
/** Colour steps the ramp is quantised into, 2..128. Unrelated to `step`, the
* column width in seconds. */
steps: number;
/** Opacity mode. Empty falls back to the caller's series colour. */
fill: string;
}
/** Row-height distribution of the bucket axis. */
export enum HeatmapAxisScale {
/** Whichever of the three below the boundaries admit: log when they are all
* positive or sit above a zero, symmetric log when they reach below it,
* linear when they are all zero. The choice is a property of the data, so
* this is the default. */
Auto = 'auto',
Linear = 'linear',
/** Plain log10. A boundary at or below zero has no logarithm, so it is pinned
* one bucket below the smallest positive one — see `resolveHeatmapYAxis`. */
Log = 'log',
/** Linear within ±the smallest non-zero boundary, logarithmic beyond,
* mirrored across zero. The scale for boundaries that straddle zero. */
Symlog = 'symlog',
}
export interface HeatmapSeriesPoint {
/** Column start, in seconds. */
timestamp: number;
/** One per bucket row, lowest first. `null` is "no data", never `0`. */
counts: Array<number | null>;
}
export interface HeatmapSeriesLabel {
key: string;
value: string;
}
export interface HeatmapSeries {
/** Group label, as the legend and the tooltip name it. Empty when there is no
* grouping. */
label: string;
points: HeatmapSeriesPoint[];
}
/** Counts pivoted into rows and aligned to one column axis. Internal to the
* chart, which resolves it from `buckets` and `series`. */
export interface HeatmapGrid {
/** Ascending. N boundaries describe N+1 rows, including the `+Inf` overflow. */
bounds: number[];
/** Column starts, in seconds. */
timestamps: number[];
/** Column width in seconds. Cells span `[timestamps[j], timestamps[j] + step)`,
* and the last column has no successor to infer it from. */
step: number;
/** `counts[row][column]`, row 0 lowest. `null` (no data) renders hatched, `0`
* at the bottom of the scale — conflating them hides an outage. */
counts: Array<Array<number | null>>;
}
export interface HeatmapRow {
/** Synthetic on the underflow row. */
lower: number;
/** Synthetic on the overflow row. */
upper: number;
isUnderflow: boolean;
isOverflow: boolean;
}
/** The bucket axis in uPlot y-scale space. A log axis is log10 values on a
* *linear* scale, not uPlot's log distribution, so boundaries stay exactly on
* ticks and uPlot's decade-only label filter cannot hide them. */
export interface HeatmapYAxis {
rows: HeatmapRow[];
/** Row edges, ascending. Length is `rows.length + 1`. */
edges: number[];
/** Real bucket boundaries — one tick each. */
splits: number[];
/** Where the `∞` tick goes: the overflow row's upper edge, not its centre,
* which would sit half a row from the last boundary and collide with it. */
overflowSplit: number | null;
toBucketValue: (axisValue: number) => number;
min: number;
max: number;
}
export interface HeatmapCell {
row: number;
column: number;
count: number | null;
}

View File

@@ -44,9 +44,6 @@ export default function ChartWrapper({
customTooltip,
pinnedTooltipElement,
tooltipPortalRoot,
customLegend,
legendLabels,
contentFooter,
'data-testid': testId,
}: ChartWrapperProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -67,10 +64,6 @@ export default function ChartWrapper({
if (!showLegend) {
return null;
}
// A pie's slices and a heatmap's groups are not uPlot series.
if (customLegend) {
return customLegend(averageLegendWidth);
}
return (
<UPlotLegend
config={config}
@@ -80,7 +73,7 @@ export default function ChartWrapper({
/>
);
},
[config, legendConfig.position, showLegend, customLegend],
[config, legendConfig.position, showLegend],
);
const renderTooltipCallback = useCallback(
@@ -111,8 +104,6 @@ export default function ChartWrapper({
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
seriesLabelsOverride={legendLabels}
contentFooter={contentFooter}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (

View File

@@ -1,334 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ColorBar from 'lib/uPlotV2/components/ColorBar/ColorBar';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import HeatmapTooltip from 'lib/uPlotV2/components/Tooltip/components/HeatmapTooltip/HeatmapTooltip';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
resolveCountDomain,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import {
cropHeatmapYAxis,
resolveHeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { resolveHeatmapGrid } from 'lib/uPlotV2/plugins/HeatmapPlugin/grid';
import {
HeatmapAxisScale,
HeatmapCell,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { HeatmapChartProps } from 'lib/visualization/charts/types';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} from './utils';
/** Vertical space the colour bar takes out of the container. */
const COLOR_BAR_HEIGHT = 28;
/** Row, column and count together: a refetch changes what the cell under a
* stationary cursor means, while the row and column stay put. */
function isSameCell(a: HeatmapCell | null, b: HeatmapCell | null): boolean {
if (a === null || b === null) {
return a === b;
}
return a.row === b.row && a.column === b.column && a.count === b.count;
}
/**
* Columns are time slices, rows are bucket ranges, cell colour is the observation
* count — so a distribution can be watched changing shape instead of collapsing to
* percentile lines. Drawn on canvas (see `createHeatmapHooks`): a 40 × 240 grid is
* ~9,600 cells, far past what per-cell DOM carries.
*/
export default function Heatmap(props: HeatmapChartProps): JSX.Element {
const {
id,
buckets,
step,
series,
width,
height,
isDarkMode,
axisScale = HeatmapAxisScale.Auto,
yAxisUnit,
decimalPrecision,
timezone,
showVisualMap = true,
showLegend = true,
legendPosition = LegendPosition.BOTTOM,
dimOnHover = true,
showTooltip = true,
canPinTooltip = false,
pinKey,
seriesColor,
minTimeScale,
maxTimeScale,
onDragSelect,
onCellClick,
renderTooltipFooter,
tooltipPortalRoot,
layoutChildren,
'data-testid': testId,
} = props;
const [hoveredCell, setHoveredCell] = useState<HeatmapCell | null>(null);
const hoveredCellRef = useRef<HeatmapCell | null>(null);
const hoverFrameRef = useRef<number | null>(null);
const onCellClickRef = useRef(onCellClick);
onCellClickRef.current = onCellClick;
const groups = useMemo(() => resolveGroupOrder(series), [series]);
const colors = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...props.colors }),
[props.colors],
);
// The opacity fill no longer follows a group colour: with several groups enabled
// at once there is no single one to follow.
const resolvedSeriesColor = seriesColor ?? DEFAULT_HEATMAP_COLORS.fill;
// Opacity mode keeps the solid fill; a partially transparent marker is hard to
// read against the panel.
const extremeColor = resolveExtremeColor({
options: colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
});
const {
visibleKeys: visibleGroups,
focusedSeriesIndex,
onLegendAction,
} = useLegendVisibility({ keys: groups, indexOffset: 1, id });
const grid = useMemo(
() => resolveHeatmapGrid({ buckets, step, series, visibleGroups }),
[buckets, step, series, visibleGroups],
);
const yAxis = useMemo(
() =>
cropHeatmapYAxis(resolveHeatmapYAxis(grid.bounds, axisScale), grid.counts),
[grid.bounds, grid.counts, axisScale],
);
// The axis, the series labels and the tooltip all name rows by their boundaries,
// so they share one precision.
const boundaryPrecision = useMemo(
() => resolveBoundaryPrecision({ yAxis, yAxisUnit, decimalPrecision }),
[yAxis, yAxisUnit, decimalPrecision],
);
const hasGrid = yAxis.rows.length > 0 && grid.timestamps.length > 0;
const data = useMemo(
() =>
hasGrid
? prepareHeatmapChartData(grid, yAxis.rows.length)
: ([[]] as unknown as ReturnType<typeof prepareHeatmapChartData>),
[grid, yAxis.rows.length, hasGrid],
);
const colorResolver = useMemo(
() =>
createHeatmapColorResolver({
options: colors,
domain: resolveCountDomain(colors, grid.counts),
isDarkMode,
seriesColor: resolvedSeriesColor,
}),
[colors, grid.counts, isDarkMode, resolvedSeriesColor],
);
// Stable: the renderer captures it at config-build time, so a new identity would
// recreate the plot on every hover.
//
// The plot reports the cell from inside its own render path, which React can be
// driving — a resize or a data swap runs the plot's hooks during a commit, and a
// rebuilt plot re-reports the cell the cursor is still sitting on. Committing to
// state there nests an update inside the commit that caused it, so the two feed
// each other until React gives up at its depth limit. The frame takes the update
// out of that chain; the equality check drops a report that carries nothing new.
const handleHoverChange = useCallback((cell: HeatmapCell | null): void => {
hoveredCellRef.current = cell;
if (hoverFrameRef.current !== null) {
return;
}
hoverFrameRef.current = requestAnimationFrame(() => {
hoverFrameRef.current = null;
setHoveredCell((previous) =>
isSameCell(previous, hoveredCellRef.current)
? previous
: hoveredCellRef.current,
);
});
}, []);
useEffect(
() => (): void => {
if (hoverFrameRef.current !== null) {
cancelAnimationFrame(hoverFrameRef.current);
}
},
[],
);
const config = useMemo(
() =>
buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor: resolvedSeriesColor,
dimOnHover,
onHoverChange: handleHoverChange,
yAxisUnit,
decimalPrecision: boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
id,
grid,
yAxis,
colors,
isDarkMode,
resolvedSeriesColor,
dimOnHover,
handleHoverChange,
yAxisUnit,
boundaryPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
],
);
const legendItems = useMemo<LegendItem[]>(
() =>
groups.map((group, index) => ({
// +1 mirrors uPlot's 1-based data series, so the shared legend's index
// handling is identical across charts.
seriesIndex: index + 1,
label: group,
// Colour means count here, so a marker names its group rather than keying
// a colour — every one of them takes the top of the ramp.
color: extremeColor,
show: visibleGroups.includes(group),
})),
[groups, visibleGroups, extremeColor],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => (
<HeatmapTooltip
{...args}
id={id}
yAxis={yAxis}
step={grid.step}
series={series}
visibleGroups={visibleGroups}
groupColor={extremeColor}
yAxisUnit={yAxisUnit}
decimalPrecision={boundaryPrecision}
timezone={timezone}
canPinTooltip={canPinTooltip}
renderTooltipFooter={renderTooltipFooter}
/>
),
[
id,
yAxis,
grid.step,
series,
visibleGroups,
extremeColor,
yAxisUnit,
boundaryPrecision,
timezone,
canPinTooltip,
renderTooltipFooter,
],
);
const handleClick = useCallback((clickData: ChartClickData): void => {
if (hoveredCellRef.current) {
onCellClickRef.current?.(hoveredCellRef.current, clickData);
}
}, []);
const groupLegend = useCallback(
(averageLegendWidth: number): React.ReactNode => (
<Legend
items={legendItems}
position={legendPosition}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onAction={onLegendAction}
/>
),
[legendItems, legendPosition, focusedSeriesIndex, onLegendAction],
);
const visualMap = useMemo(() => {
if (!showVisualMap || !hasGrid) {
return null;
}
return (
<ColorBar
label="count"
ramp={colorResolver.ramp}
minLabel={colorResolver.domain.min.toLocaleString()}
maxLabel={colorResolver.domain.max.toLocaleString()}
markerPosition={colorResolver.positionOf(hoveredCell?.count ?? null)}
showNoDataKey={hasMissingCells(grid.counts)}
/>
);
}, [showVisualMap, hasGrid, colorResolver, hoveredCell, grid.counts]);
return (
<ChartWrapper
config={config}
data={data}
width={width}
height={
showVisualMap && hasGrid ? Math.max(0, height - COLOR_BAR_HEIGHT) : height
}
legendConfig={{ position: legendPosition }}
showLegend={showLegend}
customLegend={groupLegend}
legendLabels={groups}
showTooltip={showTooltip}
canPinTooltip={canPinTooltip}
pinKey={pinKey}
onClick={onCellClick ? handleClick : undefined}
yAxisUnit={yAxisUnit}
decimalPrecision={decimalPrecision}
customTooltip={renderTooltip}
renderTooltipFooter={renderTooltipFooter}
tooltipPortalRoot={tooltipPortalRoot}
contentFooter={visualMap}
layoutChildren={layoutChildren}
data-testid={testId}
/>
);
}

View File

@@ -1,234 +0,0 @@
import type React from 'react';
import userEvent from '@testing-library/user-event';
import type { LegendItem } from 'lib/uPlotV2/config/types';
import { render, screen } from 'tests/test-utils';
import {
DEFAULT_HEATMAP_COLORS,
resolveExtremeColor,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapColorMode,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import Heatmap from '../Heatmap';
// The shared Legend virtualises its items; render them all so they are queryable.
jest.mock('react-virtuoso', () => ({
VirtuosoGrid: ({
data,
itemContent,
}: {
data: LegendItem[];
itemContent: (index: number, item: LegendItem) => React.ReactNode;
}): JSX.Element => (
<div>
{data.map((item, index) => (
<div key={item.seriesIndex}>{itemContent(index, item)}</div>
))}
</div>
),
}));
const BUCKETS = [128, 256, 1024];
const STEP = 60;
/** Two groups whose counts sum to a peak of 1,204 in the combined view. */
const SERIES: HeatmapSeries[] = [
{
label: 'service.name=cart',
points: [
{ timestamp: 1000, counts: [1, 4, 7, 10] },
{ timestamp: 1060, counts: [2, null, 8, 11] },
{ timestamp: 1120, counts: [3, 6, 9, 1200] },
],
},
{
label: 'service.name=checkout',
points: [{ timestamp: 1120, counts: [0, 0, 0, 4] }],
},
];
function renderHeatmap(
props: Partial<React.ComponentProps<typeof Heatmap>> = {},
): ReturnType<typeof render> {
return render(
<Heatmap
id="panel-1"
buckets={BUCKETS}
step={STEP}
series={SERIES}
width={800}
height={400}
isDarkMode
data-testid="heatmap"
{...props}
/>,
);
}
describe('Heatmap', () => {
it('renders the plot container', () => {
renderHeatmap();
expect(screen.getByTestId('heatmap')).toBeInTheDocument();
});
it('shows the colour bar with the resolved count domain', () => {
renderHeatmap();
expect(screen.getByTestId('color-bar')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('1,204')).toBeInTheDocument();
});
it('puts the colour bar against the plot, with the legend after it', () => {
renderHeatmap();
const bar = screen.getByTestId('color-bar');
const legend = screen
.getByText('service.name=cart')
.closest('[data-legend-item-id]');
// The bar is the scale key for the grid, so it reads before the controls.
expect(
bar.compareDocumentPosition(legend as Node) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps the colour bar inside the chart column, not below the legend', () => {
renderHeatmap();
expect(
screen.getByTestId('color-bar').closest('.chart-layout__content'),
).not.toBeNull();
});
it('hides the colour bar when the visual map is off', () => {
renderHeatmap({ showVisualMap: false });
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('labels the colour bar with an explicit clamp instead of the data range', () => {
renderHeatmap({ colors: { minCount: 5, maxCount: 500 } });
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('500')).toBeInTheDocument();
});
it('falls back to the no-data state when the metric has no buckets', () => {
renderHeatmap({ buckets: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
expect(screen.queryByTestId('color-bar')).not.toBeInTheDocument();
});
it('falls back to the no-data state when no columns came back', () => {
renderHeatmap({ series: [] });
expect(screen.getByText('No Data')).toBeInTheDocument();
});
});
describe('Heatmap group legend', () => {
const CART = 'service.name=cart';
const CHECKOUT = 'service.name=checkout';
// The selection persists under the panel id, which every case here shares.
beforeEach(() => localStorage.clear());
function legendItem(label: string): HTMLElement {
return screen.getByRole('switch', { name: label });
}
function marker(label: string): HTMLElement {
const element = legendItem(label).querySelector<HTMLElement>(
'[data-is-legend-marker]',
);
if (!element) {
throw new Error(`no marker for ${label}`);
}
return element;
}
it('lists the groups, with no combined-view entry', () => {
renderHeatmap();
expect(screen.getByText(CART)).toBeInTheDocument();
expect(screen.getByText(CHECKOUT)).toBeInTheDocument();
expect(screen.queryByText(/all groups/i)).not.toBeInTheDocument();
});
it('enables every group to begin with', () => {
renderHeatmap();
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
});
it('isolates a group when its label is clicked', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
});
it('restores every group when the isolated label is clicked again', async () => {
renderHeatmap();
await userEvent.click(screen.getByText(CART));
await userEvent.click(screen.getByText(CART));
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'true');
});
it('excludes just one group when its marker is clicked', async () => {
renderHeatmap();
await userEvent.click(marker(CHECKOUT));
expect(legendItem(CHECKOUT)).toHaveAttribute('aria-checked', 'false');
expect(legendItem(CART)).toHaveAttribute('aria-checked', 'true');
});
it('gives every marker the top of the palette, whatever the group"s counts', () => {
renderHeatmap();
// The DOM lowercases hex; the palette is built uppercase.
const extreme = resolveExtremeColor({
options: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: DEFAULT_HEATMAP_COLORS.fill,
}).toLowerCase();
// cart peaks at 1200 and checkout at 4, which the marker does not report.
expect(marker(CART).style.borderColor.toLowerCase()).toBe(extreme);
expect(marker(CHECKOUT).style.borderColor.toLowerCase()).toBe(extreme);
});
it('gives every marker the solid fill in opacity mode', () => {
renderHeatmap({
colors: { mode: HeatmapColorMode.Opacity, fill: '#e5484d' },
});
// A partially transparent marker is hard to read against the panel.
expect(marker(CART).style.borderColor).toBe(
marker(CHECKOUT).style.borderColor,
);
expect(marker(CART).style.borderColor).not.toBe('');
});
it('shows the legend for a single group, which names what the grid plots', () => {
renderHeatmap({ series: [SERIES[0]] });
expect(screen.getByText(CART)).toBeInTheDocument();
});
it('hides the legend when asked', () => {
renderHeatmap({ showLegend: false });
expect(screen.queryByText(CART)).not.toBeInTheDocument();
});
});

View File

@@ -1,333 +0,0 @@
import { resolveHeatmapYAxis } from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import { DEFAULT_HEATMAP_COLORS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import {
HeatmapAxisScale,
HeatmapGrid,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type uPlot from 'uplot';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import {
buildHeatmapConfig,
hasMissingCells,
prepareHeatmapChartData,
resolveBoundaryPrecision,
resolveGroupOrder,
} from '../utils';
const GRID: HeatmapGrid = {
bounds: [128, 256, 1024],
timestamps: [1000, 1060, 1120],
step: 60,
counts: [
[1, 2, 3],
[4, null, 6],
[7, 8, 9],
[0, 0, 0],
],
};
const Y_AXIS = resolveHeatmapYAxis(GRID.bounds, HeatmapAxisScale.Log);
/** Tall enough that no tick needs thinning. */
const TALL_PLOT = { bbox: { height: 1000 } } as uPlot;
function readSplits(
config: ReturnType<typeof buildHeatmapConfig>,
plot: uPlot,
): number[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.splits as (self: uPlot) => number[])(plot);
}
function readLabels(
config: ReturnType<typeof buildHeatmapConfig>,
splits: number[],
): string[] {
const [, yAxisConfig] = config.getConfig().axes ?? [];
return (yAxisConfig.values as (u: uPlot, splits: number[]) => string[])(
{} as uPlot,
splits,
);
}
function readRange(scale?: uPlot.Scale): [number, number] {
const range = scale?.range as (
u: uPlot,
min: number,
max: number,
) => [number, number];
return range({} as uPlot, 0, 0);
}
function buildConfig(
overrides: Partial<Parameters<typeof buildHeatmapConfig>[0]> = {},
): ReturnType<typeof buildHeatmapConfig> {
return buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
...overrides,
});
}
/** Two decimals round every one of these to `0.06 ms` or `0.07 ms`. */
const CLOSE_BOUNDS = [
0.05731275270029195, 0.059850205043660856, 0.0625, 0.06526711140171336,
0.0681567332915786,
];
describe('resolveBoundaryPrecision', () => {
it('raises the precision until every boundary prints differently', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(3);
});
it('leaves the panel"s precision alone where the boundaries already separate', () => {
expect(
resolveBoundaryPrecision({
yAxis: Y_AXIS,
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(2);
});
it('never drops below the panel"s precision', () => {
expect(
resolveBoundaryPrecision({
yAxis: Y_AXIS,
yAxisUnit: 'ms',
decimalPrecision: 4,
}),
).toBe(4);
});
it('stops at the most precise the panel can express', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(
[0.062501, 0.062502, 0.062503],
HeatmapAxisScale.Log,
),
yAxisUnit: 'ms',
decimalPrecision: 2,
}),
).toBe(4);
});
it('leaves full precision as it is, having nothing above it to reach for', () => {
expect(
resolveBoundaryPrecision({
yAxis: resolveHeatmapYAxis(CLOSE_BOUNDS, HeatmapAxisScale.Log),
yAxisUnit: 'ms',
decimalPrecision: PrecisionOptionsEnum.FULL,
}),
).toBe(PrecisionOptionsEnum.FULL);
});
});
describe('prepareHeatmapChartData', () => {
it('puts timestamps first and one series per bucket row', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data).toHaveLength(Y_AXIS.rows.length + 1);
expect(data[0]).toStrictEqual(GRID.timestamps);
expect(data[1]).toStrictEqual([1, 2, 3]);
});
it('preserves null cells rather than zeroing them', () => {
const data = prepareHeatmapChartData(GRID, Y_AXIS.rows.length);
expect(data[2]).toStrictEqual([4, null, 6]);
});
it('pads short rows so every uPlot data array is the same length', () => {
const data = prepareHeatmapChartData(
{ ...GRID, counts: [[1]] },
Y_AXIS.rows.length,
);
expect(data[1]).toStrictEqual([1, null, null]);
});
it('pads missing rows up to the resolved row count', () => {
const data = prepareHeatmapChartData({ ...GRID, counts: [] }, 2);
expect(data).toHaveLength(3);
expect(data[2]).toStrictEqual([null, null, null]);
});
});
describe('buildHeatmapConfig', () => {
it('registers one series per bucket row, plus uPlot"s timestamp series', () => {
const config = buildHeatmapConfig({
id: 'panel-1',
grid: GRID,
yAxis: Y_AXIS,
colors: DEFAULT_HEATMAP_COLORS,
isDarkMode: true,
seriesColor: '#4e74f8',
}).getConfig();
expect(config.series).toHaveLength(Y_AXIS.rows.length + 1);
});
it('draws no paths or points per series — the renderer paints the cells', () => {
const [, firstRow] = buildConfig().getConfig().series ?? [];
expect((firstRow as uPlot.Series).paths?.({} as uPlot, 1, 0, 1)).toBeNull();
expect((firstRow as uPlot.Series).points?.show).toBe(false);
});
it('labels series by bucket range, including the open-ended rows', () => {
const labels = (buildConfig().getConfig().series ?? [])
.slice(1)
.map((series) => series.label);
expect(labels[0]).toContain('≤');
expect(labels[labels.length - 1]).toContain('>');
});
it('spans the x scale to the end of the last column, not its start', () => {
const { x } = buildConfig().getConfig().scales ?? {};
expect(readRange(x)).toStrictEqual([1000, 1180]);
});
it('prefers the query window over the grid extent', () => {
const { x } =
buildConfig({ minTimeScale: 900, maxTimeScale: 1500 }).getConfig().scales ??
{};
expect(readRange(x)).toStrictEqual([900, 1500]);
});
it('pins the y scale to the bucket axis instead of auto-ranging on counts', () => {
const { y } = buildConfig().getConfig().scales ?? {};
expect(y?.auto).toBe(false);
expect(readRange(y)).toStrictEqual([Y_AXIS.min, Y_AXIS.max]);
});
it('puts a y tick on every bucket boundary plus the overflow row"s upper edge', () => {
const splits = readSplits(buildConfig(), TALL_PLOT);
expect(splits).toStrictEqual([...Y_AXIS.splits, Y_AXIS.overflowSplit]);
});
it('labels the overflow edge as infinite and the rest by bucket value', () => {
const config = buildConfig();
const labels = readLabels(config, readSplits(config, TALL_PLOT));
expect(labels[0]).toBe('128');
expect(labels[labels.length - 1]).toBe('∞');
});
it('thins the tick set when the panel is too short to label every boundary', () => {
const config = buildConfig();
const splits = readSplits(config, { bbox: { height: 40 } } as uPlot);
expect(splits.length).toBeLessThan(Y_AXIS.splits.length + 1);
// The infinite edge is the one label that must never be dropped.
expect(readLabels(config, splits).at(-1)).toBe('∞');
});
it('disables uPlot cursor focus and points, which cannot read a colour axis', () => {
const config = buildConfig().getConfig();
expect(config.cursor?.focus?.prox).toBe(-1);
expect(config.cursor?.points?.show).toBe(false);
});
it('keeps focus alpha at 1 so focusing a row does not force a full redraw', () => {
expect(buildConfig().getConfig().focus?.alpha).toBe(1);
});
it('registers the renderer hooks', () => {
const { hooks } = buildConfig().getConfig();
expect(hooks?.init).toHaveLength(1);
expect(hooks?.draw).toHaveLength(1);
expect(hooks?.setCursor).toHaveLength(1);
expect(hooks?.destroy).toHaveLength(1);
});
});
describe('resolveGroupOrder', () => {
const group = (
label: string,
counts: Array<number | null>,
): HeatmapSeries => ({
label,
points: [{ timestamp: 1000, counts }],
});
it('puts the biggest contributor first', () => {
expect(
resolveGroupOrder([
group('small', [1, 1]),
group('big', [40, 2]),
group('middling', [5, 5]),
]),
).toStrictEqual(['big', 'middling', 'small']);
});
it('sums every column, not just the first', () => {
expect(
resolveGroupOrder([
{
label: 'steady',
points: [
{ timestamp: 1000, counts: [5] },
{ timestamp: 1060, counts: [5] },
],
},
{ label: 'spike', points: [{ timestamp: 1000, counts: [9] }] },
]),
).toStrictEqual(['steady', 'spike']);
});
it('breaks a tie alphabetically, so the order is stable across refetches', () => {
expect(resolveGroupOrder([group('b', [1]), group('a', [1])])).toStrictEqual([
'a',
'b',
]);
});
it('counts a no-data cell as no contribution', () => {
expect(
resolveGroupOrder([group('gaps', [null, null]), group('counts', [1])]),
).toStrictEqual(['counts', 'gaps']);
});
});
describe('hasMissingCells', () => {
it('finds a gap anywhere in the grid', () => {
expect(
hasMissingCells([
[1, 2],
[3, null],
]),
).toBe(true);
});
it('does not mistake a zero for a gap', () => {
expect(
hasMissingCells([
[0, 0],
[0, 1],
]),
).toBe(false);
});
});

View File

@@ -1,281 +0,0 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import {
decimateAxisSplits,
formatRowLabel,
resolveColumnAlignedSplits,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/geometry';
import {
createHeatmapHooks,
HeatmapRenderOptions,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/heatmapPlugin';
import {
HeatmapGrid,
HeatmapSeries,
HeatmapYAxis,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import uPlot from 'uplot';
/** Minimum gap between y tick labels, in CSS pixels. */
const MIN_Y_TICK_GAP_PX = 18;
/** Label for the edge above the overflow row. */
const OVERFLOW_AXIS_LABEL = '∞';
const BOUNDARY_PRECISIONS: Array<
Exclude<PrecisionOption, PrecisionOptionsEnum.FULL>
> = [0, 1, 2, 3, 4];
/** What `getYAxisFormattedValue` falls back to, so an unset precision starts
* where the panel renders. */
const DEFAULT_BOUNDARY_PRECISION = 2;
/**
* Lowest precision, at or above the panel's, that prints every boundary
* distinctly: `0.0625` and `0.0653` are both `0.06` at two decimals, and the rows
* either side then claim the same range. Settles for the most precise candidate
* when none separates every pair.
*/
export function resolveBoundaryPrecision({
yAxis,
yAxisUnit,
decimalPrecision,
}: {
yAxis: HeatmapYAxis;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}): PrecisionOption | undefined {
if (decimalPrecision === PrecisionOptionsEnum.FULL) {
return decimalPrecision;
}
// The overflow row's upper edge is the synthetic top of the grid, labelled `∞`.
const boundaries = yAxis.rows
.filter((row) => !row.isOverflow)
.map((row) => row.upper);
const floor = decimalPrecision ?? DEFAULT_BOUNDARY_PRECISION;
const candidates = BOUNDARY_PRECISIONS.filter(
(candidate) => candidate >= floor,
);
const separates = (candidate: PrecisionOption): boolean => {
const labels = boundaries.map((boundary) =>
getToolTipValue(String(boundary), yAxisUnit, candidate),
);
return labels.every(
(label, index) => index === 0 || label !== labels[index - 1],
);
};
return candidates.find(separates) ?? candidates[candidates.length - 1];
}
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.
*
* Rows are padded to `rowCount` — which can differ from `bounds.length + 1` when
* the response carried duplicate boundaries — since uPlot requires equal lengths.
*/
export function prepareHeatmapChartData(
grid: HeatmapGrid,
rowCount: number,
): uPlot.AlignedData {
const columnCount = grid.timestamps.length;
const rows = Array.from({ length: rowCount }, (_, row) => {
const counts = grid.counts[row] ?? [];
return Array.from({ length: columnCount }, (_, column) =>
counts[column] === undefined ? null : counts[column],
);
});
return [grid.timestamps, ...rows] as unknown as uPlot.AlignedData;
}
export interface BuildHeatmapConfigArgs extends Omit<
HeatmapRenderOptions,
'step'
> {
id: string;
grid: HeatmapGrid;
/** Unit of the bucket boundaries; counts are never formatted with it. */
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
timezone?: Timezone;
/** Query window, in seconds. Falls back to the grid's own extent. */
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect?: (startTime: number, endTime: number) => void;
}
export function buildHeatmapConfig({
id,
grid,
yAxis,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
yAxisUnit,
decimalPrecision,
timezone,
minTimeScale,
maxTimeScale,
onDragSelect,
}: BuildHeatmapConfigArgs): UPlotConfigBuilder {
const tzDate = timezone
? (timestamp: number): Date =>
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value)
: undefined;
const builder = new UPlotConfigBuilder({ id, onDragSelect, tzDate });
// uPlot's focus picks the series closest in value space, meaningless when the
// value is a colour; the renderer focuses the hovered row itself. alpha 1 keeps
// that call off uPlot's full-redraw path.
builder.setFocus({ alpha: 1 });
builder.setCursor({ focus: { prox: -1 }, points: { show: false } });
const formatBucketValue = (value: number): string =>
getToolTipValue(String(value), yAxisUnit, decimalPrecision);
const lastTimestamp = grid.timestamps[grid.timestamps.length - 1] ?? 0;
const xRange: [number, number] = [
minTimeScale ?? grid.timestamps[0] ?? 0,
maxTimeScale ?? lastTimestamp + grid.step,
];
builder.addScale({
scaleKey: 'x',
time: true,
range: (): [number, number] => xRange,
});
builder.addScale({
scaleKey: 'y',
time: false,
auto: false,
range: (): [number, number] => [yAxis.min, yAxis.max],
});
builder.addAxis({
scaleKey: 'x',
side: 2,
isDarkMode,
values: uPlotXAxisValuesFormat as uPlot.Axis.Values,
// Grid lines are drawn on the ticks, so the ticks go on the cell edges.
splits: (_self, _axisIdx, scaleMin, scaleMax, foundIncr): number[] =>
resolveColumnAlignedSplits({
anchor: grid.timestamps[0] ?? 0,
step: grid.step,
incr: foundIncr,
min: scaleMin,
max: scaleMax,
toDate: tzDate,
}),
});
// Ticks sit on row edges, so the overflow row is the band between the last
// boundary and `∞`. A centre label would sit half a row from the boundary tick
// and collide with it.
const overflowRow = yAxis.rows[yAxis.rows.length - 1];
const hasOverflowTick =
yAxis.overflowSplit !== null && overflowRow?.isOverflow === true;
const axisSplits = hasOverflowTick
? [...yAxis.splits, yAxis.overflowSplit as number]
: yAxis.splits;
// From the boundaries themselves, not by inverting the transform:
// 10 ** Math.log10(128) is 127.999…, which formats as "127.99".
const splitLabels = new Map<number, string>();
yAxis.splits.forEach((split, index) => {
splitLabels.set(split, formatBucketValue(yAxis.rows[index].upper));
});
if (hasOverflowTick) {
splitLabels.set(yAxis.overflowSplit as number, OVERFLOW_AXIS_LABEL);
}
builder.addAxis({
scaleKey: 'y',
side: 3,
isDarkMode,
yAxisUnit,
decimalPrecision,
// Thinned to whatever fits: a histogram can carry more boundaries than the
// panel has room to label.
splits: (self): number[] =>
decimateAxisSplits({
splits: axisSplits,
min: yAxis.min,
max: yAxis.max,
plotHeight: self.bbox.height / uPlot.pxRatio,
minGapPx: MIN_Y_TICK_GAP_PX,
}),
values: (_, splits): string[] =>
splits.map(
(split) =>
splitLabels.get(split) ?? formatBucketValue(yAxis.toBucketValue(split)),
),
});
yAxis.rows.forEach((row) => {
builder.addSeries({
scaleKey: 'y',
// Nothing is stroked per series; the draw hook paints the grid.
drawStyle: DrawStyle.Line,
pathBuilder: (): null => null,
showPoints: false,
spanGaps: false,
label: formatRowLabel(row, formatBucketValue),
colorMapping: {},
isDarkMode,
});
});
const hooks = createHeatmapHooks({
yAxis,
step: grid.step,
colors,
isDarkMode,
seriesColor,
dimOnHover,
onHoverChange,
});
// Order matters — see the HeatmapHooks doc comment.
builder.addHook('init', hooks.init);
builder.addHook('draw', hooks.draw);
builder.addHook('setCursor', hooks.setCursor);
builder.addHook('destroy', hooks.destroy);
return builder;
}

View File

@@ -8,12 +8,11 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { PieChartProps, PieSlice } from 'lib/visualization/charts/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
import PieArc from 'lib/visualization/charts/Pie/PieArc';
import PieCenterLabel from 'lib/visualization/charts/Pie/PieCenterLabel';
import styles from 'lib/visualization/charts/Pie/Pie.module.scss';
@@ -27,8 +26,8 @@ import {
* Donut chart rendered with @visx. Splits its area into chart + legend with the
* same `calculateChartDimensions` logic as the uPlot charts (right column /
* up-to-two bottom rows), renders the shared chart Legend, and delegates the
* arcs and centre total to PieArc / PieCenterLabel. Pure presentation — slices
* are pre-resolved by the caller.
* arcs, centre total and interaction state to PieArc / PieCenterLabel /
* usePieInteractions. Pure presentation — slices are pre-resolved by the caller.
*/
export default function Pie({
data,
@@ -40,27 +39,14 @@ export default function Pie({
onSliceClick,
'data-testid': testId,
}: PieChartProps): JSX.Element {
const labels = useMemo(() => data.map((slice) => slice.label), [data]);
const { hiddenKeys, focusedSeriesIndex, setFocusedKey, onLegendAction } =
useLegendVisibility({ keys: labels, id });
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenKeys.has(slice.label),
})),
[data, hiddenKeys],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((slice) => !hiddenKeys.has(slice.label)),
[data, hiddenKeys],
);
const {
active,
setActive,
visibleData,
legendItems,
focusedSeriesIndex,
onLegendAction,
} = usePieInteractions(data, id);
const {
tooltipOpen,
@@ -95,9 +81,9 @@ export default function Pie({
containerWidth,
containerHeight,
legendConfig: { position },
seriesLabels: labels,
seriesLabels: data.map((slice) => slice.label),
}),
[containerWidth, containerHeight, position, labels],
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box, sized to leave room
@@ -113,8 +99,7 @@ export default function Pie({
);
const labelColor = isDarkMode ? Color.BG_VANILLA_100 : Color.BG_INK_400;
const activeColor =
focusedSeriesIndex !== null ? data[focusedSeriesIndex].color : null;
const activeColor = active?.color ?? null;
const handleSliceEnter = useCallback(
(slice: PieSlice, centroidX: number, centroidY: number): void => {
@@ -131,15 +116,15 @@ export default function Pie({
tooltipTop: centroidY + height / 2,
tooltipLeft: centroidX + width / 2,
});
setFocusedKey(slice.label);
setActive(slice);
},
[showTooltip, setFocusedKey, yAxisUnit, decimalPrecision, height, width],
[showTooltip, setActive, yAxisUnit, decimalPrecision, height, width],
);
const handleSliceLeave = useCallback((): void => {
hideTooltip();
setFocusedKey(null);
}, [hideTooltip, setFocusedKey]);
setActive(null);
}, [hideTooltip, setActive]);
if (!data.length) {
return (

View File

@@ -111,18 +111,6 @@ describe('Pie', () => {
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('recomputes the centre total from the slices left showing', () => {
renderPie();
// The arcs carry leader labels of their own, so read the centre text.
const centreTotal = (): string | null =>
screen.getByTestId('pie').querySelector('text > tspan')?.textContent ?? null;
expect(centreTotal()).toBe('200');
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(centreTotal()).toBe('100');
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;

View File

@@ -9,12 +9,6 @@ import {
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type {
HeatmapAxisScale,
HeatmapCell,
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -39,13 +33,6 @@ interface BaseChartProps {
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
/** Replaces the config-driven legend, for charts whose legend lists something
* other than uPlot series — heatmap groups, where the series are bucket rows. */
customLegend?: (averageLegendWidth: number) => React.ReactNode;
/** Measured against for the chart/legend split. Pair with `customLegend`. */
legendLabels?: string[];
/** Rendered under the plot but above the legend, inside the chart column. */
contentFooter?: React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {
@@ -87,62 +74,6 @@ export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
/**
* Data arrives as the query response carries it — bucket bounds plus one series per
* group — and the chart pivots and sums it, so no caller has to get the transpose
* or the combined view right. It builds its own `UPlotConfigBuilder` too, since the
* y axis *is* the bucket axis and `buckets` fully determines it.
*
* `buckets`, `series` and `colors` must be referentially stable: a new identity
* rebuilds the config, which recreates the plot.
*/
export interface HeatmapChartProps {
/** Widget id; the group selection persists under it. */
id: string;
/** Ascending. N boundaries describe N+1 rows. */
buckets: number[];
/** The *effective* step the server used (`meta.stepIntervals[queryName]`), not
* the requested one. Cannot be inferred: the last column has no successor. */
step: number;
/** One entry per group; a query without grouping yields one series. */
series: HeatmapSeries[];
width: number;
height: number;
isDarkMode: boolean;
/** Overrides on top of `DEFAULT_HEATMAP_COLORS`. */
colors?: Partial<HeatmapColorOptions>;
/** Default auto: the boundaries pick between log, symmetric log and
* linear. */
axisScale?: HeatmapAxisScale;
/** Unit of the bucket boundaries; counts are always plain numbers. */
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
timezone?: Timezone;
/** Colour bar below the grid. Default true. */
showVisualMap?: boolean;
/** Default true; hidden anyway when there is only one group. The label isolates
* one, the marker excludes one. */
showLegend?: boolean;
legendPosition?: LegendPosition;
/** Default true. */
dimOnHover?: boolean;
showTooltip?: boolean;
canPinTooltip?: boolean;
pinKey?: string;
/** Overrides the opacity-mode fill, which otherwise follows the selected
* group's legend colour so the grid matches the swatch that was clicked. */
seriesColor?: string;
/** Query window, in seconds. Falls back to the data's own extent. */
minTimeScale?: number;
maxTimeScale?: number;
onDragSelect?: (startTime: number, endTime: number) => void;
onCellClick?: (cell: HeatmapCell, clickData: ChartClickData) => void;
renderTooltipFooter?: (args: IRenderTooltipFooterArgs) => React.ReactNode;
tooltipPortalRoot?: HTMLElement | null;
layoutChildren?: React.ReactNode;
'data-testid'?: string;
}
/**
* One resolved pie/donut slice: a display label, its (already parsed) positive
* numeric value, and the colour used for the arc + legend swatch.

View File

@@ -1,294 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils');
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const KEYS = ['cart', 'checkout', 'payments'];
type Options = Parameters<typeof useLegendVisibility>[0];
function render(
options: Partial<Options> = {},
): ReturnType<
typeof renderHook<ReturnType<typeof useLegendVisibility>, unknown>
> {
return renderHook(() =>
useLegendVisibility({ keys: KEYS, indexOffset: 1, ...options }),
);
}
describe('useLegendVisibility', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('enables every entry to begin with', () => {
const { result } = render();
expect(result.current.visibleKeys).toStrictEqual(KEYS);
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('isolates an entry when the legend asks to show only it', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout']);
});
it('restores every entry when the legend asks to show all', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('moves the isolation to the entry named last', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 3,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['payments']);
});
it('excludes just one entry when it is toggled off', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['cart', 'payments']);
});
it('re-includes an entry when it is toggled again', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('excludes more than one entry', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout']);
});
it('keeps the last entry showing, as the uPlot legends do', () => {
const { result } = render();
KEYS.forEach((_, index) =>
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: index + 1,
}),
),
);
expect(result.current.visibleKeys).toStrictEqual(['payments']);
});
it('ignores an action naming an entry that is not there', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 99,
}),
);
expect(result.current.visibleKeys).toStrictEqual(KEYS);
});
it('addresses entries from zero when there is no offset', () => {
const { result } = render({ indexOffset: 0 });
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
expect(result.current.visibleKeys).toStrictEqual(['checkout', 'payments']);
});
it('forgets a hidden entry that left the result', () => {
const { result, rerender } = renderHook(
({ keys }) => useLegendVisibility({ keys, indexOffset: 1 }),
{ initialProps: { keys: KEYS } },
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 3,
}),
);
rerender({ keys: ['cart', 'checkout'] });
expect(result.current.visibleKeys).toStrictEqual(['cart', 'checkout']);
});
it('tracks the hovered entry for the legend"s focus highlight', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused entry is hidden', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: 2,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(result.current.focusedSeriesIndex).toBeNull();
});
describe('persistence', () => {
it('writes the selection under the widget id', () => {
const { result } = render({ id: 'panel-1' });
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'cart', show: true },
{ label: 'checkout', show: false },
{ label: 'payments', show: true },
]);
});
it('does not write without an id', () => {
const { result } = render();
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 2,
}),
);
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates the selection from the store, matched by label', () => {
mockGetStored.mockReturnValue([
{ label: 'cart', show: true },
{ label: 'checkout', show: false },
{ label: 'payments', show: true },
]);
const { result } = render({ id: 'panel-1' });
expect(result.current.visibleKeys).toStrictEqual(['cart', 'payments']);
});
it('leaves the stored selection alone when it already matches', () => {
mockGetStored.mockReturnValue([{ label: 'cart', show: false }]);
const { result, rerender } = render({ id: 'panel-1' });
const first = result.current.hiddenKeys;
rerender();
expect(result.current.hiddenKeys).toBe(first);
});
});
});

View File

@@ -0,0 +1,235 @@
import { act, renderHook } from '@testing-library/react';
import { LegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils');
const mockGetStored = getStoredSeriesVisibility as jest.MockedFunction<
typeof getStoredSeriesVisibility
>;
const mockUpdateStored =
updateSeriesVisibilityToLocalStorage as jest.MockedFunction<
typeof updateSeriesVisibilityToLocalStorage
>;
const DATA: PieSlice[] = [
{ label: 'frontend', value: 100, color: '#a' },
{ label: 'cart', value: 60, color: '#b' },
{ label: 'checkout', value: 40, color: '#c' },
];
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
mockUpdateStored.mockReset();
});
it('starts with everything visible and nothing focused', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
true,
true,
]);
expect(result.current.focusedSeriesIndex).toBeNull();
expect(result.current.active).toBeNull();
});
describe('row toggle', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
expect(mockUpdateStored).toHaveBeenLastCalledWith('panel-1', [
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('the last slice showing', () => {
it('cannot be hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
true,
false,
false,
]);
});
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 2,
}),
);
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.SHOW_ONLY,
seriesIndex: 0,
}),
);
act(() => result.current.onLegendAction({ type: LegendAction.SHOW_ALL }));
expect(result.current.visibleData).toStrictEqual(DATA);
});
});
describe('hover', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 2 }),
);
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() =>
result.current.onLegendAction({
type: LegendAction.HOVER,
seriesIndex: null,
}),
);
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 1,
}),
);
act(() =>
result.current.onLegendAction({ type: LegendAction.HOVER, seriesIndex: 1 }),
);
expect(result.current.active).toBeNull();
});
});
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() =>
result.current.onLegendAction({
type: LegendAction.TOGGLE,
seriesIndex: 0,
}),
);
expect(mockUpdateStored).not.toHaveBeenCalled();
});
it('rehydrates hidden slices from storage on mount (matched by label)', () => {
mockGetStored.mockReturnValue([
{ label: 'frontend', show: true },
{ label: 'cart', show: false },
{ label: 'checkout', show: true },
]);
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
});
});
});

View File

@@ -1,139 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { LegendAction, OnLegendAction } from 'lib/uPlotV2/components/types';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
export interface UseLegendVisibilityResult {
visibleKeys: string[];
hiddenKeys: ReadonlySet<string>;
focusedSeriesIndex: number | null;
setFocusedKey: (key: string | null) => void;
onLegendAction: OnLegendAction;
}
function isSameSet(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
return a.size === b.size && [...a].every((entry) => b.has(entry));
}
/**
* Legend visibility and focus for charts whose legend does not list uPlot series.
* Keyed by label, so a selection survives reordering, and persists under the same
* widget store the uPlot legends use.
*/
export function useLegendVisibility({
keys,
indexOffset = 0,
id,
}: {
keys: string[];
/** Legend `seriesIndex` of `keys[0]`. Charts that mirror uPlot's 1-based data
* series pass 1. */
indexOffset?: number;
/** Widget id the selection persists under. Left out, nothing is stored. */
id?: string;
}): UseLegendVisibilityResult {
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [focusedKey, setFocusedKey] = useState<string | null>(null);
const visibleKeys = useMemo(
() => keys.filter((key) => !hidden.has(key)),
[keys, hidden],
);
// The store is the source of truth: reread it whenever the entries change.
useEffect(() => {
if (!id || !keys.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const restored = new Set(
keys.filter((key) => stored.find((s) => s.label === key)?.show === false),
);
setHidden((previous) =>
isSameSet(previous, restored) ? previous : restored,
);
}, [id, keys]);
const applyHidden = useCallback(
(next: Set<string>): void => {
setHidden(next);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
keys.map((key) => ({ label: key, show: !next.has(key) })),
);
}
},
[id, keys],
);
const onLegendAction = useCallback<OnLegendAction>(
(payload): void => {
const keyAt = (seriesIndex: number): string | undefined =>
keys[seriesIndex - indexOffset];
switch (payload.type) {
case LegendAction.TOGGLE: {
const key = keyAt(payload.seriesIndex);
if (key === undefined) {
return;
}
const next = new Set(hidden);
if (next.has(key)) {
next.delete(key);
} else {
// An empty chart is never a state worth reaching, as
// PlotContext holds for uPlot series.
if (keys.length - next.size <= 1) {
return;
}
next.add(key);
}
applyHidden(next);
break;
}
case LegendAction.SHOW_ONLY: {
const key = keyAt(payload.seriesIndex);
if (key === undefined) {
return;
}
applyHidden(new Set(keys.filter((entry) => entry !== key)));
break;
}
case LegendAction.SHOW_ALL:
applyHidden(new Set());
break;
case LegendAction.HOVER:
setFocusedKey(
payload.seriesIndex === null
? null
: (keyAt(payload.seriesIndex) ?? null),
);
break;
default:
break;
}
},
[keys, indexOffset, hidden, applyHidden],
);
// Left focused, a hidden entry keeps everything else dimmed.
const focusedIndex =
focusedKey !== null && !hidden.has(focusedKey)
? keys.indexOf(focusedKey)
: -1;
return {
visibleKeys,
hiddenKeys: hidden,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex + indexOffset : null,
setFocusedKey,
onLegendAction,
};
}

View File

@@ -0,0 +1,183 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import {
LegendAction,
LegendActionPayload,
OnLegendAction,
} from 'lib/uPlotV2/components/types';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { PieSlice } from 'lib/visualization/charts/types';
export interface UsePieInteractionsResult {
/** The hovered/focused slice (drives donut dimming + tooltip). */
active: PieSlice | null;
setActive: Dispatch<SetStateAction<PieSlice | null>>;
/** Slices currently shown (hidden ones removed). */
visibleData: PieSlice[];
/** Legend item per slice (`show` reflects hide state). */
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
/** Every legend interaction, dispatched by type. */
onLegendAction: OnLegendAction;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend action dispatch.
*/
export function usePieInteractions(
data: PieSlice[],
id?: string,
): UsePieInteractionsResult {
const [active, setActive] = useState<PieSlice | null>(null);
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const legendItems = useMemo<LegendItem[]>(
() =>
data.map((slice, index) => ({
seriesIndex: index,
label: slice.label,
color: slice.color,
show: !hiddenIndices.has(index),
})),
[data, hiddenIndices],
);
// Hidden slices drop out so the remaining arcs + centre total recompute.
const visibleData = useMemo(
() => data.filter((_, index) => !hiddenIndices.has(index)),
[data, hiddenIndices],
);
// Rehydrate hide/unhide from localStorage (matched by label) whenever the
// data set changes — including first load and every refetch, since the store
// is the source of truth and toggles write back to it.
useEffect(() => {
if (!id || !data.length) {
return;
}
const stored = getStoredSeriesVisibility(id);
if (!stored) {
return;
}
const hidden = new Set<number>();
data.forEach((slice, index) => {
if (stored.find((s) => s.label === slice.label)?.show === false) {
hidden.add(index);
}
});
setHiddenIndices(hidden);
}, [id, data]);
// Apply a new hidden set and persist it (label + show) to localStorage.
const applyHidden = useCallback(
(hidden: Set<number>): void => {
setHiddenIndices(hidden);
if (id) {
updateSeriesVisibilityToLocalStorage(
id,
data.map((slice, index) => ({
label: slice.label,
show: !hidden.has(index),
})),
);
}
},
[id, data],
);
const hoverSeries = useCallback(
(sliceIndex: number | null): void => {
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
},
[data, hiddenIndices],
);
const toggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
next.add(sliceIndex);
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const showOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, index) => {
if (index !== sliceIndex) {
next.add(index);
}
});
applyHidden(next);
},
[data, applyHidden],
);
const showAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const onLegendAction = useCallback(
(payload: LegendActionPayload): void => {
switch (payload.type) {
case LegendAction.TOGGLE:
toggleSeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ONLY:
showOnlySeries(payload.seriesIndex);
break;
case LegendAction.SHOW_ALL:
showAllSeries();
break;
case LegendAction.HOVER:
hoverSeries(payload.seriesIndex);
break;
default:
break;
}
},
[toggleSeries, showOnlySeries, showAllSeries, hoverSeries],
);
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
return {
active: effectiveActive,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendAction,
};
}

View File

@@ -21,31 +21,20 @@ export interface ChartLayoutProps {
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
/**
* Rendered directly under the plot, inside the chart column — so it stays next to
* the axis with the legend below it, and beside a RIGHT legend rather than under
* it. `layoutChildren` sits below everything instead.
*/
contentFooter?: React.ReactNode;
containerWidth: number;
containerHeight: number;
legendConfig: LegendConfig;
config: UPlotConfigBuilder;
/** Defaults to the chart's series labels. Pass them when the legend lists
* something else, or the split is measured against the wrong text. */
seriesLabelsOverride?: string[];
}
export default function ChartLayout({
showLegend = true,
legendComponent,
children,
layoutChildren,
contentFooter,
containerWidth,
containerHeight,
legendConfig,
config,
seriesLabelsOverride,
}: ChartLayoutProps): JSX.Element {
const chartDimensions = useMemo(
() => {
@@ -59,11 +48,10 @@ export default function ChartLayout({
showLegendSearch: false,
};
}
const seriesLabels =
seriesLabelsOverride ??
Object.values(config.getLegendItems())
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
const legendItemsMap = config.getLegendItems();
const seriesLabels = Object.values(legendItemsMap)
.map((item) => item.label)
.filter((label): label is string => label !== undefined);
return calculateChartDimensions({
containerWidth,
containerHeight,
@@ -72,13 +60,7 @@ export default function ChartLayout({
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
containerWidth,
containerHeight,
legendConfig,
showLegend,
seriesLabelsOverride,
],
[containerWidth, containerHeight, legendConfig, showLegend],
);
return (
@@ -96,7 +78,6 @@ export default function ChartLayout({
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
{contentFooter}
</div>
{showLegend && (
<div

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { ComponentType } from 'react';
import type {
DashboardtypesLinkDTO,
DashboardtypesAxesDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
@@ -8,7 +9,6 @@ import type {
import {
SectionKind,
type AnyThreshold,
type PanelAxesSlice,
type PanelChartAppearanceSlice,
type PanelFormattingSlice,
type PanelVisualizationSlice,
@@ -81,8 +81,8 @@ export const SECTION_REGISTRY: {
},
[SectionKind.Axes]: {
Component: AxesSection,
get: (spec): PanelAxesSlice | undefined =>
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
get: (spec): DashboardtypesAxesDTO | undefined =>
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
[SectionKind.Legend]: {

View File

@@ -1,14 +1,12 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import styles from './AxesSection.module.scss';
@@ -19,37 +17,9 @@ const SCALE_OPTIONS = [
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
];
// Auto is the only one that reads the boundaries rather than overriding them.
const Y_SCALE_OPTIONS = [
{
value: DashboardtypesHeatmapYScaleDTO.auto,
label: 'Auto',
tooltip:
'Log when every bucket bound is positive, symmetric log when they cross zero',
},
{
value: DashboardtypesHeatmapYScaleDTO.linear,
label: 'Linear',
tooltip: 'Row heights proportional to the bucket range',
},
{
value: DashboardtypesHeatmapYScaleDTO.log,
label: 'Log',
tooltip:
'Log-proportional row heights; a zero bucket sits one bucket below the smallest positive bound',
},
{
value: DashboardtypesHeatmapYScaleDTO.symlog,
label: 'Symmetric log',
tooltip:
'Linear within the smallest non-zero bound, logarithmic beyond, mirrored across zero',
},
];
/**
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds, the
* linear/logarithmic scale toggle, and the heatmap's bucket-axis distribution.
* Each control is gated by its `controls` flag.
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
*/
function AxesSection({
value,
@@ -106,21 +76,6 @@ function AxesSection({
/>
</div>
)}
{controls.y && (
<div className={styles.field}>
<Typography.Text>Y-axis scale</Typography.Text>
<ConfigSelect
testId="panel-editor-v2-y-scale"
placeholder="Select scale…"
value={value?.y?.scale}
items={Y_SCALE_OPTIONS}
onChange={(next): void =>
onChange({ ...value, y: { ...value?.y, scale: next } })
}
/>
</div>
)}
</>
);
}

View File

@@ -1,4 +1,3 @@
import { DashboardtypesHeatmapYScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import AxesSection from '../AxesSection';
@@ -65,61 +64,6 @@ describe('AxesSection', () => {
expect(onChange).toHaveBeenCalledWith({ softMax: null });
});
it('offers the bucket-axis scale instead of the soft bounds when y is on', () => {
render(
<AxesSection
value={undefined}
controls={{ y: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-y-scale')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-soft-min'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-log-scale'),
).not.toBeInTheDocument();
});
it('offers every bucket-axis scale, symmetric log included', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<AxesSection value={undefined} controls={{ y: true }} onChange={onChange} />,
);
const trigger = screen.getByTestId('panel-editor-v2-y-scale');
await user.click(
trigger.querySelector('.ant-select-selector') as HTMLElement,
);
await expect(
screen.findByRole('option', { name: /Auto/ }),
).resolves.toBeInTheDocument();
expect(screen.getByRole('option', { name: /Log/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /Linear/ })).toBeInTheDocument();
await user.click(screen.getByRole('option', { name: /Symmetric log/ }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesHeatmapYScaleDTO.symlog },
});
});
it('shows the scale the spec asks for', () => {
render(
<AxesSection
value={{ y: { scale: DashboardtypesHeatmapYScaleDTO.symlog } }}
controls={{ y: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Symmetric log')).toBeInTheDocument();
});
it('toggles the logarithmic scale through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

View File

@@ -18,7 +18,6 @@ import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
import { SegmentIcon } from '../../controls/segmentIcons';
import type { SectionEditorContext } from '../../sectionContext';
import DisconnectValuesField from './DisconnectValuesField';
import HeatmapColorsField from './HeatmapColorsField';
import styles from './ChartAppearanceSection.module.scss';
@@ -88,10 +87,9 @@ function formatOpacity(opacity: number): string {
}
/**
* Edits the `chartAppearance` slice of a panel spec: line style / interpolation, fill
* mode, fill opacity, point markers and the connect-null-gaps threshold for the
* time-axis charts, the cell colour ramp for the heatmap. Each control is gated by its
* `controls` flag.
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
* interpolation, fill mode, fill opacity, point markers, and the connect-null-gaps
* threshold. Each control is gated by its `controls` flag.
*/
function ChartAppearanceSection({
value,
@@ -184,13 +182,6 @@ function ChartAppearanceSection({
onChange={(spanGaps): void => onChange({ ...value, spanGaps })}
/>
)}
{controls.colors && (
<HeatmapColorsField
value={value?.colors}
onChange={(colors): void => onChange({ ...value, colors })}
/>
)}
</>
);
}

View File

@@ -1,60 +0,0 @@
import { useState } from 'react';
import { Slider } from '@signozhq/ui/slider';
import { Typography } from '@signozhq/ui/typography';
import {
clampColorSteps,
DEFAULT_COLOR_STEPS,
MAX_COLOR_STEPS,
MIN_COLOR_STEPS,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { singleSliderValue } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapColorStepsFieldProps {
/** Unset until the slider moves; the chart's default stands in. */
value: number | undefined;
onChange: (steps: number) => void;
}
/**
* How many colours the ramp is quantised into. Committed on release, so a drag
* doesn't write every value it passes through to the spec.
*/
function HeatmapColorStepsField({
value,
onChange,
}: HeatmapColorStepsFieldProps): JSX.Element {
const [dragging, setDragging] = useState<number | null>(null);
const steps = dragging ?? value ?? DEFAULT_COLOR_STEPS;
return (
<div className={styles.field}>
<div className={styles.fieldHeader}>
<Typography.Text>Color steps</Typography.Text>
<span className={styles.mono}>{steps}</span>
</div>
<Slider
testId="panel-editor-v2-heatmap-color-steps"
className={styles.slider}
value={steps}
min={MIN_COLOR_STEPS}
max={MAX_COLOR_STEPS}
step={1}
aria-label="Color steps"
onChange={(next): void => setDragging(singleSliderValue(next))}
onAfterChange={(next): void => {
setDragging(null);
onChange(clampColorSteps(singleSliderValue(next)));
}}
/>
<Typography.Text className={styles.help}>
How many colours the ramp is split into.
</Typography.Text>
</div>
);
}
export default HeatmapColorStepsField;

View File

@@ -1,138 +0,0 @@
.colors {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.field {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.fieldHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
// The track is inset by half a thumb at each end by default, which reads as an
// indent against the label and help text either side of it. Radix keeps the thumb
// in bounds on its own, so the track can run the full width of the field.
.slider {
--slider-track-margin-x: 0;
}
.help {
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.mono {
font-family: var(--periscope-font-family-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.bounds {
display: flex;
gap: var(--spacing-4);
}
.fillGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-4);
}
.customFill {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
padding: var(--spacing-4) var(--spacing-6);
border: 1px solid var(--l2-border);
border-radius: var(--radius-1);
background: var(--l2-background-60);
cursor: pointer;
&:hover {
border-color: var(--l2-foreground);
}
&.isSelected {
border-color: var(--accent-primary);
}
}
.customFillValue {
display: flex;
align-items: center;
gap: var(--spacing-4);
}
.customFillSwatch {
width: var(--spacing-12);
height: var(--spacing-8);
border-radius: var(--radius-1);
border: 1px solid var(--l2-border);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.previewHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-4);
}
.previewRamp {
height: var(--spacing-8);
border-radius: var(--radius-1);
border: 1px solid var(--l2-border);
}
.previewBounds {
display: flex;
justify-content: space-between;
gap: var(--spacing-4);
}
.paletteGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-4);
}
.paletteCard {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: var(--spacing-4);
border: 1px solid var(--l2-border);
border-radius: var(--radius-1);
background: var(--l2-background-60);
cursor: pointer;
text-align: left;
&:hover {
border-color: var(--l2-foreground);
}
&.isSelected {
border-color: var(--accent-primary);
}
}
.paletteRamp {
height: var(--spacing-6);
border-radius: var(--radius-1);
}

View File

@@ -1,100 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import {
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
type DashboardtypesHeatmapColorsDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import HeatmapColorStepsField from './HeatmapColorStepsField';
import HeatmapCountRangeField from './HeatmapCountRangeField';
import HeatmapFillField from './HeatmapFillField';
import HeatmapPaletteGrid from './HeatmapPaletteGrid';
import HeatmapRampPreview from './HeatmapRampPreview';
import { COLOR_MODE_OPTIONS, COLOR_SCALE_OPTIONS } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapColorsFieldProps {
value: DashboardtypesHeatmapColorsDTO | undefined;
onChange: (next: DashboardtypesHeatmapColorsDTO) => void;
}
/**
* Edits `chartAppearance.colors` — how a cell's count becomes a colour. Palette
* mode ramps through a sequential palette, opacity mode through one fill's alpha;
* everything below the mode applies to both.
*/
function HeatmapColorsField({
value,
onChange,
}: HeatmapColorsFieldProps): JSX.Element {
const isOpacityMode =
value?.mode === DashboardtypesHeatmapColorModeDTO.opacity;
return (
<div className={styles.colors}>
<HeatmapRampPreview colors={value} />
<div className={styles.field}>
<Typography.Text>Color mode</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-heatmap-color-mode"
value={value?.mode}
items={COLOR_MODE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
mode: next as DashboardtypesHeatmapColorModeDTO,
})
}
/>
</div>
{isOpacityMode ? (
<HeatmapFillField
value={value?.fill}
onChange={(fill): void => onChange({ ...value, fill })}
/>
) : (
<div className={styles.field}>
<Typography.Text>Palette</Typography.Text>
<HeatmapPaletteGrid
value={value?.palette}
onChange={(palette): void => onChange({ ...value, palette })}
/>
</div>
)}
<div className={styles.field}>
<Typography.Text>Color scale</Typography.Text>
<ConfigSegmented
testId="panel-editor-v2-heatmap-color-scale"
value={value?.scale}
items={COLOR_SCALE_OPTIONS}
onChange={(next): void =>
onChange({
...value,
scale: next as DashboardtypesHeatmapColorScaleDTO,
})
}
/>
<Typography.Text className={styles.help}>
How a count maps onto the ramp.
</Typography.Text>
</div>
<HeatmapColorStepsField
value={value?.steps || undefined}
onChange={(steps): void => onChange({ ...value, steps })}
/>
<HeatmapCountRangeField
value={{ minCount: value?.minCount, maxCount: value?.maxCount }}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
/>
</div>
);
}
export default HeatmapColorsField;

View File

@@ -1,65 +0,0 @@
import type { ChangeEvent } from 'react';
import { Typography } from '@signozhq/ui/typography';
import { Input } from 'antd';
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './HeatmapColorsField.module.scss';
/** `null` on either bound derives it from the data. */
type CountBound = Pick<DashboardtypesHeatmapColorsDTO, 'minCount' | 'maxCount'>;
interface HeatmapCountRangeFieldProps {
value: CountBound;
onChange: (next: CountBound) => void;
}
/**
* Clamps the colour scale to a fixed count range. Two panels rarely derive the
* same bounds, so comparing them needs the range pinned on both.
*/
function HeatmapCountRangeField({
value,
onChange,
}: HeatmapCountRangeFieldProps): JSX.Element {
// An empty field derives the bound (null), as does transient non-numeric input
// (a lone "-"), which pinning would read as a bound the user never typed.
const handleBound =
(bound: keyof CountBound) =>
(event: ChangeEvent<HTMLInputElement>): void => {
const raw = event.target.value;
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
onChange({ ...value, [bound]: next });
};
return (
<div className={styles.field}>
<div className={styles.bounds}>
<div className={styles.field}>
<Typography.Text>Min count</Typography.Text>
<Input
data-testid="panel-editor-v2-heatmap-min-count"
type="number"
placeholder="Auto"
value={value.minCount ?? ''}
onChange={handleBound('minCount')}
/>
</div>
<div className={styles.field}>
<Typography.Text>Max count</Typography.Text>
<Input
data-testid="panel-editor-v2-heatmap-max-count"
type="number"
placeholder="Auto"
value={value.maxCount ?? ''}
onChange={handleBound('maxCount')}
/>
</div>
</div>
<Typography.Text className={styles.help}>
The counts the ramp&apos;s ends mean; empty reads them off the data.
</Typography.Text>
</div>
);
}
export default HeatmapCountRangeField;

View File

@@ -1,85 +0,0 @@
import cx from 'classnames';
import { ColorPicker } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { DEFAULT_OPACITY_FILL } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { FILL_PRESETS, isSameColor } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapFillFieldProps {
/** Empty follows the selected group's legend colour. */
value: string | undefined;
onChange: (fill: string) => void;
}
/**
* The colour opacity mode ramps the alpha of. Any hex goes, since the point of
* the fill is matching whatever else the dashboard already uses.
*/
function HeatmapFillField({
value,
onChange,
}: HeatmapFillFieldProps): JSX.Element {
const isAuto = !value;
const isCustom =
!isAuto && !FILL_PRESETS.some((preset) => isSameColor(preset.color, value));
return (
<div className={styles.field}>
<Typography.Text>Base color</Typography.Text>
<div className={styles.fillGrid}>
{FILL_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
aria-pressed={isSameColor(preset.color, value)}
className={cx(styles.paletteCard, {
[styles.isSelected]: isSameColor(preset.color, value),
})}
data-testid={`panel-editor-v2-heatmap-fill-${preset.label}`}
onClick={(): void => onChange(preset.color)}
>
<span
className={styles.paletteRamp}
style={{ background: preset.color }}
/>
<span className={styles.mono}>{preset.label}</span>
</button>
))}
</div>
<ColorPicker
value={value || DEFAULT_OPACITY_FILL}
trigger="click"
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.customFill, {
[styles.isSelected]: isCustom,
})}
data-testid="panel-editor-v2-heatmap-fill-custom"
>
<Typography.Text>Custom</Typography.Text>
<span className={styles.customFillValue}>
<span className={styles.mono}>
{isAuto ? 'group colour' : value.toUpperCase()}
</span>
<span
className={styles.customFillSwatch}
style={{ background: value || DEFAULT_OPACITY_FILL }}
/>
</span>
</button>
</ColorPicker>
<Typography.Text className={styles.help}>
The colour whose alpha ramps; unset uses the group&apos;s.
</Typography.Text>
</div>
);
}
export default HeatmapFillField;

View File

@@ -1,45 +0,0 @@
import cx from 'classnames';
import type { DashboardtypesHeatmapPaletteDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { paletteGradient, PALETTE_OPTIONS } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
interface HeatmapPaletteGridProps {
value: DashboardtypesHeatmapPaletteDTO | undefined;
onChange: (palette: DashboardtypesHeatmapPaletteDTO) => void;
}
/** The palettes as the ramps they are: "lava" and "ember" name nothing visible. */
function HeatmapPaletteGrid({
value,
onChange,
}: HeatmapPaletteGridProps): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div className={styles.paletteGrid}>
{PALETTE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={option.value === value}
className={cx(styles.paletteCard, {
[styles.isSelected]: option.value === value,
})}
data-testid={`panel-editor-v2-heatmap-palette-${option.value}`}
onClick={(): void => onChange(option.value)}
>
<span
className={styles.paletteRamp}
style={{ background: paletteGradient(option.value, isDarkMode) }}
/>
<span className={styles.mono}>{option.label}</span>
</button>
))}
</div>
);
}
export default HeatmapPaletteGrid;

View File

@@ -1,76 +0,0 @@
import { useMemo } from 'react';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesHeatmapColorsDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
createHeatmapColorResolver,
DEFAULT_HEATMAP_COLORS,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { HeatmapColorMode } from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import { resolveHeatmapColors } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/resolvers';
import { rampGradient } from './heatmapColorOptions';
import styles from './HeatmapColorsField.module.scss';
/** The ramp is domain-independent; the preview shows colours, not positions. */
const NOMINAL_DOMAIN = { min: 0, max: 1, logFloor: 1 };
interface HeatmapRampPreviewProps {
colors: DashboardtypesHeatmapColorsDTO | undefined;
}
/**
* The ramp the grid will draw, resolved through the chart's own colour resolver.
* Its ends carry the counts it is stretched between; an unset maximum is only
* known once the data arrives.
*/
function HeatmapRampPreview({ colors }: HeatmapRampPreviewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const options = useMemo(
() => ({ ...DEFAULT_HEATMAP_COLORS, ...resolveHeatmapColors(colors) }),
[colors],
);
const ramp = useMemo(
() =>
createHeatmapColorResolver({
options,
domain: NOMINAL_DOMAIN,
isDarkMode,
// No group to follow here, so the resolver's fallback fill stands in.
seriesColor: '',
}).ramp,
[options, isDarkMode],
);
const summary = [
options.mode === HeatmapColorMode.Opacity ? 'opacity' : options.palette,
options.scale,
`${options.steps} steps`,
].join(' · ');
return (
<div className={styles.preview} data-testid="panel-editor-v2-heatmap-preview">
<div className={styles.previewHeader}>
<Typography.Text>Preview</Typography.Text>
<span className={styles.mono}>{summary}</span>
</div>
<div
className={styles.previewRamp}
style={{ background: rampGradient(ramp) }}
/>
<div className={styles.previewBounds}>
<span className={styles.mono}>
{(colors?.minCount ?? 0).toLocaleString()}
</span>
<span className={styles.mono}>
{colors?.maxCount == null ? 'auto' : colors.maxCount.toLocaleString()}
</span>
</div>
</div>
);
}
export default HeatmapRampPreview;

View File

@@ -86,36 +86,6 @@ describe('ChartAppearanceSection', () => {
).not.toBeInTheDocument();
});
it('offers the heatmap colour ramp only when the colors flag is set', () => {
const { unmount } = render(
<ChartAppearanceSection
value={undefined}
controls={ALL_CONTROLS}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-heatmap-color-mode'),
).not.toBeInTheDocument();
unmount();
render(
<ChartAppearanceSection
value={undefined}
controls={{ colors: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-heatmap-color-mode'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-line-style'),
).not.toBeInTheDocument();
});
it('writes the chosen fill mode through the segmented control', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

View File

@@ -1,276 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import {
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DEFAULT_COLOR_STEPS } from 'lib/uPlotV2/plugins/HeatmapPlugin/colorScale';
import { render, screen, userEvent } from 'tests/test-utils';
import HeatmapColorsField from '../HeatmapColorsField';
const PALETTE_CARD = `panel-editor-v2-heatmap-palette-${DashboardtypesHeatmapPaletteDTO.lagoon}`;
describe('HeatmapColorsField', () => {
it('shows the ramp the grid will draw, with what resolved it', () => {
render(
<HeatmapColorsField
value={{
palette: DashboardtypesHeatmapPaletteDTO.lava,
scale: DashboardtypesHeatmapColorScaleDTO.log,
steps: 32,
}}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-heatmap-preview'),
).toBeInTheDocument();
expect(screen.getByText('lava · log · 32 steps')).toBeInTheDocument();
});
it('labels the ramp ends with the counts it is stretched between', () => {
render(
<HeatmapColorsField value={{ maxCount: 4000 }} onChange={jest.fn()} />,
);
// An unset minimum always resolves to 0; an unset maximum needs the data.
expect(screen.getByText('0')).toBeInTheDocument();
expect(screen.getByText('4,000')).toBeInTheDocument();
});
it('offers the palettes as the ramps they are in palette mode', () => {
render(
<HeatmapColorsField
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId(PALETTE_CARD)).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-color-scale'),
).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-color-steps'),
).toBeInTheDocument();
});
it('swaps the palettes for the base colour in opacity mode', () => {
render(
<HeatmapColorsField
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
onChange={jest.fn()}
/>,
);
expect(screen.queryByTestId(PALETTE_CARD)).not.toBeInTheDocument();
expect(screen.getByText('Base color')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-custom'),
).toBeInTheDocument();
});
it('writes the colour mode through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<HeatmapColorsField
value={{ mode: DashboardtypesHeatmapColorModeDTO.palette }}
onChange={onChange}
/>,
);
await user.click(screen.getByText('Opacity'));
expect(onChange).toHaveBeenCalledWith({
mode: DashboardtypesHeatmapColorModeDTO.opacity,
});
});
it('writes the picked palette through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(PALETTE_CARD));
expect(onChange).toHaveBeenCalledWith({
palette: DashboardtypesHeatmapPaletteDTO.lagoon,
});
});
it('marks the palette the spec asks for as the selected one', () => {
render(
<HeatmapColorsField
value={{ palette: DashboardtypesHeatmapPaletteDTO.lagoon }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId(PALETTE_CARD)).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('writes the colour scale through onChange and says what it drives', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
expect(
screen.getByText(/How a count maps onto the ramp/),
).toBeInTheDocument();
await user.click(screen.getByText('Sqrt'));
expect(onChange).toHaveBeenCalledWith({
scale: DashboardtypesHeatmapColorScaleDTO.sqrt,
});
});
it('shows the default for a zero step count, which is how the wire sends none', () => {
render(<HeatmapColorsField value={{ steps: 0 }} onChange={jest.fn()} />);
expect(
screen.getByText(`lava · log · ${DEFAULT_COLOR_STEPS} steps`),
).toBeInTheDocument();
expect(screen.getByText(String(DEFAULT_COLOR_STEPS))).toBeInTheDocument();
});
it('steps off the default step count, which stands in until it is moved', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
expect(screen.getByRole('slider')).toHaveAttribute(
'aria-valuenow',
String(DEFAULT_COLOR_STEPS),
);
screen.getByRole('slider').focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
steps: DEFAULT_COLOR_STEPS + 1,
});
});
it('cannot be taken past the step counts the chart can draw', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={{ steps: 2 }} onChange={onChange} />);
const slider = screen.getByRole('slider');
expect(slider).toHaveAttribute('aria-valuemin', '2');
expect(slider).toHaveAttribute('aria-valuemax', '128');
// Already at the floor, so there is no step to take and nothing to write.
slider.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('writes a pinned count bound through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-heatmap-max-count'), '5');
expect(onChange).toHaveBeenLastCalledWith({ maxCount: 5 });
});
it('takes no letters, which would silently read as no bound at all', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={undefined} onChange={onChange} />);
const field = screen.getByTestId('panel-editor-v2-heatmap-min-count');
await user.type(field, 'dsd');
expect(field).toHaveValue(null);
expect(onChange).not.toHaveBeenCalled();
});
it('clears a count bound to null, which asks for the derived one', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<HeatmapColorsField value={{ maxCount: 500 }} onChange={onChange} />);
await user.clear(screen.getByTestId('panel-editor-v2-heatmap-max-count'));
expect(onChange).toHaveBeenCalledWith({ maxCount: null });
});
it('writes a preset base colour through onChange', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<HeatmapColorsField
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
onChange={onChange}
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-heatmap-fill-forest'));
expect(onChange).toHaveBeenCalledWith({
mode: DashboardtypesHeatmapColorModeDTO.opacity,
fill: Color.BG_FOREST_400,
});
});
it('marks the preset the spec asks for, whatever case its hex is in', () => {
render(
<HeatmapColorsField
value={{
mode: DashboardtypesHeatmapColorModeDTO.opacity,
fill: Color.BG_FOREST_400.toUpperCase(),
}}
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-forest'),
).toHaveAttribute('aria-pressed', 'true');
});
it('shows a fill that is no preset as the custom one', () => {
render(
<HeatmapColorsField
value={{
mode: DashboardtypesHeatmapColorModeDTO.opacity,
fill: '#0060e6',
}}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('#0060E6')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
).toHaveAttribute('aria-pressed', 'false');
});
it('names the group colour as the fill until one is picked', () => {
render(
<HeatmapColorsField
value={{ mode: DashboardtypesHeatmapColorModeDTO.opacity }}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('group colour')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-heatmap-fill-robin'),
).toHaveAttribute('aria-pressed', 'false');
});
});

View File

@@ -1,102 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import {
DashboardtypesHeatmapColorModeDTO,
DashboardtypesHeatmapColorScaleDTO,
DashboardtypesHeatmapPaletteDTO,
} from 'api/generated/services/sigNoz.schemas';
import { HEATMAP_PALETTE_MAP } from 'pages/DashboardPage/DashboardContainer/Panels/utils/chartAppearance/enumMaps';
import { getPaletteStops } from 'lib/uPlotV2/plugins/HeatmapPlugin/palettes';
import type { ConfigSegmentedItem } from '../../controls/ConfigSegmented/ConfigSegmented';
export const COLOR_MODE_OPTIONS: ConfigSegmentedItem[] = [
{ value: DashboardtypesHeatmapColorModeDTO.palette, label: 'Palette' },
{ value: DashboardtypesHeatmapColorModeDTO.opacity, label: 'Opacity' },
];
// Log first: bucket counts routinely span decades.
export const COLOR_SCALE_OPTIONS: ConfigSegmentedItem[] = [
{
value: DashboardtypesHeatmapColorScaleDTO.log,
label: 'Log',
icon: 'scale-log',
},
{
value: DashboardtypesHeatmapColorScaleDTO.sqrt,
label: 'Sqrt',
icon: 'scale-sqrt',
},
{
value: DashboardtypesHeatmapColorScaleDTO.linear,
label: 'Linear',
icon: 'scale-linear',
},
];
export interface HeatmapFillPreset {
label: string;
color: string;
}
/** Opacity-mode base colours: the palette values that read on both themes. */
export const FILL_PRESETS: HeatmapFillPreset[] = [
{ label: 'robin', color: Color.BG_ROBIN_400 },
{ label: 'aqua', color: Color.BG_AQUA_400 },
{ label: 'forest', color: Color.BG_FOREST_400 },
{ label: 'sienna', color: Color.BG_SIENNA_400 },
];
/** Hex comparison for a spec value that may differ only in case. */
export function isSameColor(
left: string | undefined,
right: string | undefined,
): boolean {
return Boolean(left) && left?.toLowerCase() === right?.toLowerCase();
}
export interface HeatmapPaletteOption {
value: DashboardtypesHeatmapPaletteDTO;
label: string;
}
export const PALETTE_OPTIONS: HeatmapPaletteOption[] = [
{ value: DashboardtypesHeatmapPaletteDTO.ice, label: 'ice' },
{ value: DashboardtypesHeatmapPaletteDTO.moss, label: 'moss' },
{ value: DashboardtypesHeatmapPaletteDTO.rust, label: 'rust' },
{ value: DashboardtypesHeatmapPaletteDTO.graphite, label: 'graphite' },
{ value: DashboardtypesHeatmapPaletteDTO.ember, label: 'ember' },
{ value: DashboardtypesHeatmapPaletteDTO.lagoon, label: 'lagoon' },
{ value: DashboardtypesHeatmapPaletteDTO.orchid, label: 'orchid' },
{ value: DashboardtypesHeatmapPaletteDTO.verdant, label: 'verdant' },
{ value: DashboardtypesHeatmapPaletteDTO.lava, label: 'lava' },
{ value: DashboardtypesHeatmapPaletteDTO.beacon, label: 'beacon' },
];
/** `background` for a palette's card, low-count-first like the colour bar. */
export function paletteGradient(
palette: DashboardtypesHeatmapPaletteDTO,
isDarkMode: boolean,
): string {
const stops = getPaletteStops(HEATMAP_PALETTE_MAP[palette], isDarkMode);
return `linear-gradient(90deg, ${stops.join(', ')})`;
}
/** `background` for a resolved ramp: hard-edged bands, one per colour step. */
export function rampGradient(ramp: string[]): string {
if (ramp.length === 0) {
return 'transparent';
}
if (ramp.length === 1) {
return ramp[0];
}
const stops = ramp.flatMap((color, index) => [
`${color} ${(index / ramp.length) * 100}%`,
`${color} ${((index + 1) / ramp.length) * 100}%`,
]);
return `linear-gradient(90deg, ${stops.join(', ')})`;
}
/** The Slider emits a pair in range mode; these are all single-thumb. */
export function singleSliderValue(value: number | number[]): number {
return Array.isArray(value) ? value[0] : value;
}

View File

@@ -26,8 +26,7 @@ import { toPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
* builder may be pointed at, and how it narrows the builder's fields. */
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
@@ -99,7 +98,6 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={panelDefinition.supportedSignals}
showTraceOperator={!isRawQuery}
version="v3"
isRawQuery={isRawQuery}

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More