Compare commits

..

7 Commits

Author SHA1 Message Date
aks07
89666ef2d9 chore(explorer): remove an inaccurate comment 2026-10-08 21:14:21 +05:30
aks07
41ad49d21d fix(llm-observability): run the orderBy sanitizer on the explorer route 2026-10-08 21:14:21 +05:30
Aditya Singh
9e8b1530fd Merge branch 'main' into fix/explorer-list-order-by 2026-10-08 18:37:10 +05:30
aks07
9e2ac4c55f fix(llm-observability): keep the list order-by in the composite query
Apply the same change as the logs and traces explorers to the list and trace
views, which are a copy of them. The trace view keeps its own default of
last_activity_time:desc.
2026-10-08 18:20:37 +05:30
aks07
a0960fe862 fix(explorer): keep the list order-by in the composite query
The order lived in component state, so it never reached the URL and was lost
on back navigation, ignored on a deep link, and missing from a saved view. It
now lives in the query and is written through redirectWithQueryBuilderData.

A raw panel cannot order by an aggregation, so getRawPanelOrderBy drops those
on read and leaves them in the query for the aggregated tab to use.
2026-10-08 18:20:35 +05:30
aks07
cd1a88fd7d fix(explorer): clear the run-query bypass after the init it applies to
handleRunQuery sets a flag so the init it triggers keeps the query exactly as
built. The flag was cleared only when the pathname changed, so after one run
every later init on that page skipped sanitizing too: tab switches, filters,
time range changes and saved views. A refresh reset it, a tab switch did not.

Cmd+Enter in the filter bar also sets it, so most sessions entered that state
without using Stage and Run.
2026-10-08 18:20:30 +05:30
aks07
0b6a6134d2 fix(explorer): stop the orderBy sanitizer running on list panels
The sanitizer keeps only order-by columns that are a group-by key or an
aggregation. A list panel has neither, so it dropped every valid row-field
sort. Gate it on panel type, and keep it for the aggregated panels it was
written for.
2026-10-08 18:20:17 +05:30
159 changed files with 648 additions and 8210 deletions

View File

@@ -3425,13 +3425,6 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4174,7 +4167,6 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4190,7 +4182,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4204,7 +4195,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4290,18 +4280,6 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4381,16 +4359,6 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4564,87 +4532,6 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesSource:
enum:
- user

View File

@@ -57,7 +57,6 @@ const mockPaths = {
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
const mockOrient = jest.fn();
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
@@ -71,7 +70,6 @@ function MockUPlot(
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
MockUPlot.orient = mockOrient;
// Pinned so canvas-space maths in draw hooks is deterministic under jsdom.
MockUPlot.pxRatio = 1;

View File

@@ -4372,12 +4372,6 @@ export interface DashboardtypesAreaChartPanelSpecDTO {
visualization?: DashboardtypesAreaChartVisualizationDTO;
}
export enum DashboardtypesAxisScaleDTO {
auto = 'auto',
linear = 'linear',
log = 'log',
symlog = 'symlog',
}
export interface DashboardtypesBarChartVisualizationDTO {
/**
* @type boolean
@@ -5378,101 +5372,6 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesHeatmapPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind {
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
export interface DashboardtypesScatterPlotAxisDTO {
/**
* @type string
* @description Axis title. Empty draws none.
*/
label?: string;
scale?: DashboardtypesAxisScaleDTO;
/**
* @type number,null
*/
softMax?: number | null;
/**
* @type number,null
*/
softMin?: number | null;
}
export interface DashboardtypesScatterPlotAxesDTO {
x?: DashboardtypesScatterPlotAxisDTO;
y?: DashboardtypesScatterPlotAxisDTO;
}
/**
* @minimum 2
* @maximum 40
* @nullable
*/
export type DashboardtypesPointDiameterDTO = number | null;
/**
* @minimum 0.1
* @maximum 1
* @nullable
*/
export type DashboardtypesPointOpacityDTO = number | null;
export interface DashboardtypesScatterPlotPointsDTO {
maxSize?: DashboardtypesPointDiameterDTO | null;
minSize?: DashboardtypesPointDiameterDTO | null;
opacity?: DashboardtypesPointOpacityDTO | null;
size?: DashboardtypesPointDiameterDTO | null;
}
export interface DashboardtypesScatterPlotChartAppearanceDTO {
points?: DashboardtypesScatterPlotPointsDTO;
}
export interface DashboardtypesScatterPlotDimensionsDTO {
/**
* @type array,null
* @description Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label.
*/
colorBy?: string[] | null;
/**
* @type string
* @description Value column key that scales dot size. Empty draws every dot at the default size.
*/
sizeBy?: string;
/**
* @type string
* @description Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column.
*/
x?: string;
/**
* @type string
* @description Value column key plotted on the y axis. Empty uses the second value column.
*/
y?: string;
}
export interface DashboardtypesScatterPlotPanelSpecDTO {
axes?: DashboardtypesScatterPlotAxesDTO;
chartAppearance?: DashboardtypesScatterPlotChartAppearanceDTO;
dimensions?: DashboardtypesScatterPlotDimensionsDTO;
formatting?: DashboardtypesTableFormattingDTO;
legend?: DashboardtypesLegendDTO;
/**
* @type array,null
*/
thresholds?: DashboardtypesThresholdWithLabelDTO[] | null;
visualization?: DashboardtypesBasicVisualizationDTO;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO {
/**
* @enum signoz/ScatterPlotPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTOKind;
spec: DashboardtypesScatterPlotPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5483,8 +5382,7 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -6411,7 +6309,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
'signoz/HeatmapPanel' = 'signoz/HeatmapPanel',
'signoz/ScatterPlotPanel' = 'signoz/ScatterPlotPanel',
}
/**
* @nullable

View File

@@ -21,7 +21,6 @@ import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
mapPanelTypeToRequestType,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
@@ -905,12 +904,6 @@ describe('prepareQueryRangePayloadV5', () => {
});
});
describe('mapPanelTypeToRequestType', () => {
it('sends a scatter plot as scalar, like a table', () => {
expect(mapPanelTypeToRequestType(PANEL_TYPES.SCATTER)).toBe('scalar');
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(

View File

@@ -51,7 +51,6 @@ export function mapPanelTypeToRequestType(panelType: PANEL_TYPES): RequestType {
case PANEL_TYPES.BAR:
return 'time_series';
case PANEL_TYPES.TABLE:
case PANEL_TYPES.SCATTER:
case PANEL_TYPES.PIE:
case PANEL_TYPES.VALUE:
return 'scalar';
@@ -279,7 +278,6 @@ export function createAggregation(
queryData.dataSource === DataSource.METRICS &&
panelType &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE);

View File

@@ -518,10 +518,7 @@ function QueryAddOns({
type="number"
onChange={handleChangeLimit}
initialValue={query?.limit ?? undefined}
placeholder={
resolvedFields.get(QueryBuilderField.Limit)?.placeholder ??
'Enter limit'
}
placeholder="Enter limit"
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}
closeIcon={<ChevronUp size={16} />}
/>

View File

@@ -43,9 +43,7 @@ function QueryAggregationOptions({
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE)
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, reason: undefined };
}

View File

@@ -95,7 +95,6 @@ export const QueryV2 = forwardRef(function QueryV2(
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.SCATTER ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],

View File

@@ -9,7 +9,6 @@ import {
} from 'tests/test-utils';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { QueryBuilderField } from '../queryBuilderFields.types';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
// Mocks: only what is required for this component to render and for us to assert handler calls
@@ -170,33 +169,6 @@ describe('QueryAddOns', () => {
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it('shows the configured limit placeholder', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isRawQuery={false}
showReduceTo={false}
panelType={PANEL_TYPES.SCATTER}
index={0}
fieldsConfig={{
[QueryBuilderField.Limit]: {
state: 'defaulted',
placeholder: 'Default 10,000',
},
}}
isForTraceOperator={false}
/>,
);
fireEvent.click(screen.getByTestId('query-add-on-limit'));
expect(screen.getByTestId('input-Limit')).toHaveAttribute(
'placeholder',
'Default 10,000',
);
});
it('auto-opens Order By and Limit when present in query', () => {
const query = baseQuery({
orderBy: [{ columnName: 'duration', order: 'desc' }],

View File

@@ -1,45 +0,0 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { render, screen } from 'tests/test-utils';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import QueryAggregationOptions from '../QueryV2/QueryAggregation/QueryAggregation';
jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
__esModule: true,
default: (): null => null,
}));
const queryData = initialQueriesMap.traces.builder
.queryData[0] as IBuilderQuery;
function renderFor(panelType: PANEL_TYPES, dataSource: DataSource): void {
render(
<QueryAggregationOptions
dataSource={dataSource}
panelType={panelType}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
}
describe('QueryAggregationOptions step interval', () => {
it.each([PANEL_TYPES.TABLE, PANEL_TYPES.SCATTER, PANEL_TYPES.PIE])(
'is hidden for a %s panel over traces, which reduces the whole window',
(panelType) => {
renderFor(panelType, DataSource.TRACES);
expect(screen.queryByText('every')).not.toBeInTheDocument();
},
);
it('stays for a scatter plot over metrics, where it sets the series step', () => {
renderFor(PANEL_TYPES.SCATTER, DataSource.METRICS);
expect(screen.getByText('every')).toBeInTheDocument();
});
it('stays for a time series over traces', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
expect(screen.getByText('every')).toBeInTheDocument();
});
});

View File

@@ -57,19 +57,6 @@ describe('resolveQueryBuilderField', () => {
expect(resolved.hidden).toBe(false);
});
it('keeps a defaulted field as is, carrying its placeholder', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'defaulted', placeholder: 'Default 10' },
});
expect(resolved).toStrictEqual({
hidden: false,
disabled: false,
pinned: false,
placeholder: 'Default 10',
});
});
it('only ever resolves one state at a time', () => {
const resolved = resolveQueryBuilderField(QueryBuilderField.Limit, {
[QueryBuilderField.Limit]: { state: 'disabled', reason: 'why' },

View File

@@ -22,12 +22,8 @@ export enum QueryBuilderField {
AdditionalQueries = 'additional_queries',
}
/**
* `reason` is required on `disabled`: an inert control the user can see has to explain itself.
* `defaulted` leaves the field as is; its placeholder names the value an empty field falls back to.
*/
/** `reason` is required on `disabled`: an inert control the user can see has to explain itself. */
export type QueryBuilderFieldRule =
| { state: 'defaulted'; placeholder: string }
| { state: 'hidden' }
| { state: 'disabled'; reason: string }
| { state: 'pinned' };

View File

@@ -10,7 +10,6 @@ export interface ResolvedQueryBuilderField {
reason?: string;
/** Rendered open, not dismissable, and kept out of the add-on toggle bar. */
pinned: boolean;
placeholder?: string;
}
const AVAILABLE: ResolvedQueryBuilderField = {
@@ -21,8 +20,6 @@ const AVAILABLE: ResolvedQueryBuilderField = {
function fromRule(rule: QueryBuilderFieldRule): ResolvedQueryBuilderField {
switch (rule.state) {
case 'defaulted':
return { ...AVAILABLE, placeholder: rule.placeholder };
case 'hidden':
return { hidden: true, disabled: false, pinned: false };
case 'disabled':

View File

@@ -36,7 +36,6 @@ export const getComponentForPanelType = (
[PANEL_TYPES.HEATMAP]: null,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -337,7 +337,6 @@ export enum PANEL_TYPES {
TRACE = 'trace',
BAR = 'bar',
AREA = 'area',
SCATTER = 'scatter',
PIE = 'pie',
HISTOGRAM = 'histogram',
HEATMAP = 'heatmap',

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -34,6 +33,8 @@ import { Warning } from 'types/api';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import TraceExplorerControls from '../Controls';
import { getListViewQuery } from '../explorerUtils';
import {
@@ -58,12 +59,16 @@ function ListView({
setIsLoadingQueries,
queryKeyRef,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -92,7 +97,6 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
orderBy,
],
[
stagedQuery,
@@ -101,7 +105,6 @@ function ListView({
paginationConfig,
maxTime,
minTime,
orderBy,
],
);
@@ -167,9 +170,12 @@ function ListView({
[queryTableData],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -6,7 +6,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { QueryKey } from 'react-query';
// eslint-disable-next-line no-restricted-imports
@@ -23,6 +22,7 @@ import { TracesTableRow } from '../TracesTable/getFieldColumn';
import TracesTable from '../TracesTable/TracesTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { ArrowUp10, Minus } from '@signozhq/icons';
@@ -57,9 +57,13 @@ function TracesView({
setIsLoadingQueries,
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const [orderBy, setOrderBy] = useState<string>(TRACE_VIEW_DEFAULT_ORDER_BY);
const orderBy = useMemo(
() => getListOrderBy(stagedQuery, TRACE_VIEW_DEFAULT_ORDER_BY),
[stagedQuery],
);
const {
columns,
@@ -94,7 +98,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
[
globalSelectedTime,
@@ -103,7 +106,6 @@ function TracesView({
stagedQuery,
panelType,
paginationQueryData,
orderBy,
],
);
@@ -165,9 +167,12 @@ function TracesView({
}
}, [isLoading, isFetching, isError, rows.length]);
const handleOrderChange = useCallback((value: string): void => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
// Without the full column set there is no pool to pick from, so the control is dropped.
const fieldsSelectorConfig = useMemo(

View File

@@ -1,6 +1,7 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -11,12 +12,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -16,14 +16,14 @@ function LogsActionsContainer({
showFrequencyChart,
handleToggleFrequencyChart,
orderBy,
setOrderBy,
onOrderChange,
explorerActions,
}: {
listQuery: any;
showFrequencyChart: boolean;
handleToggleFrequencyChart: () => void;
orderBy: string;
setOrderBy: (value: string) => void;
onOrderChange: (value: string) => void;
explorerActions: ReactNode;
}): JSX.Element {
const { options, config } = useOptionsMenu({
@@ -78,7 +78,7 @@ function LogsActionsContainer({
<ListViewOrderBy
value={orderBy}
onChange={(value): void => setOrderBy(value)}
onChange={onOrderChange}
dataSource={DataSource.LOGS}
/>
</div>

View File

@@ -59,6 +59,7 @@ import { QueryDataV3 } from 'types/api/widgets/getQuery';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 } from 'uuid';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import LogsActionsContainer from './LogsActionsContainer';
@@ -101,7 +102,8 @@ function LogsExplorerViewsContainer({
const currentMinTimeRef = useRef<number>(minTime);
// Context
const { stagedQuery, panelType } = useQueryBuilder();
const { stagedQuery, currentQuery, redirectWithQueryBuilderData, panelType } =
useQueryBuilder();
const selectedPanelType = panelType || PANEL_TYPES.LIST;
@@ -111,7 +113,7 @@ function LogsExplorerViewsContainer({
const [requestData, setRequestData] = useState<Query | null>(null);
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit('');
@@ -192,6 +194,13 @@ function LogsExplorerViewsContainer({
'custom',
);
const handleOrderChange = useCallback(
(value: string): void => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
const getRequestData = useCallback(
(
query: Query | null,
@@ -430,7 +439,7 @@ function LogsExplorerViewsContainer({
showFrequencyChart={showFrequencyChart}
handleToggleFrequencyChart={handleToggleFrequencyChart}
orderBy={orderBy}
setOrderBy={setOrderBy}
onOrderChange={handleOrderChange}
explorerActions={explorerActions}
/>
)}

View File

@@ -7,7 +7,6 @@ import {
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
@@ -34,6 +33,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { getListOrderBy, setListOrderBy } from 'utils/explorerUtils';
import { ArrowUp10, Minus } from '@signozhq/icons';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -66,12 +66,16 @@ function ListView({
queryKeyRef,
headerActions,
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const {
stagedQuery,
currentQuery,
redirectWithQueryBuilderData,
panelType: panelTypeFromQueryBuilder,
} = useQueryBuilder();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const orderBy = useMemo(() => getListOrderBy(stagedQuery), [stagedQuery]);
const {
selectedTime: globalSelectedTime,
@@ -123,7 +127,6 @@ function ListView({
panelType,
paginationConfig,
selectColumnsSignature,
orderBy,
],
[
stagedQuery,
@@ -133,7 +136,6 @@ function ListView({
selectColumnsSignature,
maxTime,
minTime,
orderBy,
],
);
@@ -215,9 +217,12 @@ function ListView({
[config],
);
const handleOrderChange = useCallback((value: string) => {
setOrderBy(value);
}, []);
const handleOrderChange = useCallback(
(value: string) => {
redirectWithQueryBuilderData(setListOrderBy(currentQuery, value));
},
[currentQuery, redirectWithQueryBuilderData],
);
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {

View File

@@ -2,6 +2,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { cloneDeep, set } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
export const getListViewQuery = (
stagedQuery: Query,
@@ -12,12 +13,7 @@ export const getListViewQuery = (
: cloneDeep(initialQueriesMap.traces);
const orderByPayload: OrderByPayload[] = orderBy
? [
{
columnName: orderBy.split(':')[0],
order: orderBy.split(':')[1] as 'asc' | 'desc',
},
]
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {

View File

@@ -28,7 +28,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
TRACE: false,
BAR: true,
AREA: true,
SCATTER: false,
PIE: false,
HISTOGRAM: false,
HEATMAP: false,

View File

@@ -16,7 +16,6 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.SCATTER]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -14,7 +14,6 @@ export type PartialPanelTypes = {
[PANEL_TYPES.AREA]: 'area';
[PANEL_TYPES.VALUE]: 'value';
[PANEL_TYPES.PIE]: 'pie';
[PANEL_TYPES.SCATTER]: 'scatter';
[PANEL_TYPES.HISTOGRAM]: 'histogram';
[PANEL_TYPES.HEATMAP]: 'heatmap';
};
@@ -150,7 +149,6 @@ export const panelTypeDataSourceFormValuesMap: Record<
[PANEL_TYPES.HEATMAP]: bySource(HEATMAP, HEATMAP_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.SCATTER]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
};

View File

@@ -1,105 +0,0 @@
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
// Padding lives on the sections so a footer can reach the container edges.
.container {
// Same inset as the heatmap tooltip.
--gutter: 14px;
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);
}
}
.divider {
display: block;
width: 100%;
height: 1px;
background-color: var(--l2-border);
}
.header {
display: flex;
align-items: center;
gap: var(--spacing-3);
padding: var(--spacing-6) var(--gutter);
}
// Matches the legend row's marker.
.marker {
box-sizing: border-box;
width: 12px;
height: 12px;
border: 1.5px solid;
border-radius: var(--radius);
flex-shrink: 0;
}
.title {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--text-vanilla-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-1);
font-size: 11px;
color: var(--text-vanilla-400);
flex-shrink: 0;
}
.rows {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: var(--spacing-5) var(--gutter);
}
.row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-4);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
color: var(--text-vanilla-100);
}
// The group values name the point; the channels are what it says.
.rowMuted {
color: var(--text-vanilla-400);
}
.rowLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowValue {
flex: 0 0 auto;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,114 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/**
* One point, its channels, then the group values that name it. Purpose-built
* rather than composed from the shared `Tooltip`, whose list is one row per
* series at a shared x; a scatter point has no such neighbours.
*/
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointLabels,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const point = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
[point, channels, decimalPrecision],
);
const labels = useMemo(
() =>
point
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
: [],
[point, resolvePointLabels],
);
if (!point) {
return null;
}
return (
<div
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
data-pinned={isPinned}
data-testid="scatter-tooltip"
>
<div className={Styles.header}>
<span
className={Styles.marker}
style={{ backgroundColor: point.color, borderColor: point.color }}
/>
<span
className={Styles.title}
title={point.label}
data-testid="scatter-tooltip-title"
>
{point.label}
</span>
{isPinned && (
<span className={Styles.status} data-testid="scatter-tooltip-status">
<Pin size={12} />
<span>Pinned</span>
</span>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<span className={Styles.rowLabel}>{row.label}</span>
<span className={Styles.rowValue}>{row.value}</span>
</div>
))}
</div>
{labels.length > 0 && (
<>
<span className={Styles.divider} />
<div className={Styles.rows}>
{labels.map((label) => (
<div
key={label.key}
className={cx(Styles.row, Styles.rowMuted)}
data-testid="scatter-tooltip-label"
>
<span className={Styles.rowLabel} title={label.key}>
{label.key}
</span>
<span className={Styles.rowValue} title={label.value}>
{label.value}
</span>
</div>
))}
</div>
</>
)}
{renderTooltipFooter?.({ isPinned, dismiss })}
</div>
);
}

View File

@@ -1,107 +0,0 @@
import type uPlot from 'uplot';
import {
buildChannelRows,
resolveHoveredPoint,
ScatterHoveredPoint,
} from '../scatterTooltipContent';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
`${value} ${unit ?? ''}`.trim(),
),
}));
const plot = {
data: [
null,
[
[10, 20],
[100, 200],
[5, null],
],
[[30], [300]],
],
series: [
{},
{ label: 'cart', stroke: '#ff0000' },
{ label: 'checkout', stroke: (): string => '#00ff00' },
],
} as unknown as uPlot;
describe('resolveHoveredPoint', () => {
it('reads the focused series at its own data index', () => {
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
seriesIndex: 1,
dataIndex: 1,
label: 'cart',
color: '#ff0000',
x: 20,
y: 200,
size: null,
});
});
it('carries the size column when present and resolves function strokes', () => {
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
label: 'checkout',
color: '#00ff00',
size: null,
});
});
it('is null without a focused series or an index for it', () => {
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
});
});
describe('buildChannelRows', () => {
const point: ScatterHoveredPoint = {
seriesIndex: 1,
dataIndex: 0,
label: 'cart',
color: '#f00',
x: 12,
y: 340,
size: 7,
};
it('formats x and y with their own units', () => {
const rows = buildChannelRows(point, {
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99', unit: 'ms' },
});
expect(rows).toStrictEqual([
{ label: 'Throughput', value: '12 reqps' },
{ label: 'p99', value: '340 ms' },
]);
});
it('adds the size row only when the channel is mapped and the point has one', () => {
const channels = {
x: { label: 'x' },
y: { label: 'y' },
size: { label: 'Errors' },
};
expect(buildChannelRows(point, channels)).toHaveLength(3);
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
});
it('leaves out a size row that repeats the x row', () => {
const rows = buildChannelRows(
{ ...point, size: point.x },
{
x: { label: 'A.count()' },
y: { label: 'p99' },
size: { label: 'A.count()' },
},
);
expect(rows.map((row) => row.label)).toStrictEqual(['A.count()', 'p99']);
});
});

View File

@@ -1,98 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import type uPlot from 'uplot';
import type {
ScatterChannel,
ScatterChannels,
ScatterSeriesData,
} from '../../plugins/ScatterPlugin/types';
import { resolveSeriesColor } from './utils';
export interface ScatterHoveredPoint {
seriesIndex: number;
dataIndex: number;
label: string;
color: string;
x: number;
y: number;
size: number | null;
}
export interface ScatterTooltipRow {
label: string;
value: string;
}
/** The point the cursor resolved to: the focused series' own index into its columns. */
export function resolveHoveredPoint(
u: uPlot,
seriesIndex: number | null,
dataIndexes: Array<number | null>,
): ScatterHoveredPoint | null {
if (seriesIndex == null || seriesIndex < 1) {
return null;
}
const dataIndex = dataIndexes[seriesIndex];
const series = u.series[seriesIndex];
const columns = u.data[seriesIndex] as unknown as
| ScatterSeriesData
| undefined;
if (dataIndex == null || !series || !columns) {
return null;
}
const x = columns[0][dataIndex];
const y = columns[1][dataIndex];
if (x == null || y == null) {
return null;
}
return {
seriesIndex,
dataIndex,
label: String(series.label ?? ''),
color: resolveSeriesColor(series.stroke, u, seriesIndex),
x,
y,
size: columns[2]?.[dataIndex] ?? null,
};
}
function formatChannel(
value: number,
channel: ScatterChannel,
decimalPrecision?: PrecisionOption,
): string {
return getToolTipValue(value, channel.unit, decimalPrecision);
}
export function buildChannelRows(
point: ScatterHoveredPoint,
channels: ScatterChannels,
decimalPrecision?: PrecisionOption,
): ScatterTooltipRow[] {
const rows: ScatterTooltipRow[] = [
{
label: channels.x.label,
value: formatChannel(point.x, channels.x, decimalPrecision),
},
{
label: channels.y.label,
value: formatChannel(point.y, channels.y, decimalPrecision),
},
];
if (channels.size && point.size != null) {
const sizeRow = {
label: channels.size.label,
value: formatChannel(point.size, channels.size, decimalPrecision),
};
// Size bound to the X or Y column would only repeat that row.
if (
!rows.some(
(row) => row.label === sizeRow.label && row.value === sizeRow.value,
)
) {
rows.push(sizeRow);
}
}
return rows;
}

View File

@@ -6,10 +6,6 @@ import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { LegendItem } from '../config/types';
import { HeatmapSeries, HeatmapYAxis } from '../plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from '../plugins/ScatterPlugin/types';
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
/**
@@ -123,17 +119,6 @@ export interface HeatmapTooltipProps
groupColor: string;
}
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointLabels?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -39,16 +39,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** The axis line reads as the outermost grid line, so it takes the grid's style. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -68,49 +58,32 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
}
/**
* Build values formatter for X-axis: time, or a value axis when a unit or
* precision is given (scatter). Neither leaves uPlot's numeric default.
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
const { isTimeAxis } = this.props;
if (isTimeAxis) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
return this.buildValueAxisFormatter();
}
return undefined;
}
/**
* Build values formatter for a value axis (values with units). A split outside
* the scale's range gets no label: uPlot's arcsinh splits always include
* ±threshold, and it would draw that label past the plot's edge.
* Build values formatter for Y-axis (values with units)
*/
private buildValueAxisFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
return (u, t): string[] => {
const scale = u?.scales?.[scaleKey];
const min = scale?.min ?? -Infinity;
const max = scale?.max ?? Infinity;
return t.map((v) => {
if (
v === null ||
v === undefined ||
Number.isNaN(v) ||
v < min ||
v > max
) {
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
};
}
/**
@@ -128,7 +101,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildValueAxisFormatter()
? this.buildYAxisValuesFormatter()
: undefined;
}
@@ -176,12 +149,10 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
side = 2, // bottom by default
space,
splits,
filter,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const border = this.buildBorderConfig(grid);
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
@@ -206,9 +177,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (ticks) {
axisConfig.ticks = ticks;
}
if (border) {
axisConfig.border = border;
}
if (values) {
axisConfig.values = values;
}
@@ -221,9 +189,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
if (space !== undefined) {
axisConfig.space = space;
}
if (filter) {
axisConfig.filter = filter;
}
if (size) {
axisConfig.size = size;
}

View File

@@ -19,7 +19,6 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -66,8 +65,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -163,15 +160,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
setMode(mode: PlotMode): void {
this.mode = mode;
}
getMode(): PlotMode {
return this.mode;
}
/**
* Add or merge a scale configuration
*/
@@ -524,10 +512,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
{} as Record<string, uPlot.Scale>,
);
if (this.mode === PlotMode.Faceted) {
config.mode = this.mode as number as uPlot.Mode;
}
config.hooks = this.hooks;
config.select = this.select;

View File

@@ -99,7 +99,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold: this.props.asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -87,8 +87,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
lineConfig.fill = finalFillColor;
} else if (this.props.drawStyle === DrawStyle.Histogram) {
lineConfig.fill = `${finalFillColor}40`;
} else if (this.props.drawStyle === DrawStyle.Scatter) {
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
} else if (fillMode && fillMode !== FillMode.None) {
const resolvedOpacity = resolveFillOpacity(fillOpacity);
if (fillMode === FillMode.Solid) {
@@ -124,8 +122,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
// Scatter without a `pathBuilder` has nothing to draw its discs with.
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
if (drawStyle === DrawStyle.Points) {
return { paths: (): null => null };
}
@@ -197,10 +194,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
if (drawStyle === DrawStyle.Points) {
return true;
}
// The discs are the series path; uPlot's own points would double-draw them.
if (drawStyle === DrawStyle.Scatter) {
return false;
}
return !!showPoints;
}
@@ -225,7 +218,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
const resolvedLineColor = this.getLineColor();
@@ -253,7 +246,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(facets && { facets }),
};
}
}

View File

@@ -120,35 +120,6 @@ describe('UPlotAxisBuilder', () => {
});
});
it('draws the axis line by default, styled like the grid', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ isDarkMode: true, grid: { width: 0.5 } }),
).getConfig();
expect(config.border).toStrictEqual({
show: true,
stroke: config.grid?.stroke,
width: 0.5,
});
});
it('leaves the axis line off when showBorder is false', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ showBorder: false })).getConfig(),
).not.toHaveProperty('border');
});
it('passes explicit splits and a label filter through to uPlot', () => {
const splits = jest.fn();
const filter = jest.fn();
const config = new UPlotAxisBuilder(
createAxisProps({ splits, filter }),
).getConfig();
expect(config.splits).toBe(splits);
expect(config.filter).toBe(filter);
});
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
const customTicks = { width: 1, show: false };
const withTicks = new UPlotAxisBuilder(
@@ -405,51 +376,3 @@ describe('UPlotAxisBuilder', () => {
expect(config.values).toBeDefined();
});
});
describe('UPlotAxisBuilder value x axis', () => {
it('formats a non-time x axis with its unit', () => {
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
).getConfig();
const values = (config.values as uPlot.Axis.DynamicValues)(
{} as uPlot,
[1200],
0,
0,
0,
);
expect(values).toStrictEqual(['1.2K req/s']);
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
});
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
).getConfig();
expect(config.values).toBeUndefined();
});
});
describe('UPlotAxisBuilder out-of-range splits', () => {
it('leaves a split the scale cannot place unlabelled', () => {
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
const config = new UPlotAxisBuilder(
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
).getConfig();
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
const values = (config.values as uPlot.Axis.DynamicValues)(
u,
[-10, 0, 500, 5000],
0,
0,
0,
);
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
});
});

View File

@@ -5,12 +5,7 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../../constants';
import type { SeriesProps } from '../types';
import {
DrawStyle,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from '../types';
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
// Mock only the real boundary that hits localStorage
@@ -656,15 +651,3 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
});
describe('UPlotConfigBuilder plot mode', () => {
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
expect(aligned.getConfig().mode).toBeUndefined();
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
faceted.setMode(PlotMode.Faceted);
expect(faceted.getMode()).toBe(PlotMode.Faceted);
expect(faceted.getConfig().mode).toBe(2);
});
});

View File

@@ -399,39 +399,3 @@ describe('UPlotSeriesBuilder', () => {
expect(builder.getConfig().fill).toBeUndefined();
});
});
describe('UPlotSeriesBuilder scatter', () => {
it('draws through the given path builder and hides uPlot points', () => {
const pathBuilder = jest.fn();
const config = new UPlotSeriesBuilder(
createBaseProps({
drawStyle: DrawStyle.Scatter,
pathBuilder,
facets: [{ scale: 'x' }, { scale: 'y' }],
lineColor: '#ff0000',
fillOpacity: 0.5,
lineWidth: 1,
pointSize: 8,
}),
).getConfig();
expect(config.paths).toBe(pathBuilder);
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
expect(config.points?.show).toBe(false);
expect(config.points?.size).toBe(8);
expect(config.stroke).toBe('#ff0000');
expect(config.width).toBe(1);
expect(config.fill).toBe('#ff000080');
});
it('draws nothing without a path builder', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Scatter }),
).getConfig();
expect(
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
).toBeNull();
expect(config.facets).toBeUndefined();
});
});

View File

@@ -78,12 +78,8 @@ export interface AxisProps {
};
/** Explicit tick formatter, replacing the scale's default (time / unit-formatted). */
values?: uPlot.Axis.Values;
/** Explicit tick positions, replacing uPlot's per-distribution default. */
/** Explicit axis splits, overriding the default tick calculation. */
splits?: uPlot.Axis.Splits;
/** Which splits get a label, replacing uPlot's per-distribution default. */
filter?: uPlot.Axis.Filter;
/** Draw the axis line along the plot edge, styled like the grid. Default true. */
showBorder?: boolean;
/** 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. */
@@ -94,8 +90,7 @@ export interface AxisProps {
isDarkMode?: boolean;
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
isLogScale?: boolean;
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
yAxisUnit?: string;
/**
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
@@ -114,15 +109,6 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
* negatives, which a plain log cannot place. */
SymmetricLog = 'symlog',
}
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -139,8 +125,6 @@ export interface ScaleProps {
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
asinhThreshold?: number;
}
export enum DisconnectedValuesMode {
@@ -162,8 +146,6 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -247,8 +229,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -1,114 +0,0 @@
import type uPlot from 'uplot';
import { Quadtree } from '../../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
} from '../geometry';
import { ScatterHit, ScatterPointSize } from '../types';
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
const asData = (columns: unknown[]): uPlot.AlignedData =>
columns as unknown as uPlot.AlignedData;
describe('resolveSizeDomain', () => {
it('spans the size columns of every series, skipping nulls', () => {
const data = asData([
null,
[
[1, 2],
[1, 2],
[10, null],
],
[[3], [3], [40]],
]);
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
});
it('is null when no series carries sizes', () => {
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
});
});
describe('resolvePointDiameter', () => {
it('uses the fixed diameter without a size or a domain', () => {
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
});
it('maps the domain ends to min and max', () => {
const domain = { min: 0, max: 100 };
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
});
it('scales by area, not diameter', () => {
const midArea = (4 ** 2 + 20 ** 2) / 2;
expect(
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
).toBeCloseTo(Math.sqrt(midArea));
});
it('clamps values outside the domain', () => {
const domain = { min: 10, max: 20 };
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
});
it('uses the midpoint when every size is the same', () => {
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
});
});
describe('resolveHit', () => {
const hit = (
seriesIndex: number,
dataIndex: number,
x: number,
y: number,
d: number,
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
it('returns the disc under the cursor', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
tree.add(hit(2, 3, 50, 50, 6));
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
seriesIndex: 1,
dataIndex: 0,
});
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
seriesIndex: 2,
dataIndex: 3,
});
});
it('is null when the cursor is off every disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
});
it('tolerance widens each disc', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 6));
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
});
it('prefers the disc whose centre is nearest when they overlap', () => {
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
tree.add(hit(1, 0, 10, 10, 10));
tree.add(hit(1, 1, 14, 10, 10));
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
});
});

View File

@@ -1,288 +0,0 @@
import uPlot from 'uplot';
import { PlotMode } from '../../../config/types';
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from '../scatterPlugin';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
class FakePath2D {
moveTo = jest.fn();
arc = jest.fn();
}
type OrientCallback = Parameters<typeof uPlot.orient>[2];
interface FakePlotArgs {
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
cursor?: { left: number; top: number };
scaleX?: { min: number; max: number };
scaleY?: { min: number; max: number };
}
/**
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
*/
function createFakePlot({
series,
cursor = { left: -1, top: -1 },
scaleX = { min: 0, max: 100 },
scaleY = { min: 0, max: 100 },
}: FakePlotArgs): uPlot {
const data = [
null,
...series.map((entry) =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return {
data,
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
bbox: { left: 0, top: 0, width: 100, height: 100 },
cursor,
scales: { x: scaleX, y: scaleY },
} as unknown as uPlot;
}
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
function orientWithIdentityScales(
u: uPlot,
seriesIdx: number,
cb: OrientCallback,
): void {
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
if (!columns) {
return;
}
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
const valToPosX = (value: number): number => value;
const valToPosY = (value: number): number => 100 - value;
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
const arc = (path: FakePath2D, ...args: number[]): void => {
path.arc(...args);
};
cb(
u.series[seriesIdx],
columns[0],
columns[1],
scaleX,
scaleY,
valToPosX as unknown as uPlot.ValToPos,
valToPosY as unknown as uPlot.ValToPos,
0,
0,
100,
100,
jest.fn() as never,
jest.fn() as never,
jest.fn() as never,
arc as never,
jest.fn() as never,
);
}
describe('createScatterPlugin', () => {
beforeAll(() => {
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
});
beforeEach(() => {
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
(uPlot.orient as jest.Mock).mockReset();
});
function drawAll(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): void {
plugin.hooks.drawClear(u);
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
const columns = (u.data as unknown as number[][][])[seriesIdx];
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
}
}
/** Runs the cursor scan the way uPlot does: every data series, in order. */
function scan(
u: uPlot,
plugin: ReturnType<typeof createScatterPlugin>,
): Array<number | null> {
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
const indexes: Array<number | null> = [null];
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
indexes.push(dataIdx(u, seriesIdx, 0, 0));
}
return indexes;
}
it('returns one path that strokes and fills the same discs', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
plugin.hooks.drawClear(u);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect(paths.stroke).toBeInstanceOf(FakePath2D);
expect(paths.fill).toBe(paths.stroke);
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
});
it('resolves the hovered point to its own series and index', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 6, min: 4, max: 20 },
});
const u = createFakePlot({
series: [
{ xs: [10, 50], ys: [10, 50] },
{ xs: [80], ys: [80] },
],
// Over the second series' only point: x 80, y drawn at 100 − 80.
cursor: { left: 80, top: 20 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
});
it('returns null for every series when the cursor is off the plot or off any disc', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: -1, top: -1 },
});
drawAll(u, plugin);
expect(scan(u, plugin)).toStrictEqual([null, null]);
(u.cursor as { left: number; top: number }).left = 50;
(u.cursor as { left: number; top: number }).top = 50;
expect(scan(u, plugin)).toStrictEqual([null, null]);
});
it('skips points outside the visible scale range', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({
series: [{ xs: [10, 500], ys: [10, 10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
expect(scan(u, plugin)).toStrictEqual([null, 0]);
});
it('rings the hit disc, in CSS pixels, with a gap around it', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 8, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
const bbox = plugin.cursor.points?.bbox;
// The 8px disc spans 6..14; the ring sits 3px out and is 2px wide.
expect(bbox?.(u, 1)).toStrictEqual({
left: 1,
top: 81,
width: 18,
height: 18,
});
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
});
it('snaps the ring to whole pixels without moving its centre', () => {
const plugin = createScatterPlugin({
pointSize: { fixed: 7, min: 4, max: 20 },
});
const u = createFakePlot({
series: [{ xs: [10], ys: [10] }],
cursor: { left: 10, top: 90 },
});
drawAll(u, plugin);
scan(u, plugin);
// The 7px disc starts at 6.5, so the ring's edge would land on 1.5.
const ring = plugin.cursor.points?.bbox?.(u, 1);
expect(ring).toMatchObject({ left: 1, width: 18 });
expect((ring?.left ?? 0) + (ring?.width ?? 0) / 2).toBe(10);
});
it('draws the hover marker as an outline in the series colour', () => {
const plugin = createScatterPlugin();
const u = {
series: [{}, { stroke: (): string => '#E5484D' }, { stroke: '#30A46C' }],
} as unknown as uPlot;
const points = plugin.cursor.points as {
fill: (u: uPlot, seriesIdx: number) => string;
stroke: (u: uPlot, seriesIdx: number) => string;
width: (u: uPlot, seriesIdx: number, size: number) => number;
};
expect(points.fill(u, 1)).toBe('transparent');
expect(points.stroke(u, 1)).toBe('#E5484D');
expect(points.stroke(u, 2)).toBe('#30A46C');
expect(points.width(u, 1, 0)).toBe(2);
});
it('drawClear drops cached paths on data series only', () => {
const plugin = createScatterPlugin();
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
xSeries._paths = 'x';
dataSeries._paths = 'cached';
plugin.hooks.drawClear(u);
expect(xSeries._paths).toBe('x');
expect(dataSeries._paths).toBeNull();
});
it('focus distance is zero, so the hit series wins focus', () => {
const plugin = createScatterPlugin();
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
});
});
describe('applyScatterPlugin', () => {
it('switches the builder to faceted mode and disables drag selection', () => {
const builder = new UPlotConfigBuilder({ id: 'scatter' });
const plugin = createScatterPlugin();
applyScatterPlugin(builder, plugin);
const config = builder.getConfig();
expect(builder.getMode()).toBe(PlotMode.Faceted);
expect(config.mode).toBe(2);
expect(config.cursor?.drag).toMatchObject({
x: false,
y: false,
setScale: false,
});
expect(config.hooks?.drawClear).toHaveLength(1);
expect(config.hooks?.destroy).toHaveLength(1);
});
it('facets read x and y against the shared scales', () => {
expect(SCATTER_FACETS).toStrictEqual([
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
]);
});
});

View File

@@ -1,96 +0,0 @@
import type uPlot from 'uplot';
import type { Quadtree } from '../../utils/quadtree';
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
export interface SizeDomain {
min: number;
max: number;
}
/**
* Extent of the size column across every series, so equal values draw equal
* discs whichever group they belong to. `null` when nothing carries a size.
*/
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
let min = Infinity;
let max = -Infinity;
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
if (!sizes) {
continue;
}
for (const size of sizes) {
if (size == null || !Number.isFinite(size)) {
continue;
}
min = Math.min(min, size);
max = Math.max(max, size);
}
}
return min <= max ? { min, max } : null;
}
/**
* Disc diameter for a size value. Area, not diameter, follows the value: a
* point worth twice as much should look twice as big.
*/
export function resolvePointDiameter(
size: number | null | undefined,
domain: SizeDomain | null,
pointSize: ScatterPointSize,
): number {
if (size == null || domain == null || !Number.isFinite(size)) {
return pointSize.fixed;
}
if (domain.max === domain.min) {
return (pointSize.min + pointSize.max) / 2;
}
const t = Math.min(
1,
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
);
const minArea = pointSize.min ** 2;
const maxArea = pointSize.max ** 2;
return Math.sqrt(minArea + (maxArea - minArea) * t);
}
/**
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
* whose centre is closest; `tolerance` widens every disc so thin points stay
* hoverable.
*/
export function resolveHit(
tree: Quadtree<ScatterHit>,
cx: number,
cy: number,
tolerance: number,
): ScatterHit | null {
let best: ScatterHit | null = null;
let bestDistance = Infinity;
tree.get(
cx - tolerance,
cy - tolerance,
tolerance * 2,
tolerance * 2,
(hit) => {
const left = hit.x - tolerance;
const top = hit.y - tolerance;
const right = hit.x + hit.w + tolerance;
const bottom = hit.y + hit.h + tolerance;
if (cx < left || cx > right || cy < top || cy > bottom) {
return;
}
const dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -1,234 +0,0 @@
import uPlot, { Series } from 'uplot';
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
import { PlotMode } from '../../config/types';
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import { Quadtree } from '../../utils/quadtree';
import {
resolveHit,
resolvePointDiameter,
resolveSizeDomain,
SizeDomain,
} from './geometry';
import {
DEFAULT_HOVER_TOLERANCE_PX,
DEFAULT_SCATTER_POINT_SIZE,
ScatterHit,
ScatterPluginOptions,
ScatterSeriesData,
} from './types';
/** Every scatter series reads its own x and y columns against the shared scales. */
export const SCATTER_FACETS: Series.Facet[] = [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
];
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
const TWO_PI = 2 * Math.PI;
/** CSS pixels between a hovered disc and the outline ring drawn around it. */
const HOVER_RING_GAP_PX = 3;
const HOVER_RING_WIDTH_PX = 2;
/**
* uPlot `ceil`s a cursor point's left/top but not its size, which pulls a ring
* up to 1px right and down of the disc. Snapping the edge to a whole pixel and
* growing the span by twice the snap keeps the centre exact.
*/
function snapToPixel(start: number, length: number): [number, number] {
const snapped = Math.floor(start);
return [snapped, length + (start - snapped) * 2];
}
export function getSeriesStroke(u: uPlot, seriesIdx: number): string {
const { stroke } = u.series[seriesIdx];
const color = typeof stroke === 'function' ? stroke(u, seriesIdx) : stroke;
return typeof color === 'string' ? color : '';
}
/** uPlot caches built paths on the series; the field is internal to it. */
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
export interface ScatterPlugin {
/** Draws every point of a series as one path and indexes the discs for hover. */
pathBuilder: Series.PathBuilder;
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
cursor: uPlot.Cursor;
hooks: {
drawClear: (u: uPlot) => void;
destroy: (u: uPlot) => void;
};
getHit: () => ScatterHit | null;
}
export function createScatterPlugin({
pointSize = DEFAULT_SCATTER_POINT_SIZE,
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
}: ScatterPluginOptions = {}): ScatterPlugin {
let tree: Quadtree<ScatterHit> | null = null;
let hit: ScatterHit | null = null;
// The domain spans every series, so it is resolved once per dataset rather than
// once per series path.
let cachedData: uPlot.AlignedData | null = null;
let cachedDomain: SizeDomain | null = null;
function getSizeDomain(u: uPlot): SizeDomain | null {
if (cachedData !== u.data) {
cachedDomain = resolveSizeDomain(u.data);
cachedData = u.data;
}
return cachedDomain;
}
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
const domain = getSizeDomain(u);
const { pxRatio } = uPlot;
uPlot.orient(
u,
seriesIdx,
(
_series,
dataX,
dataY,
scaleX,
scaleY,
valToPosX,
valToPosY,
xOff,
yOff,
xDim,
yDim,
_moveTo,
_lineTo,
_rect,
arc,
) => {
const xMin = scaleX.min ?? -Infinity;
const xMax = scaleX.max ?? Infinity;
const yMin = scaleY.min ?? -Infinity;
const yMax = scaleY.max ?? Infinity;
for (let i = idx0; i <= idx1; i++) {
const x = dataX[i];
const y = dataY[i];
if (
x == null ||
y == null ||
x < xMin ||
x > xMax ||
y < yMin ||
y > yMax
) {
continue;
}
const diameter =
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
const radius = diameter / 2;
const cx = valToPosX(x, scaleX, xDim, xOff);
const cy = valToPosY(y, scaleY, yDim, yOff);
path.moveTo(cx + radius, cy);
arc(path, cx, cy, radius, 0, TWO_PI);
tree?.add({
x: cx - radius - u.bbox.left,
y: cy - radius - u.bbox.top,
w: diameter,
h: diameter,
seriesIndex: seriesIdx,
dataIndex: i,
});
}
},
);
return { stroke: path, fill: path, clip: null };
};
const cursor: uPlot.Cursor = {
// Selection would set the dashboard time range; neither axis is time here.
drag: { x: false, y: false, setScale: false },
dataIdx: (u, seriesIdx): number | null => {
// uPlot asks series 1..n in order on every cursor move; resolve once.
if (seriesIdx === 1) {
const { left = -1, top = -1 } = u.cursor;
const { pxRatio } = uPlot;
hit =
tree && left >= 0 && top >= 0
? resolveHit(
tree,
left * pxRatio,
top * pxRatio,
hoverTolerance * pxRatio,
)
: null;
}
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
},
points: {
bbox: (_u, seriesIdx): uPlot.BBox => {
if (hit?.seriesIndex !== seriesIdx) {
return HIDDEN_BBOX;
}
const { pxRatio } = uPlot;
const inset = HOVER_RING_GAP_PX + HOVER_RING_WIDTH_PX;
const [left, width] = snapToPixel(
hit.x / pxRatio - inset,
hit.w / pxRatio + inset * 2,
);
const [top, height] = snapToPixel(
hit.y / pxRatio - inset,
hit.h / pxRatio + inset * 2,
);
return { left, top, width, height };
},
// An outline around the disc, so the point itself stays readable.
fill: (): string => 'transparent',
stroke: getSeriesStroke,
width: (): number => HOVER_RING_WIDTH_PX,
},
// uPlot only measures series that returned a data index, i.e. the hit one.
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
};
return {
pathBuilder,
cursor,
hooks: {
drawClear: (u: uPlot): void => {
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
// The tree only knows what the path builder last drew, so cached paths
// must be rebuilt alongside it.
u.series.forEach((series, index) => {
if (index > 0) {
(series as SeriesWithPaths)._paths = null;
}
});
},
destroy: (): void => {
tree = null;
hit = null;
cachedData = null;
cachedDomain = null;
},
},
getHit: (): ScatterHit | null => hit,
};
}
export function applyScatterPlugin(
builder: UPlotConfigBuilder,
plugin: ScatterPlugin,
): void {
builder.setMode(PlotMode.Faceted);
builder.setCursor(plugin.cursor);
builder.addHook('drawClear', plugin.hooks.drawClear);
builder.addHook('destroy', plugin.hooks.destroy);
}

View File

@@ -1,58 +0,0 @@
import type { QuadtreeRect } from '../../utils/quadtree';
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
export interface ScatterPointSize {
fixed: number;
min: number;
max: number;
}
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
fixed: 6,
min: 4,
max: 24,
};
/** CSS pixels around a point's disc that still register as a hover. */
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
export interface ScatterPluginOptions {
pointSize?: ScatterPointSize;
hoverTolerance?: number;
}
/**
* One faceted series: parallel columns, one point per index. Sizes are in the
* caller's units and mapped to `pointSize` at draw time; `null` draws at `fixed`.
*/
export type ScatterSeriesData = [
xs: number[],
ys: number[],
sizes?: Array<number | null>,
];
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
export type ScatterChartData = [null, ...ScatterSeriesData[]];
/** A drawn point's disc, in canvas pixels relative to the plot area. */
export interface ScatterHit extends QuadtreeRect {
seriesIndex: number;
dataIndex: number;
}
export interface ScatterChannel {
label: string;
unit?: string;
}
/** What each visual channel plots, for the tooltip and axes. */
export interface ScatterChannels {
x: ScatterChannel;
y: ScatterChannel;
size?: ScatterChannel;
}
export interface ScatterPointLabel {
key: string;
value: string;
}

View File

@@ -1,52 +0,0 @@
import type uPlot from 'uplot';
import { buildClickData } from '../utils';
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => ({ seriesIndex: 1 })),
}));
const event = {
offsetX: 10,
offsetY: 20,
clientX: 110,
clientY: 220,
} as MouseEvent;
describe('buildClickData', () => {
it('skips the x-index lookups on a faceted plot, which has no shared x series', () => {
const posToIdx = jest.fn(() => {
throw new Error('faceted data[0] is null');
});
const plot = {
data: [null],
posToVal: (pos: number): number => pos * 2,
posToIdx,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toStrictEqual({
xValue: 20,
yValue: 40,
focusedSeries: null,
clickedDataTimestamp: 20,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
});
expect(posToIdx).not.toHaveBeenCalled();
});
it('resolves the focused series and clicked timestamp on an aligned plot', () => {
const plot = {
data: [[100, 200, 300]],
posToVal: (pos: number): number => pos,
posToIdx: (): number => 1,
} as unknown as uPlot;
expect(buildClickData(event, plot)).toMatchObject({
focusedSeries: { seriesIndex: 1 },
clickedDataTimestamp: 200,
});
});
});

View File

@@ -174,25 +174,6 @@ export function createLayoutObserver(
export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const pointer = {
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
// A faceted plot (scatter) has no shared x series, so `data[0]` is null and
// `posToIdx` would throw; the hit is the plot's own to resolve.
if (plot.data[0] == null) {
return {
xValue,
yValue,
focusedSeries: null,
clickedDataTimestamp: xValue,
...pointer,
};
}
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
@@ -212,6 +193,9 @@ export function buildClickData(event: MouseEvent, plot: uPlot): ChartClickData {
yValue,
focusedSeries,
clickedDataTimestamp,
...pointer,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
}

View File

@@ -1,59 +0,0 @@
import type uPlot from 'uplot';
import { logScaleSplits, spacedLogLabels } from '../logGridSplits';
describe('logScaleSplits', () => {
const splitsFor = (min: number, max: number): number[] =>
(
logScaleSplits as (
u: uPlot,
axisIdx: number,
min: number,
max: number,
) => number[]
)({} as uPlot, 0, min, max);
it('splits at 1, 2 and 5 × 10ⁿ inside an unsnapped range', () => {
expect(splitsFor(10.6, 990)).toStrictEqual([20, 50, 100, 200, 500]);
});
it('includes the ends when they fall on a split', () => {
expect(splitsFor(0.1, 10)).toStrictEqual([0.1, 0.2, 0.5, 1, 2, 5, 10]);
});
it('is empty for a range a log scale cannot hold', () => {
expect(splitsFor(0, 10)).toStrictEqual([]);
expect(splitsFor(5, 5)).toStrictEqual([]);
});
});
describe('spacedLogLabels', () => {
// 100px per decade.
const plot = {
axes: [{ scale: 'x' }],
valToPos: (value: number): number => Math.log10(value) * 100,
} as unknown as uPlot;
const label = (splits: number[], space: number): (number | null)[] =>
spacedLogLabels(plot, splits, 0, space, 0) as (number | null)[];
it('always labels the powers of ten', () => {
expect(label([10, 20, 50, 100], 200)).toStrictEqual([10, null, null, 100]);
});
it('adds a 2 or 5 that clears the labels already kept', () => {
// 20 is 30px from 10; 50 is 70px from 10 and 30px from 100.
expect(label([10, 20, 50, 100], 25)).toStrictEqual([10, 20, 50, 100]);
expect(label([10, 20, 50, 100], 40)).toStrictEqual([10, null, null, 100]);
});
it('labels both ends of a range spanning under a decade', () => {
// 100 sits at 200px; 20 (130px) and 500 (270px) clear it, 50 and 200 do not.
expect(label([20, 50, 100, 200, 500], 60)).toStrictEqual([
20,
null,
100,
null,
500,
]);
});
});

View File

@@ -1,70 +0,0 @@
import { Quadtree, QuadtreeRect } from '../quadtree';
interface Item extends QuadtreeRect {
id: number;
}
function collect(
tree: Quadtree<Item>,
x: number,
y: number,
w: number,
h: number,
): Set<number> {
const ids = new Set<number>();
tree.get(x, y, w, h, (item) => ids.add(item.id));
return ids;
}
describe('Quadtree', () => {
it('returns items in the queried region and not those far from it', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
// Below the split threshold every item is visited; callers refine the hit.
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
});
it('splits past the object limit and still finds every item', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
const total = 50;
for (let id = 0; id < total; id++) {
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
}
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
});
it('after a split, a query in one quadrant skips items confined to another', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
// All in the north-west quadrant.
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
}
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
const northWest = collect(tree, 0, 0, 10, 10);
expect(northWest.has(99)).toBe(false);
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
});
it('reports an item straddling the midline from either side', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
for (let id = 0; id < 20; id++) {
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
}
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
});
it('clear empties the tree', () => {
const tree = new Quadtree<Item>(0, 0, 100, 100);
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
tree.clear();
expect(collect(tree, 0, 0, 100, 100).size).toBe(0);
});
});

View File

@@ -195,40 +195,3 @@ describe('scale utils', () => {
});
});
});
describe('symmetric log scale', () => {
it('maps to uPlot arcsinh with the given linear threshold', () => {
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
}),
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
expect(
scaleUtils.getDistributionConfig({
time: false,
distr: DistributionType.SymmetricLog,
}).asinh,
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
});
it('ranges a distr 4 scale through uPlot.rangeAsinh', () => {
const rangeAsinh = jest.fn(() => [-10, 1000] as uPlot.Range.MinMax);
Object.assign(uPlot, { rangeAsinh });
const rangeFn = scaleUtils.createRangeFunction({
rangeConfig: {} as uPlot.Range.Config,
hardMinOnly: false,
hardMaxOnly: false,
hasFixedRange: false,
min: null,
max: null,
});
const u = { scales: { y: { distr: 4, log: 10 } } } as unknown as uPlot;
expect(rangeFn(u, -3, 700, 'y')).toStrictEqual([-10, 1000]);
expect(rangeAsinh).toHaveBeenCalledWith(-3, 700, 10, true);
});
});

View File

@@ -1,69 +0,0 @@
import type uPlot from 'uplot';
const DECADE_TOLERANCE = 1e-9;
function isDecade(value: number): boolean {
if (value === 0) {
return true;
}
const exponent = Math.log10(Math.abs(value));
return Math.abs(exponent - Math.round(exponent)) < DECADE_TOLERANCE;
}
const LOG_SPLIT_MULTIPLES = [1, 2, 5];
/**
* Splits at 1, 2 and 5 × 10ⁿ inside the scale. uPlot's own log splits step
* from the scale's minimum, so a range not snapped to a power of ten would
* label 10.79, 21.58, … instead.
*/
export const logScaleSplits: uPlot.Axis.Splits = (_u, _axisIdx, min, max) => {
if (!(min > 0) || !(max > min)) {
return [];
}
const splits: number[] = [];
for (
let exponent = Math.floor(Math.log10(min));
exponent <= Math.ceil(Math.log10(max));
exponent++
) {
LOG_SPLIT_MULTIPLES.forEach((multiple) => {
// Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max].
const split = Number((multiple * 10 ** exponent).toPrecision(12));
if (split >= min && split <= max) {
splits.push(split);
}
});
}
return splits;
};
/**
* Labels for `logScaleSplits`: every power of ten, then any 2 or 5 that sits
* at least `space` px from the labels already kept, so a range spanning under
* one decade still reads at both ends.
*/
export const spacedLogLabels: uPlot.Axis.Filter = (
u,
splits,
axisIdx,
space,
) => {
const scaleKey = u.axes[axisIdx]?.scale ?? 'x';
const positions = splits.map((split) =>
split == null ? null : u.valToPos(split, scaleKey),
);
const kept = splits.map((split) => split != null && isDecade(split));
const isClear = (index: number): boolean =>
kept.every(
(isKept, other) =>
!isKept ||
Math.abs((positions[other] ?? 0) - (positions[index] ?? 0)) >= space,
);
splits.forEach((split, index) => {
if (split != null && !kept[index] && isClear(index)) {
kept[index] = true;
}
});
return splits.map((split, index) => (kept[index] ? split : null));
};

View File

@@ -1,110 +0,0 @@
export interface QuadtreeRect {
x: number;
y: number;
w: number;
h: number;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
/**
* Spatial index over axis-aligned rectangles, for answering "what is under the
* cursor" on charts whose marks have no shared x order to binary-search. An item
* straddling a quadrant boundary lives in every quadrant it touches, so `get` can
* report it more than once.
*/
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
private items: T[] = [];
private quadrants: Quadtree<T>[] | null = null;
constructor(
private readonly x: number,
private readonly y: number,
private readonly w: number,
private readonly h: number,
private readonly level = 0,
) {}
add(item: T): void {
if (this.quadrants) {
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
return;
}
this.items.push(item);
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
this.split();
const items = this.items;
this.items = [];
for (const existing of items) {
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
}
}
}
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
get(
x: number,
y: number,
w: number,
h: number,
visit: (item: T) => void,
): void {
for (const item of this.items) {
visit(item);
}
if (this.quadrants) {
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
quadrant.get(x, y, w, h, visit),
);
}
}
clear(): void {
this.items = [];
this.quadrants = null;
}
private split(): void {
const w = this.w / 2;
const h = this.h / 2;
const level = this.level + 1;
// North-east, north-west, south-west, south-east.
this.quadrants = [
new Quadtree<T>(this.x + w, this.y, w, h, level),
new Quadtree<T>(this.x, this.y, w, h, level),
new Quadtree<T>(this.x, this.y + h, w, h, level),
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
];
}
private forEachQuadrant(
rect: QuadtreeRect,
visit: (quadrant: Quadtree<T>) => void,
): void {
if (!this.quadrants) {
return;
}
const midX = this.x + this.w / 2;
const midY = this.y + this.h / 2;
const startsNorth = rect.y < midY;
const startsWest = rect.x < midX;
const endsEast = rect.x + rect.w > midX;
const endsSouth = rect.y + rect.h > midY;
if (startsNorth && endsEast) {
visit(this.quadrants[0]);
}
if (startsWest && startsNorth) {
visit(this.quadrants[1]);
}
if (startsWest && endsSouth) {
visit(this.quadrants[2]);
}
if (endsEast && endsSouth) {
visit(this.quadrants[3]);
}
}
}

View File

@@ -58,23 +58,18 @@ function normalizeLogLimit(
return logBase ** exp;
}
export const DEFAULT_ASINH_THRESHOLD = 1;
/**
* Returns uPlot scale distribution options for a value axis.
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
* Returns uPlot scale distribution options for the Y axis.
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
*/
export function getDistributionConfig({
time,
distr,
logBase,
asinhThreshold,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
asinhThreshold?: number;
}): Partial<Scale> {
if (time) {
return {};
@@ -82,14 +77,6 @@ export function getDistributionConfig({
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
if (distr === DistributionType.SymmetricLog) {
return {
distr: 4,
log: resolvedLogBase,
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
};
}
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,
@@ -210,33 +197,6 @@ function getLogScaleRange(
);
}
/**
* Computes the arcsinh-scale range using uPlot.rangeAsinh, which pads to whole
* magnitudes on either side of zero and pins an edge that sits exactly on zero.
*/
function getAsinhScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
logBase?: uPlot.Scale['log'],
): Range.MinMax {
const { min, max } = params;
const resolvedMin = min ?? dataMin;
const resolvedMax = max ?? dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeAsinh(
resolvedMin,
resolvedMax,
(logBase ?? 10) as 2 | 10,
true,
);
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
@@ -339,8 +299,6 @@ export function createRangeFunction(
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
} else if (scale.distr === 4) {
minMax = getAsinhScaleRange(minMax, params, dataMin, dataMax, logBase);
}
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);

View File

@@ -37,7 +37,6 @@ export default function ChartWrapper({
syncKey,
syncFilterMode,
onDestroy = noop,
plotRef,
children,
layoutChildren,
yAxisUnit,
@@ -124,7 +123,6 @@ export default function ChartWrapper({
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;

View File

@@ -1,45 +0,0 @@
import { render } from 'tests/test-utils';
import type uPlot from 'uplot';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import ChartWrapper from '../ChartWrapper';
const FAKE_PLOT = { id: 'plot' } as unknown as uPlot;
jest.mock('lib/uPlotV2/components/UPlotChart/UPlotChart', () => ({
__esModule: true,
default: ({ plotRef }: { plotRef?: (plot: uPlot | null) => void }): null => {
plotRef?.(FAKE_PLOT);
return null;
},
}));
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartWrapper', () => {
it('hands the plot instance to the caller', () => {
const plotRef = jest.fn();
render(
<ChartWrapper
config={new UPlotConfigBuilder({ id: 'chart' })}
data={[[1], [2]]}
width={400}
height={300}
legendConfig={{ position: LegendPosition.BOTTOM }}
showLegend={false}
showTooltip={false}
plotRef={plotRef}
/>,
);
expect(plotRef).toHaveBeenCalledWith(FAKE_PLOT);
});
});

View File

@@ -1,256 +0,0 @@
import { useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import Scatter from './Scatter';
import {
buildScatterConfig,
prepareScatterChartData,
ScatterAxisScale,
ScatterSeries,
} from './utils';
const SERVICES = [
'frontend',
'cart',
'checkout',
'payment',
'shipping',
'currency',
'email',
'recommendation',
'ads',
'product-catalog',
];
type Shape = 'spread' | 'single' | 'sameX';
interface ScatterStoryProps {
groups: number;
pointsPerGroup: number;
/** Adds an error-count size column. */
sized: boolean;
xScale: ScatterAxisScale;
yScale: ScatterAxisScale;
/** Zeroes a share of y values, which forces the symmetric log. */
withZeros: boolean;
shape: Shape;
thresholds: boolean;
pointSize: number;
/** 0–1. */
fillOpacity: number;
width: number;
height: number;
}
/** Deterministic, so a story renders the same points on every run. */
function createRng(seed: number): () => number {
let state = seed >>> 0;
return (): number => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
function buildSeries({
groups,
pointsPerGroup,
sized,
withZeros,
shape,
}: ScatterStoryProps): ScatterSeries[] {
const rng = createRng(42);
return Array.from({ length: groups }, (_, groupIndex) => {
const label = SERVICES[groupIndex % SERVICES.length];
// Each service sits in its own throughput/latency band, so groups are telling
// apart rather than one cloud.
const baseThroughput = 20 * 2 ** (groupIndex % 5);
const baseLatency = 40 + 60 * (groupIndex % 4);
const count = shape === 'single' ? 1 : pointsPerGroup;
const xs: number[] = [];
const ys: number[] = [];
const sizes: Array<number | null> = [];
for (let i = 0; i < count; i++) {
const throughput =
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
let latency =
baseLatency *
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
outlier;
if (withZeros && rng() < 0.2) {
latency = 0;
}
xs.push(Number(throughput.toFixed(2)));
ys.push(Number(latency.toFixed(2)));
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
}
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
});
}
const THRESHOLDS: Threshold[] = [
{
thresholdValue: 300,
thresholdUnit: 'ms',
thresholdColor: '#E5484D',
thresholdLabel: 'p99 SLO',
},
];
function ScatterStory(props: ScatterStoryProps): JSX.Element {
const {
xScale,
yScale,
sized,
thresholds,
pointSize,
fillOpacity,
width,
height,
} = props;
const isDarkMode = useIsDarkMode();
const [drawMs, setDrawMs] = useState<number | null>(null);
const series = useMemo(() => buildSeries(props), [props]);
const pointCount = series.reduce((sum, entry) => sum + entry.xs.length, 0);
const drawLabel = drawMs === null ? '—' : `${drawMs.toFixed(1)} ms`;
const config = useMemo(() => {
const builder = buildScatterConfig({
id: 'scatter-story',
series,
isDarkMode,
x: { unit: 'reqps', scale: xScale },
y: { unit: 'ms', scale: yScale },
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
fillOpacity,
thresholds: thresholds ? THRESHOLDS : undefined,
});
let started = 0;
builder.addHook('drawClear', (): void => {
started = performance.now();
});
builder.addHook('draw', (): void => {
setDrawMs(performance.now() - started);
});
return builder;
}, [series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds]);
const data = useMemo(() => prepareScatterChartData(series), [series]);
const resolvePointLabels = (
seriesIndex: number,
dataIndex: number,
): ScatterPointLabel[] => [
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
{
key: 'k8s.pod.name',
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
},
];
return (
<div style={{ width, padding: 16 }}>
<Scatter
config={config}
data={data}
width={width}
height={height}
legendConfig={{ position: LegendPosition.BOTTOM }}
channels={{
x: { label: 'Throughput', unit: 'reqps' },
y: { label: 'p99 latency', unit: 'ms' },
...(sized && { size: { label: 'Errors', unit: 'short' } }),
}}
resolvePointLabels={resolvePointLabels}
canPinTooltip
/>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, opacity: 0.7 }}>
{`${pointCount.toLocaleString()} points · last draw ${drawLabel}`}
</p>
</div>
);
}
const meta = {
title: 'Charts/Scatter',
component: ScatterStory,
parameters: { layout: 'padded' },
args: {
groups: 1,
pointsPerGroup: 10,
sized: false,
xScale: ScatterAxisScale.Linear,
yScale: ScatterAxisScale.Linear,
withZeros: false,
shape: 'spread',
thresholds: false,
pointSize: 6,
fillOpacity: 0.7,
width: 800,
height: 420,
},
argTypes: {
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
xScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
yScale: { control: 'radio', options: Object.values(ScatterAxisScale) },
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
},
} satisfies Meta<ScatterStoryProps>;
export default meta;
type Story = StoryObj<ScatterStoryProps>;
/** One service, ten points: axes formatted with units, hover picks the right point. */
export const Basic: Story = {};
/** Five services, one legend entry each; toggling a row hides its points. */
export const Grouped: Story = {
args: { groups: 5, pointsPerGroup: 40 },
};
/** Error count as disc area, between the configured min and max diameters. */
export const Sized: Story = {
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
};
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
export const LogAxes: Story = {
args: {
groups: 5,
pointsPerGroup: 60,
xScale: ScatterAxisScale.Log,
yScale: ScatterAxisScale.Log,
withZeros: true,
},
};
/** A single point still gets a padded range rather than an empty plot. */
export const SinglePoint: Story = {
args: { shape: 'single' },
};
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
export const SameX: Story = {
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
};
/** Horizontal line with label on the y axis; the scale stretches to include it. */
export const Thresholds: Story = {
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
};
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
export const Dense: Story = {
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
};

View File

@@ -1,102 +0,0 @@
import { useCallback, useRef } from 'react';
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
import {
ScatterTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import uPlot from 'uplot';
import { ScatterChartProps } from 'lib/visualization/charts/types';
import { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import { getCursorHit } from './utils';
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
// mount; empty aligned data makes the shell show its no-data state instead.
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
export default function Scatter(props: ScatterChartProps): JSX.Element {
const {
children,
customTooltip,
channels,
resolvePointLabels,
pinnedTooltipElement,
onPointClick,
plotRef,
...rest
} = props;
const plotInstanceRef = useRef<uPlot | null>(null);
const handlePlotRef = useCallback(
(plot: uPlot | null): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
},
[plotRef],
);
// The shared click data finds its series by x position, which a faceted plot
// has no single axis for; the hit comes from the cursor instead.
const handleClick = useCallback(
(click: ChartClickData): void => {
const plot = plotInstanceRef.current;
const hit = plot ? getCursorHit(plot) : null;
if (!plot || !hit || !onPointClick) {
return;
}
onPointClick({
...hit,
color: getSeriesStroke(plot, hit.seriesIndex),
coordinates: { x: click.absoluteMouseX, y: click.absoluteMouseY },
});
},
[onPointClick],
);
const renderTooltip = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(args);
}
const tooltipProps: ScatterTooltipProps = {
...args,
id: rest.config.getId(),
channels,
resolvePointLabels,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <ScatterTooltip {...tooltipProps} />;
},
[
customTooltip,
channels,
resolvePointLabels,
rest.config,
rest.decimalPrecision,
rest.canPinTooltip,
rest.renderTooltipFooter,
],
);
const hasSeries = rest.data.length > 1;
return (
<ChartWrapper
{...rest}
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
plotRef={handlePlotRef}
onClick={onPointClick ? handleClick : rest.onClick}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);
}

View File

@@ -1,97 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { createScatterRange, type ScatterRangeOptions } from '../scatterRange';
/** A 220px-tall, 420px-wide plot at a device pixel ratio of 1. */
const plot = { bbox: { width: 420, height: 220 } } as unknown as uPlot;
function rangeOf(
options: Partial<ScatterRangeOptions>,
dataMin: number | null,
dataMax: number | null,
): [number, number] {
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
...options,
}) as (u: uPlot, min: number | null, max: number | null) => [number, number];
return range(plot, dataMin, dataMax);
}
describe('createScatterRange', () => {
beforeAll(() => {
Object.defineProperty(uPlot, 'pxRatio', { value: 1, configurable: true });
});
it('widens a linear range so the data sits marginPx inside each edge', () => {
// 200px of data inside 220px: 10px at each end is 1/20 of the span.
expect(rangeOf({}, 0, 100)).toStrictEqual([-5, 105]);
});
it('measures the margin along its own axis', () => {
// 400px of data inside 420px.
expect(rangeOf({ dimension: 'x' }, 0, 100)).toStrictEqual([-2.5, 102.5]);
});
it('pads a log range in decades, without snapping to the next power of ten', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.Logarithmic },
1,
10,
);
expect(Math.log10(min)).toBeCloseTo(-0.05);
expect(Math.log10(max)).toBeCloseTo(1.05);
});
it('pads a symmetric log range in its asinh space', () => {
const [min, max] = rangeOf(
{ distribution: DistributionType.SymmetricLog, asinhThreshold: 2 },
0,
20,
);
const span = Math.asinh(10);
expect(Math.asinh(min / 2)).toBeCloseTo(-span / 20);
expect(Math.asinh(max / 2)).toBeCloseTo(span + span / 20);
});
it('stretches to the soft limits before padding', () => {
expect(rangeOf({ softMin: 0, softMax: 200 }, 50, 100)).toStrictEqual([
-10, 210,
]);
});
it('ignores a soft min at or below zero on a log scale', () => {
const [min] = rangeOf(
{ distribution: DistributionType.Logarithmic, softMin: 0 },
10,
100,
);
expect(min).toBeGreaterThan(0);
});
it('spreads a single value around itself', () => {
const [min, max] = rangeOf({}, 5, 5);
expect(min).toBeLessThan(5);
expect(max).toBeGreaterThan(5);
expect(5 - min).toBeCloseTo(max - 5);
});
it('falls back to a unit range with no data', () => {
expect(rangeOf({}, null, null)).toStrictEqual([0, 1]);
expect(
rangeOf({ distribution: DistributionType.Logarithmic }, null, null),
).toStrictEqual([1, 10]);
});
it('skips the margin on a plot too small to hold it', () => {
const tiny = { bbox: { width: 15, height: 15 } } as unknown as uPlot;
const range = createScatterRange({
dimension: 'y',
marginPx: 10,
distribution: DistributionType.Linear,
}) as (u: uPlot, min: number, max: number) => [number, number];
expect(range(tiny, 0, 100)).toStrictEqual([0, 100]);
});
});

View File

@@ -1,217 +0,0 @@
import type uPlot from 'uplot';
import { DistributionType } from 'lib/uPlotV2/config/types';
import {
buildScatterConfig,
getCursorHit,
prepareScatterChartData,
resolveAxisDistribution,
ScatterAxisScale,
ScatterSeries,
} from '../utils';
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
getStoredSeriesVisibility: jest.fn(),
}));
const SERIES: ScatterSeries[] = [
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
{ label: 'checkout', xs: [30], ys: [0] },
];
describe('prepareScatterChartData', () => {
it('lays series out as facets behind an empty x slot', () => {
expect(prepareScatterChartData(SERIES)).toStrictEqual([
null,
[
[10, 20],
[100, 200],
[1, null],
],
[[30], [0]],
]);
});
});
describe('getCursorHit', () => {
const plotWith = (idxs: Array<number | null>): uPlot =>
({ cursor: { idxs } }) as unknown as uPlot;
it('reads the series the scatter plugin answered for', () => {
expect(getCursorHit(plotWith([null, null, 4, null]))).toStrictEqual({
seriesIndex: 2,
dataIndex: 4,
});
});
it('takes a data index of 0 as a hit', () => {
expect(getCursorHit(plotWith([0, 0]))).toStrictEqual({
seriesIndex: 1,
dataIndex: 0,
});
});
it('is null off every dot', () => {
expect(getCursorHit(plotWith([null, null]))).toBeNull();
expect(getCursorHit({ cursor: {} } as unknown as uPlot)).toBeNull();
});
});
describe('resolveAxisDistribution', () => {
it('is linear when asked, whatever the values', () => {
expect(
resolveAxisDistribution([1, 1e6], ScatterAxisScale.Linear),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('is a plain log when every value is positive', () => {
expect(resolveAxisDistribution([1, 100], ScatterAxisScale.Log)).toStrictEqual(
{ distribution: DistributionType.Logarithmic },
);
});
it('falls back from log to a symmetric log around the smallest magnitude when zero is present', () => {
expect(
resolveAxisDistribution([0, 0.05, 300], ScatterAxisScale.Log),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 0.01,
});
});
it('keeps an explicit log axis for a soft min it cannot place', () => {
expect(
resolveAxisDistribution([1, 100], ScatterAxisScale.Log, [-10]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is a symmetric log when asked, even with only positive values', () => {
expect(
resolveAxisDistribution([2, 300], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('uses a unit threshold when nothing is positive', () => {
expect(
resolveAxisDistribution([0, -5], ScatterAxisScale.SymLog),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
describe('auto', () => {
it.each([
[
'positive values spanning three decades',
[1, 1000],
DistributionType.Logarithmic,
],
['positive values spanning less', [1, 999], DistributionType.Linear],
['a wide span that includes zero', [0, 1, 1e6], DistributionType.Linear],
['no finite values', [Number.NaN], DistributionType.Linear],
])('picks a scale for %s', (_, values, distribution) => {
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
{ distribution },
);
});
it('stays linear when a soft limit reaches zero or below', () => {
expect(
resolveAxisDistribution([0.02, 5000], ScatterAxisScale.Auto, [-10]),
).toStrictEqual({ distribution: DistributionType.Linear });
});
it('counts a soft limit towards the decades it spans', () => {
expect(
resolveAxisDistribution([5, 50], ScatterAxisScale.Auto, [null, 5000]),
).toStrictEqual({ distribution: DistributionType.Logarithmic });
});
it('is the default', () => {
expect(resolveAxisDistribution([1, 1e4])).toStrictEqual({
distribution: DistributionType.Logarithmic,
});
});
});
});
describe('buildScatterConfig', () => {
const build = (
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
): ReturnType<typeof buildScatterConfig> =>
buildScatterConfig({
id: 'scatter',
series: SERIES,
isDarkMode: true,
x: { unit: 'reqps' },
y: { unit: 'ms', scale: ScatterAxisScale.Log },
...overrides,
});
it('draws a line along both plot edges', () => {
const [xAxis, yAxis] = build().getConfig().axes ?? [];
expect(xAxis?.border?.show).toBe(true);
expect(yAxis?.border?.show).toBe(true);
});
it('titles each axis with its label, and leaves an unlabelled one bare', () => {
const config = build({
x: { unit: 'reqps', label: 'Throughput' },
y: { unit: 'ms', label: '' },
}).getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis?.label).toBe('Throughput');
expect(yAxis?.label).toBeUndefined();
});
it('emits a faceted plot with two value scales', () => {
const config = build().getConfig();
expect(config.mode).toBe(2);
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
// The y column has a 0, so log becomes the symmetric variant.
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
});
it('draws one faceted series per group with the plugin path builder', () => {
const config = build().getConfig();
const [, cart, checkout] = config.series ?? [];
expect(config.series).toHaveLength(3);
expect(cart).toMatchObject({
label: 'cart',
facets: [
{ scale: 'x', auto: true },
{ scale: 'y', auto: true },
],
});
expect(typeof cart?.paths).toBe('function');
expect(cart?.paths).toBe(checkout?.paths);
expect(cart?.points?.show).toBe(false);
});
it('formats both axes with their units', () => {
const config = build().getConfig();
const [xAxis, yAxis] = config.axes ?? [];
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
expect(typeof xAxis?.values).toBe('function');
expect(typeof yAxis?.values).toBe('function');
});
it('registers a y threshold draw hook when thresholds are given', () => {
const config = build({
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
}).getConfig();
expect(config.hooks?.draw).toHaveLength(1);
expect(build().getConfig().hooks?.draw).toBeUndefined();
});
});

View File

@@ -1,108 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import type { AxisDistribution } from './utils';
interface ScaleTransform {
forward: (value: number) => number;
inverse: (position: number) => number;
}
/** The space uPlot lays the scale out in, so a pixel margin is the same size along it. */
function getScaleTransform({
distribution,
asinhThreshold = 1,
}: AxisDistribution): ScaleTransform {
switch (distribution) {
case DistributionType.Logarithmic:
return {
forward: (value): number => Math.log10(value),
inverse: (position): number => 10 ** position,
};
case DistributionType.SymmetricLog:
return {
forward: (value): number => Math.asinh(value / asinhThreshold),
inverse: (position): number => Math.sinh(position) * asinhThreshold,
};
default:
return {
forward: (value): number => value,
inverse: (position): number => position,
};
}
}
/** Half the span a single value spreads to, in transformed units. */
const SINGLE_VALUE_HALF_SPAN = 0.5;
/** The data's extent stretched to the soft limits; null with neither. */
function resolveExtent(
dataMin: number | null,
dataMax: number | null,
softMin: number | null | undefined,
softMax: number | null | undefined,
): [number, number] | null {
const lows = [dataMin, softMin].filter(
(value): value is number => value != null && Number.isFinite(value),
);
const highs = [dataMax, softMax].filter(
(value): value is number => value != null && Number.isFinite(value),
);
return lows.length > 0 && highs.length > 0
? [Math.min(...lows), Math.max(...highs)]
: null;
}
export interface ScatterRangeOptions extends AxisDistribution {
/** The axis this scale is laid out along. */
dimension: 'x' | 'y';
/** CSS pixels kept clear at each end so the largest dot draws whole. */
marginPx: number;
softMin?: number | null;
softMax?: number | null;
}
/**
* Fits the scale to the data and then widens it by `marginPx` at each end.
* uPlot clips series to the plot area, so a dot at the extreme value would
* otherwise lose the half that hangs past the edge. Log scales are left
* unsnapped: rounding out to the next power of ten after the margin can add a
* whole empty decade.
*/
export function createScatterRange({
dimension,
marginPx,
softMin,
softMax,
...distribution
}: ScatterRangeOptions): uPlot.Scale.Range {
const transform = getScaleTransform(distribution);
const isLog = distribution.distribution === DistributionType.Logarithmic;
// A log scale cannot place a soft limit at or below zero.
const usableSoftMin = isLog && (softMin ?? 0) <= 0 ? null : softMin;
return (u, dataMin, dataMax): uPlot.Range.MinMax => {
const extent = resolveExtent(dataMin, dataMax, usableSoftMin, softMax);
if (!extent) {
return isLog ? [1, 10] : [0, 1];
}
let start = transform.forward(extent[0]);
let end = transform.forward(extent[1]);
if (start === end) {
start -= SINGLE_VALUE_HALF_SPAN;
end += SINGLE_VALUE_HALF_SPAN;
}
const plotPx =
(dimension === 'x' ? u.bbox.width : u.bbox.height) / uPlot.pxRatio;
if (plotPx > 2 * marginPx) {
const pad = ((end - start) * marginPx) / (plotPx - 2 * marginPx);
start -= pad;
end += pad;
}
return [transform.inverse(start), transform.inverse(end)];
};
}

View File

@@ -1,328 +0,0 @@
import { PrecisionOption } from 'components/Graph/types';
import {
type AxisProps,
DistributionType,
DrawStyle,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { Threshold } from 'lib/uPlotV2/hooks/types';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import {
DEFAULT_SCATTER_POINT_SIZE,
ScatterChartData,
ScatterPointSize,
ScatterSeriesData,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
logScaleSplits,
spacedLogLabels,
} from 'lib/uPlotV2/utils/logGridSplits';
import { adjustSoftLimitsWithThresholds } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import { createScatterRange } from './scatterRange';
/** Circle outline; the fill carries the colour. */
const POINT_STROKE_WIDTH = 1;
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
const X_AXIS_TICK_SPACE_PX = 90;
const X_AXIS_END_LABEL_PADDING_PX = 40;
export interface ScatterSeries {
/** Group label, as the legend names it. */
label: string;
xs: number[];
ys: number[];
/** Optional third channel, in the caller's units. */
sizes?: Array<number | null>;
}
export enum ScatterAxisScale {
/** Log when the values are positive and span several decades, else linear. */
Auto = 'auto',
Linear = 'linear',
Log = 'log',
/** Log-like, but places zero and negatives. */
SymLog = 'symlog',
}
/** Decades of positive values `auto` needs before it picks a log axis. */
export const AUTO_LOG_MIN_DECADES = 3;
export interface ScatterAxisOptions {
/** Axis title. */
label?: string;
unit?: string;
softMin?: number | null;
softMax?: number | null;
scale?: ScatterAxisScale;
}
export interface BuildScatterConfigArgs {
id: string;
series: ScatterSeries[];
isDarkMode: boolean;
x: ScatterAxisOptions;
y: ScatterAxisOptions;
pointSize?: ScatterPointSize;
/** 0–1. */
fillOpacity?: number;
colorMapping?: Record<string, string>;
/** Drawn on the y axis. */
thresholds?: Threshold[];
decimalPrecision?: PrecisionOption;
selectionPreferencesSource?: SelectionPreferencesSource;
shouldSaveSelectionPreference?: boolean;
}
/**
* The dot under the cursor, read off uPlot's cursor: the scatter plugin's
* `dataIdx` answers only for the hit series, so its index is the one set.
*/
export function getCursorHit(
plot: uPlot,
): { seriesIndex: number; dataIndex: number } | null {
const idxs = plot.cursor.idxs ?? [];
for (let seriesIndex = 1; seriesIndex < idxs.length; seriesIndex++) {
const dataIndex = idxs[seriesIndex];
if (dataIndex != null) {
return { seriesIndex, dataIndex };
}
}
return null;
}
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
export function prepareScatterChartData(
series: ScatterSeries[],
): uPlot.AlignedData {
const data: ScatterChartData = [
null,
...series.map(
(entry): ScatterSeriesData =>
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
),
];
return data as unknown as uPlot.AlignedData;
}
export interface AxisDistribution {
distribution: DistributionType;
asinhThreshold?: number;
}
function getPositiveRange(values: number[]): {
minPositive: number;
maxPositive: number;
hasNonPositive: boolean;
} {
let minPositive = Infinity;
let maxPositive = -Infinity;
let hasNonPositive = false;
for (const value of values) {
if (!Number.isFinite(value)) {
continue;
}
if (value <= 0) {
hasNonPositive = true;
} else {
minPositive = Math.min(minPositive, value);
maxPositive = Math.max(maxPositive, value);
}
}
return { minPositive, maxPositive, hasNonPositive };
}
/**
* The symmetric log's linear band ends at the smallest non-zero magnitude, so
* the small values still spread out.
*/
function symmetricLogDistribution(minPositive: number): AxisDistribution {
const asinhThreshold = Number.isFinite(minPositive)
? 10 ** Math.floor(Math.log10(minPositive))
: 1;
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
}
/**
* A plain log axis cannot place zero or negatives, so `log` falls back to the
* symmetric log rather than lose those points; callers that would rather drop
* them filter first.
*/
export function resolveAxisDistribution(
values: number[],
scale: ScatterAxisScale = ScatterAxisScale.Auto,
/**
* Soft limits (and thresholds) the axis must reach. Only `auto` weighs them:
* a soft min at or below zero rules out log. An explicit scale keeps its
* choice, and a log axis drops a bound it cannot place.
*/
softLimits: Array<number | null | undefined> = [],
): AxisDistribution {
const { minPositive, hasNonPositive } = getPositiveRange(values);
switch (scale) {
case ScatterAxisScale.Linear:
return { distribution: DistributionType.Linear };
case ScatterAxisScale.SymLog:
return symmetricLogDistribution(minPositive);
case ScatterAxisScale.Log:
return hasNonPositive
? symmetricLogDistribution(minPositive)
: { distribution: DistributionType.Logarithmic };
case ScatterAxisScale.Auto:
default: {
const extent = getPositiveRange([
...values,
...softLimits.filter((limit): limit is number => limit != null),
]);
const spansDecades =
Number.isFinite(extent.minPositive) &&
Math.log10(extent.maxPositive / extent.minPositive) >= AUTO_LOG_MIN_DECADES;
return !extent.hasNonPositive && spansDecades
? { distribution: DistributionType.Logarithmic }
: { distribution: DistributionType.Linear };
}
}
}
/** The scatter range is unsnapped, so a log axis places and labels its own ticks. */
function getLogAxisTicks({
distribution,
}: AxisDistribution): Pick<AxisProps, 'splits' | 'filter'> {
return distribution === DistributionType.Logarithmic
? { splits: logScaleSplits, filter: spacedLogLabels }
: {};
}
export function buildScatterConfig({
id,
series,
isDarkMode,
x,
y,
pointSize = DEFAULT_SCATTER_POINT_SIZE,
fillOpacity,
colorMapping = {},
thresholds,
decimalPrecision,
selectionPreferencesSource,
shouldSaveSelectionPreference,
}: BuildScatterConfigArgs): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder({
id,
selectionPreferencesSource,
shouldSaveSelectionPreference,
});
const plugin = createScatterPlugin({ pointSize });
applyScatterPlugin(builder, plugin);
// The last x label is centred on the plot's right edge; room for its unit.
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
const ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
thresholds,
y.unit,
);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
[x.softMin, x.softMax],
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
[ySoftLimits.softMin, ySoftLimits.softMax],
);
const yThresholds =
thresholds && thresholds.length > 0
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
: undefined;
// The largest disc drawn, plus its outline, kept clear of each plot edge.
const largestDiameter = series.some((entry) => entry.sizes)
? pointSize.max
: pointSize.fixed;
const marginPx = largestDiameter / 2 + POINT_STROKE_WIDTH;
builder.addScale({
scaleKey: 'x',
time: false,
softMin: x.softMin ?? undefined,
softMax: x.softMax ?? undefined,
range: createScatterRange({
dimension: 'x',
marginPx,
softMin: x.softMin,
softMax: x.softMax,
...xDistribution,
}),
...xDistribution,
});
builder.addScale({
scaleKey: 'y',
time: false,
softMin: y.softMin ?? undefined,
softMax: y.softMax ?? undefined,
thresholds: yThresholds,
range: createScatterRange({
dimension: 'y',
marginPx,
...ySoftLimits,
...yDistribution,
}),
...yDistribution,
});
builder.addAxis({
scaleKey: 'x',
side: 2,
label: x.label || undefined,
isDarkMode,
isTimeAxis: false,
yAxisUnit: x.unit ?? '',
decimalPrecision,
isLogScale: xDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(xDistribution),
space: X_AXIS_TICK_SPACE_PX,
});
builder.addAxis({
scaleKey: 'y',
side: 3,
label: y.label || undefined,
isDarkMode,
yAxisUnit: y.unit ?? '',
decimalPrecision,
isLogScale: yDistribution.distribution !== DistributionType.Linear,
...getLogAxisTicks(yDistribution),
});
series.forEach((entry) => {
builder.addSeries({
scaleKey: 'y',
label: entry.label,
colorMapping,
drawStyle: DrawStyle.Scatter,
pathBuilder: plugin.pathBuilder,
facets: SCATTER_FACETS,
lineWidth: POINT_STROKE_WIDTH,
pointSize: pointSize.fixed,
fillOpacity,
isDarkMode,
});
});
if (yThresholds) {
builder.addThresholds(yThresholds);
}
return builder;
}

View File

@@ -16,10 +16,6 @@ import type {
HeatmapColorOptions,
HeatmapSeries,
} from 'lib/uPlotV2/plugins/HeatmapPlugin/types';
import type {
ScatterChannels,
ScatterPointLabel,
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
import {
DashboardCursorSync,
SyncTooltipFilterMode,
@@ -92,27 +88,6 @@ export interface HistogramChartProps extends ChartWrapperProps {
isQueriesMerged?: boolean;
}
/** The dot a click landed on; `seriesIndex` is uPlot's, so the first series is 1. */
export interface ScatterPointClick {
seriesIndex: number;
dataIndex: number;
/** The series' drawn colour. */
color: string;
/** Viewport coordinates of the click. */
coordinates: { x: number; y: number };
}
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
export interface ScatterChartProps extends ChartWrapperProps {
channels: ScatterChannels;
resolvePointLabels?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
/** Fires for a click on a dot; a click on empty plot space fires nothing. */
onPointClick?: (click: ScatterPointClick) => void;
}
/**
* 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

View File

@@ -40,8 +40,6 @@ interface ConfigPaneProps {
legendSeries: LegendSeries[];
/** Table panel's resolved value columns, for the table-only editors. */
tableColumns: TableColumnOption[];
/** Group-by labels of a joined scalar result, for the scatter dimensions editor. */
groupColumns?: string[];
/** Query step interval (seconds), for the chart-appearance span-gaps floor. */
stepInterval?: number;
/**
@@ -69,7 +67,6 @@ function ConfigPane({
queryType,
legendSeries,
tableColumns,
groupColumns,
stepInterval,
panel,
panelId,
@@ -139,7 +136,6 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -167,7 +163,6 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,8 +12,6 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isDifferent } from '../utils/changes';
import { getScatterAxisColumnNames } from '../utils/scatterAxisColumns';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -65,7 +63,6 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -77,10 +74,7 @@ function SectionSlot({
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (
config.kind === SectionKind.Visualization ||
config.kind === SectionKind.Dimensions
) {
if (config.kind === SectionKind.Visualization) {
return true;
}
const value = editor?.get(spec);
@@ -135,9 +129,6 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -1,12 +0,0 @@
.select {
width: 100%;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
:global(.ant-select-selection-item) {
font-family: 'Geist Mono', monospace;
font-size: 12px;
}
}

View File

@@ -1,57 +0,0 @@
import { Select } from 'antd';
import styles from './ConfigMultiSelect.module.scss';
export interface ConfigMultiSelectItem<T extends string = string> {
value: T;
label: string;
}
interface ConfigMultiSelectProps<T extends string = string> {
testId: string;
value: T[];
/** Shown while nothing is selected, which is a valid choice. */
placeholder?: string;
items: ConfigMultiSelectItem<T>[];
onChange: (value: T[]) => void;
disabled?: boolean;
'aria-label'?: string;
}
/**
* Multi-select for the config sections; the picks show as tags. The order of
* `items` is kept rather than the order they were picked in, so the same set
* always reads (and is stored) the same way.
*/
function ConfigMultiSelect<T extends string = string>({
testId,
value,
placeholder,
items,
onChange,
disabled,
'aria-label': ariaLabel,
}: ConfigMultiSelectProps<T>): JSX.Element {
return (
<Select<T[]>
mode="multiple"
className={styles.select}
data-testid={testId}
aria-label={ariaLabel}
value={value}
placeholder={placeholder}
disabled={disabled}
virtual={false}
options={items}
onChange={(next): void =>
onChange(
items
.map((item) => item.value)
.filter((candidate) => next.includes(candidate)),
)
}
/>
);
}
export default ConfigMultiSelect;

View File

@@ -1,52 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigMultiSelect from '../ConfigMultiSelect';
const ITEMS = [
{ value: 'a', label: 'alpha' },
{ value: 'b', label: 'beta' },
{ value: 'c', label: 'gamma' },
];
async function open(): Promise<ReturnType<typeof userEvent.setup>> {
const user = userEvent.setup();
await user.click(
screen
.getByTestId('multi')
.querySelector('.ant-select-selector') as HTMLElement,
);
return user;
}
describe('ConfigMultiSelect', () => {
it('shows the placeholder while nothing is picked', () => {
render(
<ConfigMultiSelect
testId="multi"
value={[]}
placeholder="Every group key"
items={ITEMS}
onChange={jest.fn()}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
});
it('keeps picks in item order, not the order they were picked', async () => {
const onChange = jest.fn();
render(
<ConfigMultiSelect
testId="multi"
value={['c']}
items={ITEMS}
onChange={onChange}
/>,
);
const user = await open();
await user.click(await screen.findByRole('option', { name: 'alpha' }));
expect(onChange).toHaveBeenLastCalledWith(['a', 'c']);
});
});

View File

@@ -1,23 +0,0 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// The design-system slider insets its track by half a thumb on each side so the
// fill follows the thumb's centre. Pull that inset back off the row so the track
// lines up with the other controls; the thumb never paints past the track edge.
margin-inline: calc(var(--slider-thumb-width, 18px) / -2);
}
.value {
flex-shrink: 0;
min-width: 56px;
text-align: right;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--l3-foreground);
}

View File

@@ -1,49 +0,0 @@
import { Slider } from '@signozhq/ui/slider';
import styles from './ConfigRangeSlider.module.scss';
interface ConfigRangeSliderProps {
testId: string;
value: [number, number];
min: number;
max: number;
step: number;
/** Renders the current range beside the track (e.g. `4–24 px`). */
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
/** Two-thumb slider for a min/max pair; the thumbs can meet but not cross. */
function ConfigRangeSlider({
testId,
value,
min,
max,
step,
formatValue,
onChange,
}: ConfigRangeSliderProps): JSX.Element {
return (
<div className={styles.row}>
<Slider
range
testId={testId}
className={styles.slider}
value={value}
min={min}
max={max}
step={step}
onChange={(next): void => {
if (Array.isArray(next) && next.length === 2) {
onChange([Math.min(next[0], next[1]), Math.max(next[0], next[1])]);
}
}}
/>
<span className={styles.value}>
{formatValue ? formatValue(value) : `${value[0]}–${value[1]}`}
</span>
</div>
);
}
export default ConfigRangeSlider;

View File

@@ -1,51 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ConfigRangeSlider from '../ConfigRangeSlider';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ConfigRangeSlider', () => {
it('renders a thumb per end and the formatted range', () => {
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
formatValue={([min, max]): string => `${min}–${max} px`}
onChange={jest.fn()}
/>,
);
expect(screen.getAllByRole('slider')).toHaveLength(2);
expect(screen.getByText('4–24 px')).toBeInTheDocument();
});
it('moves one end and reports the pair low to high', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ConfigRangeSlider
testId="range"
value={[4, 24]}
min={2}
max={40}
step={1}
onChange={onChange}
/>,
);
const [, maxThumb] = screen.getAllByRole('slider');
maxThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith([4, 25]);
});
});

View File

@@ -13,12 +13,6 @@ import { EQueryType } from 'types/common/dashboard';
export interface SectionEditorContext {
legendSeries?: LegendSeries[];
tableColumns?: TableColumnOption[];
/** Group-by labels of the joined scalar result; the colour-by options. */
groupColumns?: string[];
/** Label of the value column a Scatter Plot sizes its dots by; unset when none is. */
sizeColumnLabel?: string;
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
axisColumnNames?: { x?: string; y?: string };
signal?: TelemetrytypesSignalDTO;
panelKind?: PanelKind;
onChangePanelKind?: (kind: PanelKind) => void;

View File

@@ -4,8 +4,6 @@ import type {
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesScatterPlotAxesDTO,
DashboardtypesScatterPlotDimensionsDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SectionKind,
@@ -24,11 +22,9 @@ import AxesSection from './sections/AxesSection/AxesSection';
import BucketsSection from './sections/BucketsSection/BucketsSection';
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import DimensionsSection from './sections/DimensionsSection/DimensionsSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import ScatterAxesSection from './sections/ScatterAxesSection/ScatterAxesSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -90,20 +86,6 @@ export const SECTION_REGISTRY: {
getPluginSlice<PanelAxesSlice>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
// Same `axes` key as above, in Scatter Plot's per-axis shape.
[SectionKind.ScatterAxes]: {
Component: ScatterAxesSection,
get: (spec): DashboardtypesScatterPlotAxesDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotAxesDTO>(spec, 'axes'),
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
},
[SectionKind.Dimensions]: {
Component: DimensionsSection,
get: (spec): DashboardtypesScatterPlotDimensionsDTO | undefined =>
getPluginSlice<DashboardtypesScatterPlotDimensionsDTO>(spec, 'dimensions'),
update: (spec, dimensions): PanelSpec =>
updatePluginSlice(spec, 'dimensions', dimensions),
},
[SectionKind.Legend]: {
Component: LegendSection,
get: (spec): DashboardtypesLegendDTO | undefined =>

View File

@@ -14,7 +14,6 @@ import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import DisconnectValuesField from './DisconnectValuesField';
import HeatmapColorsField from './HeatmapColorsField';
import PointsFields from './PointsFields';
import {
FILL_MODE_OPTIONS,
FILLED_FILL_MODE_OPTIONS,
@@ -40,9 +39,8 @@ function ChartAppearanceSection({
controls,
onChange,
stepInterval,
sizeColumnLabel,
}: SectionEditorProps<SectionKind.ChartAppearance> &
Pick<SectionEditorContext, 'stepInterval' | 'sizeColumnLabel'>): JSX.Element {
Pick<SectionEditorContext, 'stepInterval'>): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
const interpolation = value?.lineInterpolation;
@@ -124,15 +122,6 @@ function ChartAppearanceSection({
/>
)}
{controls.points && (
<PointsFields
value={value?.points}
savedValue={savedValue?.points}
sizeColumnLabel={sizeColumnLabel}
onChange={(points): void => onChange({ ...value, points })}
/>
)}
{controls.spanGaps && (
<DisconnectValuesField
testId="panel-editor-v2-span-gaps"

View File

@@ -1,87 +0,0 @@
import type { DashboardtypesScatterPlotPointsDTO } from 'api/generated/services/sigNoz.schemas';
import {
POINT_OPACITY_BOUNDS,
POINT_SIZE_BOUNDS,
resolvePointOpacity,
resolvePointSize,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/ScatterPlotPanel/utils/points';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigRangeSlider from '../../controls/ConfigRangeSlider/ConfigRangeSlider';
import ConfigSlider from '../../controls/ConfigSlider/ConfigSlider';
import { createFieldResetter } from '../../utils/changes';
import { formatOpacity, formatPointSize, formatPointSizeRange } from './utils';
const OPACITY_STEP = 0.01;
interface PointsFieldsProps {
value: DashboardtypesScatterPlotPointsDTO | undefined;
savedValue: DashboardtypesScatterPlotPointsDTO | undefined;
onChange: (next: DashboardtypesScatterPlotPointsDTO) => void;
/** The column dots are sized by; unset draws every dot at one size. */
sizeColumnLabel?: string;
}
function PointsFields({
value,
savedValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
// The renderer's defaults, so a thumb starts where an unset field draws.
const size = resolvePointSize(value);
return (
<>
{sizeColumnLabel ? (
<ConfigField
label="Point size"
help={`Each dot's area scales with ${sizeColumnLabel} between min and max.`}
{...reset('minSize', 'maxSize')}
>
<ConfigRangeSlider
testId="panel-editor-v2-point-size-range"
value={[size.min, size.max]}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSizeRange}
onChange={([minSize, maxSize]): void =>
onChange({ ...value, minSize, maxSize })
}
/>
</ConfigField>
) : (
<ConfigField
label="Point size"
help="Every dot uses this size. Map a Size column to draw a bubble chart."
{...reset('size')}
>
<ConfigSlider
testId="panel-editor-v2-point-size"
value={size.fixed}
min={POINT_SIZE_BOUNDS.min}
max={POINT_SIZE_BOUNDS.max}
step={1}
formatValue={formatPointSize}
onChange={(next): void => onChange({ ...value, size: next })}
/>
</ConfigField>
)}
<ConfigField label="Fill opacity" {...reset('opacity')}>
<ConfigSlider
testId="panel-editor-v2-point-opacity"
value={resolvePointOpacity(value)}
min={POINT_OPACITY_BOUNDS.min}
max={POINT_OPACITY_BOUNDS.max}
step={OPACITY_STEP}
formatValue={formatOpacity}
onChange={(opacity): void => onChange({ ...value, opacity })}
/>
</ConfigField>
</>
);
}
export default PointsFields;

View File

@@ -1,114 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import ChartAppearanceSection from '../ChartAppearanceSection';
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
describe('ChartAppearanceSection points', () => {
it('offers one point size when nothing sizes the dots', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ points: true }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-point-size')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size-range'),
).not.toBeInTheDocument();
expect(screen.getByText('6 px')).toBeInTheDocument();
expect(
screen.getByText(
'Every dot uses this size. Map a Size column to draw a bubble chart.',
),
).toBeInTheDocument();
expect(screen.getByText('70%')).toBeInTheDocument();
});
it('offers a min/max range named after the size column when one is bound', () => {
render(
<ChartAppearanceSection
value={{ points: { minSize: 5 } }}
controls={{ points: true }}
sizeColumnLabel="B.count()"
onChange={jest.fn()}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-point-size-range'),
).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.getByText('5–24 px')).toBeInTheDocument();
expect(
screen.getByText(
"Each dot's area scales with B.count() between min and max.",
),
).toBeInTheDocument();
});
it('steps the fixed size by a whole pixel, keeping the range', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { size: 6, minSize: 4, maxSize: 30 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const [sizeThumb] = screen.getAllByRole('slider');
sizeThumb.focus();
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenLastCalledWith({
points: { size: 7, minSize: 4, maxSize: 30 },
});
});
it('stops the opacity at 10%', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ChartAppearanceSection
value={{ points: { opacity: 0.1 } }}
controls={{ points: true }}
onChange={onChange}
/>,
);
const thumbs = screen.getAllByRole('slider');
const opacityThumb = thumbs[thumbs.length - 1];
expect(opacityThumb).toHaveAttribute('aria-valuemin', '0.1');
opacityThumb.focus();
await user.keyboard('{ArrowLeft}');
expect(onChange).not.toHaveBeenCalled();
});
it('leaves the points out of a kind that does not declare them', () => {
render(
<ChartAppearanceSection
value={undefined}
controls={{ lineStyle: true }}
onChange={jest.fn()}
/>,
);
expect(
screen.queryByTestId('panel-editor-v2-point-size'),
).not.toBeInTheDocument();
expect(screen.queryByText('Fill opacity')).not.toBeInTheDocument();
});
});

View File

@@ -12,11 +12,3 @@ export function defaultDisconnectDuration(stepInterval?: number): string {
export function formatOpacity(opacity: number): string {
return `${Math.round(opacity * 100)}%`;
}
export function formatPointSize(size: number): string {
return `${size} px`;
}
export function formatPointSizeRange([min, max]: [number, number]): string {
return `${min}–${max} px`;
}

View File

@@ -1,30 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: 18px;
}
.labelWithInfo {
display: inline-flex;
align-items: center;
gap: 6px;
}
.info {
display: inline-flex;
color: var(--l3-foreground);
cursor: help;
}
.infoTooltip {
max-width: 240px;
white-space: normal;
word-break: break-word;
}
.description {
margin: 0;
color: var(--l2-foreground);
font-size: 12px;
line-height: 17px;
}

View File

@@ -1,117 +0,0 @@
import { useMemo } from 'react';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import ConfigMultiSelect from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
import type { SectionEditorContext } from '../../sectionContext';
import { resolveAutoAxes } from '../../utils/scatterAxisColumns';
import styles from './DimensionsSection.module.scss';
import LabelWithInfo from './LabelWithInfo';
import {
buildColorKeyItems,
buildDimensionItems,
formatAutoOption,
formatColumnOption,
UNSET_DIMENSION,
} from './utils';
type DimensionsSectionProps = SectionEditorProps<SectionKind.Dimensions> &
Pick<SectionEditorContext, 'tableColumns' | 'groupColumns'>;
const FIELD_INFO = {
x: 'The value column that places each dot left to right. Auto takes the first value column.',
y: "The value column that places each dot bottom to top. Auto takes the first value column X doesn't use.",
sizeBy:
"Optional. Scales each dot's area by this value column; a group without a value draws at the default size.",
colorBy:
'One color and legend entry per combination of the selected keys. Leave it empty to color by every group key.',
};
/**
* Edits the `dimensions` slice of a Scatter Plot: which value column each axis
* and the dot size read, and which group-by keys colour the dots. Options come
* from the preview query's joined result.
*/
function DimensionsSection({
value,
onChange,
tableColumns = [],
groupColumns = [],
}: DimensionsSectionProps): JSX.Element {
const valueItems = useMemo(
() =>
tableColumns.map((column) => ({
value: column.key,
label: formatColumnOption(column),
})),
[tableColumns],
);
const autoAxes = resolveAutoAxes(tableColumns, {
x: value?.x,
y: value?.y,
});
const colorBy = value?.colorBy ?? [];
const colorItems = buildColorKeyItems(groupColumns, colorBy);
const fields = [
{ dimension: 'x', label: 'X axis', unsetLabel: formatAutoOption(autoAxes.x) },
{ dimension: 'y', label: 'Y axis', unsetLabel: formatAutoOption(autoAxes.y) },
{ dimension: 'sizeBy', label: 'Size by', unsetLabel: 'None' },
] as const;
return (
<div className={styles.section}>
{fields.map(({ dimension, label, unsetLabel }) => (
<ConfigField
key={dimension}
label={
<LabelWithInfo
label={label}
info={FIELD_INFO[dimension]}
testId={`panel-editor-v2-dimension-${dimension}-info`}
/>
}
>
<ConfigSelect
testId={`panel-editor-v2-dimension-${dimension}`}
value={value?.[dimension] || UNSET_DIMENSION}
items={buildDimensionItems(valueItems, value?.[dimension], unsetLabel)}
onChange={(next): void => onChange({ ...value, [dimension]: next })}
/>
</ConfigField>
))}
<ConfigField
label={
<LabelWithInfo
label="Color by"
info={FIELD_INFO.colorBy}
testId="panel-editor-v2-dimension-colorBy-info"
/>
}
>
<ConfigMultiSelect
testId="panel-editor-v2-dimension-colorBy"
aria-label="Color by"
value={colorBy}
items={colorItems}
placeholder={
colorItems.length > 0 ? 'Every group key' : 'No group-by labels'
}
disabled={colorItems.length === 0}
onChange={(next): void => onChange({ ...value, colorBy: next })}
/>
</ConfigField>
<p className={styles.description}>
Each dot is one group from the query. X and Y place it, Size by scales it,
and Color by groups it in the legend.
</p>
</div>
);
}
export default DimensionsSection;

View File

@@ -1,32 +0,0 @@
import { Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './DimensionsSection.module.scss';
interface LabelWithInfoProps {
label: string;
info: string;
testId: string;
}
function LabelWithInfo({
label,
info,
testId,
}: LabelWithInfoProps): JSX.Element {
return (
<span className={styles.labelWithInfo}>
{label}
<TooltipSimple
title={info}
tooltipContentProps={{ className: styles.infoTooltip }}
>
<span className={styles.info} data-testid={testId}>
<Info size={12} />
</span>
</TooltipSimple>
</span>
);
}
export default LabelWithInfo;

View File

@@ -1,189 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import DimensionsSection from '../DimensionsSection';
const TABLE_COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B.p99(duration_nano)', label: 'B.p99 latency', name: 'p99 latency' },
{ key: 'C.count()', label: 'C.count()', name: 'count()' },
];
async function pick(dimension: string, label: string): Promise<void> {
const user = userEvent.setup();
const trigger = screen.getByTestId(`panel-editor-v2-dimension-${dimension}`);
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
await user.click(await screen.findByRole('option', { name: label }));
}
describe('DimensionsSection', () => {
it('names the column each unset axis falls back to', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
groupColumns={['service.name']}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'Auto (A · Request rate)',
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (B.p99(duration_nano) · p99 latency)',
);
expect(
screen.getByTestId('panel-editor-v2-dimension-sizeBy'),
).toHaveTextContent('None');
});
it('falls Y back to a column other than the bound X', () => {
render(
<DimensionsSection
value={{ x: 'B.p99(duration_nano)' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-y')).toHaveTextContent(
'Auto (A · Request rate)',
);
});
it('shows a column by its key alone when the name only repeats it', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={undefined}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'C.count()');
expect(onChange).toHaveBeenCalledWith({ sizeBy: 'C.count()' });
});
it('binds an axis to a value column by its key', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ x: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('y', 'B.p99(duration_nano) · p99 latency');
expect(onChange).toHaveBeenCalledWith({
x: 'A',
y: 'B.p99(duration_nano)',
});
});
it('clears the size back to none', async () => {
const onChange = jest.fn();
render(
<DimensionsSection
value={{ sizeBy: 'A' }}
onChange={onChange}
tableColumns={TABLE_COLUMNS}
/>,
);
await pick('sizeBy', 'None');
expect(onChange).toHaveBeenCalledWith({ sizeBy: '' });
});
it('keeps a binding the results no longer have visible', () => {
render(
<DimensionsSection
value={{ x: 'D' }}
onChange={jest.fn()}
tableColumns={TABLE_COLUMNS}
/>,
);
expect(screen.getByTestId('panel-editor-v2-dimension-x')).toHaveTextContent(
'D (not in results)',
);
});
it('colours by any combination of group-by keys, kept in result order', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<DimensionsSection
value={{ colorBy: ['service.name'] }}
onChange={onChange}
groupColumns={['k8s.namespace.name', 'service.name']}
/>,
);
const trigger = screen.getByTestId('panel-editor-v2-dimension-colorBy');
await user.click(
trigger.querySelector('.ant-select-selector') as HTMLElement,
);
await user.click(
await screen.findByRole('option', { name: 'k8s.namespace.name' }),
);
expect(onChange).toHaveBeenLastCalledWith({
colorBy: ['k8s.namespace.name', 'service.name'],
});
});
it('explains every field from an info icon', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
['x', 'y', 'sizeBy', 'colorBy'].forEach((dimension) => {
expect(
screen.getByTestId(`panel-editor-v2-dimension-${dimension}-info`),
).toBeInTheDocument();
});
});
it('says what an empty colour selection means', () => {
render(
<DimensionsSection
value={undefined}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(screen.getByText('Every group key')).toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy-info'),
).toBeInTheDocument();
});
it('keeps a selected colour key the results no longer have', () => {
render(
<DimensionsSection
value={{ colorBy: ['host.name'] }}
onChange={jest.fn()}
groupColumns={['service.name']}
/>,
);
expect(
screen.getByTestId('panel-editor-v2-dimension-colorBy'),
).toHaveTextContent('host.name (not in results)');
});
it('disables colour when the query has no group by', () => {
render(<DimensionsSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByText('No group-by labels')).toBeInTheDocument();
expect(
screen
.getByTestId('panel-editor-v2-dimension-colorBy')
.classList.contains('ant-select-disabled'),
).toBe(true);
});
});

View File

@@ -1,49 +0,0 @@
import type { ConfigMultiSelectItem } from '../../controls/ConfigMultiSelect/ConfigMultiSelect';
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
import type { TableColumnOption } from '../../../hooks/useTableColumns';
/** The select value for an unset dimension; the spec stores it as `''`. */
export const UNSET_DIMENSION = '';
/** `A · Request rate`, or the key alone when the name only repeats it (`A.count()`). */
export function formatColumnOption({
key,
name,
}: Pick<TableColumnOption, 'key' | 'name'>): string {
return key === name || key.endsWith(`.${name}`) ? key : `${key} · ${name}`;
}
export function formatAutoOption(
column: TableColumnOption | undefined,
): string {
return column ? `Auto (${formatColumnOption(column)})` : 'Auto';
}
/**
* The unset option first, then the result's columns. A bound key the current
* result no longer has stays listed, so the select never shows a bare value.
*/
export function buildDimensionItems(
columns: ConfigSelectItem[],
current: string | undefined,
unsetLabel: string,
): ConfigSelectItem[] {
const items = [{ value: UNSET_DIMENSION, label: unsetLabel }, ...columns];
if (current && !columns.some((column) => column.value === current)) {
items.push({ value: current, label: `${current} (not in results)` });
}
return items;
}
/** The result's group-by keys, then any selected key the result no longer has. */
export function buildColorKeyItems(
groupColumns: string[],
selected: string[],
): ConfigMultiSelectItem[] {
return [
...groupColumns.map((column) => ({ value: column, label: column })),
...selected
.filter((key) => !groupColumns.includes(key))
.map((key) => ({ value: key, label: `${key} (not in results)` })),
];
}

View File

@@ -152,7 +152,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 'By' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,
@@ -166,7 +166,7 @@ describe('FormattingSection', () => {
<FormattingSection
value={{ columnUnits: { A: 's' } }}
controls={{ columnUnits: true }}
tableColumns={[{ key: 'A', label: 'A', name: 'A' }]}
tableColumns={[{ key: 'A', label: 'A' }]}
metricUnit="s"
onChange={jest.fn()}
/>,

View File

@@ -1,10 +0,0 @@
.axes {
display: flex;
flex-direction: column;
gap: 24px;
}
.axis + .axis {
padding-top: 24px;
border-top: 1px solid var(--l1-border);
}

View File

@@ -1,43 +0,0 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigField from '../../controls/ConfigField/ConfigField';
import type { SectionEditorContext } from '../../sectionContext';
import { createFieldResetter } from '../../utils/changes';
import ScatterAxisFields from './ScatterAxisFields';
import styles from './ScatterAxesSection.module.scss';
type ScatterAxesSectionProps = SectionEditorProps<SectionKind.ScatterAxes> &
Pick<SectionEditorContext, 'axisColumnNames'>;
/** Edits a Scatter Plot's `axes` slice: label, soft bounds and scale, per axis. */
function ScatterAxesSection({
value,
savedValue,
onChange,
axisColumnNames,
}: ScatterAxesSectionProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
return (
<div className={styles.axes}>
{(['x', 'y'] as const).map((axis) => (
<div key={axis} className={styles.axis}>
<ConfigField label={`${axis.toUpperCase()} axis`} {...reset(axis)}>
<ScatterAxisFields
axis={axis}
value={value?.[axis]}
columnName={axisColumnNames?.[axis]}
onChange={(next): void => onChange({ ...value, [axis]: next })}
/>
</ConfigField>
</div>
))}
</div>
);
}
export default ScatterAxesSection;

View File

@@ -1,65 +0,0 @@
import { Input } from 'antd';
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
import ConfigInlineField from '../../controls/ConfigInlineField/ConfigInlineField';
import AxisFields from '../AxesSection/AxisFields';
import AxisRangeField from '../AxesSection/AxisRangeField';
import AxisScaleField from '../AxesSection/AxisScaleField';
import { LOG_RANGE_IGNORED_HELP, SCALE_HELP, SCALE_OPTIONS } from './options';
import { hasBoundALogAxisDrops } from './utils';
interface ScatterAxisFieldsProps {
axis: 'x' | 'y';
value: DashboardtypesScatterPlotAxisDTO | undefined;
onChange: (next: DashboardtypesScatterPlotAxisDTO) => void;
/** The name of the column the axis plots, shown until a label is typed. */
columnName?: string;
}
function ScatterAxisFields({
axis,
value,
onChange,
columnName,
}: ScatterAxisFieldsProps): JSX.Element {
const scale = value?.scale ?? DashboardtypesAxisScaleDTO.auto;
const name = axis.toUpperCase();
return (
<AxisFields>
<ConfigInlineField label="Label">
<Input
data-testid={`panel-editor-v2-${axis}-label`}
aria-label={`${name}-axis label`}
placeholder={columnName ?? 'None'}
value={value?.label ?? ''}
onChange={(event): void =>
onChange({ ...value, label: event.target.value })
}
/>
</ConfigInlineField>
<AxisRangeField
testIdPrefix={`panel-editor-v2-${axis}`}
value={value}
onChange={(bounds): void => onChange({ ...value, ...bounds })}
help={
hasBoundALogAxisDrops(scale, value) ? LOG_RANGE_IGNORED_HELP : undefined
}
helpTestId={`panel-editor-v2-${axis}-range-help`}
/>
<AxisScaleField
testId={`panel-editor-v2-${axis}-scale`}
aria-label={`${name}-axis scale`}
value={scale}
items={SCALE_OPTIONS}
help={SCALE_HELP}
onChange={(next): void => onChange({ ...value, scale: next })}
/>
</AxisFields>
);
}
export default ScatterAxisFields;

View File

@@ -1,171 +0,0 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import ScatterAxesSection from '../ScatterAxesSection';
describe('ScatterAxesSection', () => {
it('renders bounds and scale for both axes', () => {
render(<ScatterAxesSection value={undefined} onChange={jest.fn()} />);
['x', 'y'].forEach((axis) => {
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-min`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-soft-max`),
).toBeInTheDocument();
expect(
screen.getByTestId(`panel-editor-v2-${axis}-scale`),
).toBeInTheDocument();
});
});
it('writes a soft bound to its own axis', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ x: { scale: DashboardtypesAxisScaleDTO.log } }}
onChange={onChange}
/>,
);
await user.type(screen.getByTestId('panel-editor-v2-y-soft-max'), '5');
expect(onChange).toHaveBeenCalledWith({
x: { scale: DashboardtypesAxisScaleDTO.log },
y: { softMax: 5 },
});
});
it('shows the plotted column as each label placeholder', () => {
render(
<ScatterAxesSection
value={undefined}
onChange={jest.fn()}
axisColumnNames={{ x: 'Request rate', y: 'p99 latency' }}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-label')).toHaveAttribute(
'placeholder',
'Request rate',
);
expect(screen.getByTestId('panel-editor-v2-y-label')).toHaveAttribute(
'placeholder',
'p99 latency',
);
});
it('resets one axis without touching the other', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, label: 'Throughput' },
y: { scale: DashboardtypesAxisScaleDTO.symlog },
}}
savedValue={{}}
onChange={onChange}
/>,
);
const [resetX] = screen.getAllByTestId('config-field-reset');
await user.click(resetX);
expect(onChange).toHaveBeenCalledWith({
x: undefined,
y: { scale: DashboardtypesAxisScaleDTO.symlog },
});
});
it('says a log axis ignores a bound at or below zero', () => {
render(
<ScatterAxesSection
value={{
x: { scale: DashboardtypesAxisScaleDTO.log, softMin: -10 },
y: { scale: DashboardtypesAxisScaleDTO.symlog, softMin: -10 },
}}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"A log axis can't reach 0 or below",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
it('flags a soft min above the soft max on that axis only', () => {
render(
<ScatterAxesSection
value={{ x: { softMin: 23, softMax: 12 }, y: { softMin: 1, softMax: 2 } }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('panel-editor-v2-x-range-help')).toHaveTextContent(
"Min can't be greater than Max.",
);
expect(screen.getByTestId('panel-editor-v2-y-range-help')).toHaveTextContent(
'The axis always shows at least this range.',
);
});
it('writes an axis label', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.type(screen.getByTestId('panel-editor-v2-y-label'), 'p');
expect(onChange).toHaveBeenCalledWith({ y: { label: 'p' } });
});
it('clears a soft bound to null when the field is emptied', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection value={{ x: { softMin: 10 } }} onChange={onChange} />,
);
await user.clear(screen.getByTestId('panel-editor-v2-x-soft-min'));
expect(onChange).toHaveBeenCalledWith({ x: { softMin: null } });
});
it.each([
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
])('sets the x scale to %s', async (scale) => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<ScatterAxesSection value={undefined} onChange={onChange} />);
await user.click(screen.getByTestId(`panel-editor-v2-x-scale-${scale}`));
expect(onChange).toHaveBeenCalledWith({ x: { scale } });
});
it('marks a changed scale against the saved value and resets it', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ScatterAxesSection
value={{ y: { scale: DashboardtypesAxisScaleDTO.log } }}
savedValue={{ y: { scale: DashboardtypesAxisScaleDTO.auto } }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole('button', { name: /reset/i }));
expect(onChange).toHaveBeenCalledWith({
y: { scale: DashboardtypesAxisScaleDTO.auto },
});
});
});

View File

@@ -1,23 +0,0 @@
import { DashboardtypesAxisScaleDTO } from 'api/generated/services/sigNoz.schemas';
import {
pickScaleOptions,
SCALE_HELP as AXIS_SCALE_HELP,
} from '../AxesSection/options';
export const SCALE_OPTIONS = pickScaleOptions([
DashboardtypesAxisScaleDTO.auto,
DashboardtypesAxisScaleDTO.linear,
DashboardtypesAxisScaleDTO.log,
DashboardtypesAxisScaleDTO.symlog,
]);
export const LOG_RANGE_IGNORED_HELP =
"A log axis can't reach 0 or below, so that bound is ignored. Use Symlog to include it.";
/** A log axis leaves out groups it can't place, rather than drawing them elsewhere. */
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
...AXIS_SCALE_HELP,
[DashboardtypesAxisScaleDTO.log]:
"Spreads out values that span orders of magnitude. Groups with a value of 0 or less can't be placed and are left out.",
};

View File

@@ -1,17 +0,0 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
/** A log axis cannot place a soft bound at or below zero, so it drops it. */
export function hasBoundALogAxisDrops(
scale: DashboardtypesAxisScaleDTO,
axis: DashboardtypesScatterPlotAxisDTO | undefined,
): boolean {
return (
scale === DashboardtypesAxisScaleDTO.log &&
[axis?.softMin, axis?.softMax].some(
(bound) => typeof bound === 'number' && bound <= 0,
)
);
}

View File

@@ -1,52 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getScatterAxisColumnNames } from '../scatterAxisColumns';
const COLUMNS = [
{ key: 'A', label: 'A.Request rate', name: 'Request rate' },
{ key: 'B', label: 'B.p99 latency', name: 'p99 latency' },
{ key: 'C', label: 'C.Errors', name: 'Errors' },
];
const scatterSpec = (dimensions: {
x?: string;
y?: string;
}): DashboardtypesPanelSpecDTO =>
({
plugin: { kind: 'signoz/ScatterPlotPanel', spec: { dimensions } },
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
describe('getScatterAxisColumnNames', () => {
it('names the first two value columns when nothing is bound', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), COLUMNS)).toStrictEqual({
x: 'Request rate',
y: 'p99 latency',
});
});
it('follows the bound columns, with Y avoiding the column X took', () => {
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'A', y: 'C' }), COLUMNS),
).toStrictEqual({ x: 'Request rate', y: 'Errors' });
expect(
getScatterAxisColumnNames(scatterSpec({ x: 'B' }), COLUMNS),
).toStrictEqual({ x: 'p99 latency', y: 'Request rate' });
});
it('is empty before results load and for other kinds', () => {
expect(getScatterAxisColumnNames(scatterSpec({}), [])).toStrictEqual({
x: undefined,
y: undefined,
});
expect(
getScatterAxisColumnNames(
{
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO,
COLUMNS,
),
).toStrictEqual({});
});
});

View File

@@ -1,41 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { getSizeColumnLabel } from '../sizeColumnLabel';
const scatterSpec = (size?: string): DashboardtypesPanelSpecDTO =>
({
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { dimensions: { sizeBy: size } },
},
queries: [],
}) as unknown as DashboardtypesPanelSpecDTO;
const COLUMNS = [{ key: 'B', label: 'B.count()', name: 'count()' }];
describe('getSizeColumnLabel', () => {
it('names the bound column by its label', () => {
expect(getSizeColumnLabel(scatterSpec('B'), COLUMNS)).toBe('B.count()');
});
it('is unset when nothing is bound', () => {
expect(getSizeColumnLabel(scatterSpec(''), COLUMNS)).toBeUndefined();
expect(getSizeColumnLabel(scatterSpec(), COLUMNS)).toBeUndefined();
});
it('is unset for a bound key the loaded result no longer has, as the chart draws it', () => {
expect(getSizeColumnLabel(scatterSpec('C'), COLUMNS)).toBeUndefined();
});
it('falls back to the key before the result loads', () => {
expect(getSizeColumnLabel(scatterSpec('B'), [])).toBe('B');
});
it('is unset for other kinds', () => {
const spec = {
plugin: { kind: 'signoz/TablePanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
expect(getSizeColumnLabel(spec, COLUMNS)).toBeUndefined();
});
});

View File

@@ -1,33 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* The columns a Scatter Plot's axes plot, as the renderer picks them: a bound
* key, else X takes the first value column and Y the first one X did not take.
*/
export function resolveAutoAxes(
columns: TableColumnOption[],
bound: { x?: string; y?: string },
): { x?: TableColumnOption; y?: TableColumnOption } {
const x = columns.find((column) => column.key === bound.x) ?? columns[0];
const y =
columns.find((column) => column.key === bound.y) ??
columns.find((column) => column !== x);
return { x, y };
}
/** The name of the column each axis plots; unset for other kinds or before results load. */
export function getScatterAxisColumnNames(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): { x?: string; y?: string } {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return {};
}
const { x, y } = resolveAutoAxes(tableColumns, {
x: spec.plugin.spec.dimensions?.x,
y: spec.plugin.spec.dimensions?.y,
});
return { x: x?.name, y: y?.name };
}

View File

@@ -1,24 +0,0 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import type { TableColumnOption } from '../../hooks/useTableColumns';
/**
* What the dots are sized by, mirroring the renderer: a bound key the loaded
* result no longer has sizes nothing. Before the result loads the key stands in.
*/
export function getSizeColumnLabel(
spec: DashboardtypesPanelSpecDTO,
tableColumns: TableColumnOption[] = [],
): string | undefined {
if (spec.plugin.kind !== 'signoz/ScatterPlotPanel') {
return undefined;
}
const key = spec.plugin.spec.dimensions?.sizeBy;
if (!key) {
return undefined;
}
if (tableColumns.length === 0) {
return key;
}
return tableColumns.find((column) => column.key === key)?.label;
}

View File

@@ -24,7 +24,6 @@ import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDef
import { toPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
import { useSyncQueryBuilderFields } from './useSyncQueryBuilderFields';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
@@ -63,7 +62,6 @@ function PanelEditorQueryBuilder({
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
useSyncQueryBuilderFields(panelDefinition.syncedQueryBuilderFields);
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {

View File

@@ -14,9 +14,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
jest.mock('../useSyncQueryBuilderFields', () => ({
useSyncQueryBuilderFields: jest.fn(),
}));
jest.mock('components/QueryBuilderV2/QueryBuilderV2', () => ({
QueryBuilderV2: (props: unknown): null => {
mockQueryBuilderV2(props);

View File

@@ -1,112 +0,0 @@
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
import {
snapshotQueries,
syncQueryBuilderFields,
} from '../syncQueryBuilderFields';
const key = (name: string): BaseAutocompleteData =>
({ key: name }) as BaseAutocompleteData;
function query(
queryName: string,
{
groupBy = [],
limit = null,
dataSource = 'traces',
}: { groupBy?: string[]; limit?: number | null; dataSource?: string } = {},
): IBuilderQuery {
return {
queryName,
groupBy: groupBy.map(key),
limit,
dataSource,
} as IBuilderQuery;
}
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
describe('syncQueryBuilderFields', () => {
it('copies an edited field to the other queries', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('gives a new query the existing values', () => {
const previous = snapshotQueries([query('A', { groupBy: ['service.name'] })]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it("gives a query switched to another signal the others' values, not its reset ones", () => {
const previous = snapshotQueries([
query('A', { groupBy: ['service.name'] }),
query('B', { groupBy: ['service.name'], dataSource: 'metrics' }),
]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([
{ index: 1, query: query('B', { groupBy: ['service.name'] }) },
]);
});
it('leaves values that already differ until one is edited', () => {
const queries = [query('A', { groupBy: ['host.name'] }), query('B')];
expect(
syncQueryBuilderFields(queries, snapshotQueries(queries), GROUP_BY),
).toStrictEqual([]);
});
it('syncs each field from its own source', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { groupBy: ['service.name'] }), query('B', { limit: 50 })],
previous,
[QueryBuilderField.GroupBy, QueryBuilderField.Limit],
),
).toStrictEqual([
{ index: 0, query: query('A', { groupBy: ['service.name'], limit: 50 }) },
{ index: 1, query: query('B', { groupBy: ['service.name'], limit: 50 }) },
]);
});
it('leaves fields it is not asked to sync', () => {
const previous = snapshotQueries([query('A'), query('B')]);
expect(
syncQueryBuilderFields(
[query('A', { limit: 50 }), query('B')],
previous,
GROUP_BY,
),
).toStrictEqual([]);
});
});

View File

@@ -1,62 +0,0 @@
import { renderHook } from '@testing-library/react';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../../Panels/types/panelCapabilities';
import { useSyncQueryBuilderFields } from '../useSyncQueryBuilderFields';
const mockHandleSetQueryData = jest.fn();
let mockQueries: IBuilderQuery[] = [];
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: { builder: { queryData: mockQueries } },
handleSetQueryData: mockHandleSetQueryData,
}),
}));
const query = (queryName: string, groupBy: string[]): IBuilderQuery =>
({
queryName,
groupBy: groupBy.map((name) => ({ key: name }) as BaseAutocompleteData),
dataSource: 'traces',
}) as IBuilderQuery;
const GROUP_BY: SyncedQueryBuilderField[] = [QueryBuilderField.GroupBy];
function setup(
fields: SyncedQueryBuilderField[] | undefined,
initial: IBuilderQuery[],
): (next: IBuilderQuery[]) => void {
mockQueries = initial;
const { rerender } = renderHook(() => useSyncQueryBuilderFields(fields));
return (next): void => {
mockQueries = next;
rerender();
};
}
describe('useSyncQueryBuilderFields', () => {
beforeEach(() => mockHandleSetQueryData.mockClear());
it('writes the synced value into the other queries', () => {
const update = setup(GROUP_BY, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).toHaveBeenCalledTimes(1);
expect(mockHandleSetQueryData).toHaveBeenCalledWith(
1,
query('B', ['service.name']),
);
});
it('does nothing when the kind syncs no fields', () => {
const update = setup(undefined, [query('A', []), query('B', [])]);
update([query('A', ['service.name']), query('B', [])]);
expect(mockHandleSetQueryData).not.toHaveBeenCalled();
});
});

View File

@@ -1,72 +0,0 @@
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import { isEqual } from 'lodash-es';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
const QUERY_PROPERTY = {
[QueryBuilderField.GroupBy]: 'groupBy',
[QueryBuilderField.OrderBy]: 'orderBy',
[QueryBuilderField.Having]: 'having',
[QueryBuilderField.Limit]: 'limit',
[QueryBuilderField.StepInterval]: 'stepInterval',
[QueryBuilderField.ReduceTo]: 'reduceTo',
} as const satisfies Record<SyncedQueryBuilderField, keyof IBuilderQuery>;
export function snapshotQueries(
queries: IBuilderQuery[],
): Map<string, IBuilderQuery> {
return new Map(queries.map((query) => [query.queryName, query]));
}
/**
* The query each field should be copied from: the one whose value just
* changed, or any settled query when one was added or switched signal. A
* signal switch reloads that query's own state, so it is never the source.
*/
function findSource(
property: keyof IBuilderQuery,
settled: IBuilderQuery[],
hasNewcomer: boolean,
previous: Map<string, IBuilderQuery>,
): IBuilderQuery | undefined {
const changed = settled.find(
(query) =>
!isEqual(previous.get(query.queryName)?.[property], query[property]),
);
return changed ?? (hasNewcomer ? settled[0] : undefined);
}
/** The queries that need rewriting so every synced field matches its source. */
export function syncQueryBuilderFields(
queries: IBuilderQuery[],
previous: Map<string, IBuilderQuery>,
fields: SyncedQueryBuilderField[],
): { index: number; query: IBuilderQuery }[] {
const settled = queries.filter((query) => {
const before = previous.get(query.queryName);
return (
before?.dataSource === query.dataSource && before.source === query.source
);
});
const hasNewcomer = settled.length < queries.length;
const sources = fields
.map((field) => QUERY_PROPERTY[field])
.map((property) => ({
property,
source: findSource(property, settled, hasNewcomer, previous),
}));
return queries.flatMap((query, index) => {
const patch = sources.reduce<Partial<IBuilderQuery>>(
(acc, { property, source }) =>
source && !isEqual(query[property], source[property])
? { ...acc, [property]: source[property] }
: acc,
{},
);
return Object.keys(patch).length > 0
? [{ index, query: { ...query, ...patch } }]
: [];
});
}

View File

@@ -1,37 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import type { SyncedQueryBuilderField } from '../../Panels/types/panelCapabilities';
import {
snapshotQueries,
syncQueryBuilderFields,
} from './syncQueryBuilderFields';
const NO_FIELDS: SyncedQueryBuilderField[] = [];
/**
* Keeps `fields` equal across builder queries: an edit to one query copies to
* the rest, and a query added or switched to another signal takes the others'
* values. Values that already differ when this starts are left alone until one
* of them is edited.
*/
export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const queries = currentQuery.builder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;
useEffect(() => {
const previous = previousRef.current;
previousRef.current = enabled ? snapshotQueries(queries) : undefined;
if (!enabled || !previous || queries.length < 2) {
return;
}
syncQueryBuilderFields(queries, previous, fields).forEach(
({ index, query }) => handleSetQueryData(index, query),
);
}, [enabled, fields, queries, handleSetQueryData]);
}

View File

@@ -35,7 +35,6 @@ import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useGroupColumns } from './hooks/useGroupColumns';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
@@ -197,7 +196,6 @@ function QueryEditorBody({
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
const groupColumns = useGroupColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
@@ -328,7 +326,6 @@ function QueryEditorBody({
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>

View File

@@ -1,69 +0,0 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useGroupColumns } from '../useGroupColumns';
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables',
() => ({ prepareScalarTables: jest.fn() }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData',
() => ({ getScalarResults: jest.fn(() => []) }),
);
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const DATA = {
response: undefined,
legendMap: {},
requestPayload: undefined,
} as unknown as PanelQueryData;
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind, spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
}
describe('useGroupColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns [] for a kind that does not join its scalar rows', () => {
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/PieChartPanel'), DATA),
);
expect(result.current).toStrictEqual([]);
expect(mockPrepareScalarTables).not.toHaveBeenCalled();
});
it('keeps only the group-by columns, by key', () => {
mockPrepareScalarTables.mockReturnValue([
{ columns: [], rows: [] },
{
columns: [
{
id: 'k8s.namespace.name',
name: 'k8s.namespace.name',
isValueColumn: false,
},
{ id: 'A', name: 'cpu', isValueColumn: true },
{ id: 'k8s.pod.name', name: 'k8s.pod.name', isValueColumn: false },
],
rows: [],
},
]);
const { result } = renderHook(() =>
useGroupColumns(panelOfKind('signoz/ScatterPlotPanel'), DATA),
);
expect(result.current).toStrictEqual(['k8s.namespace.name', 'k8s.pod.name']);
});
});

View File

@@ -3,7 +3,6 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { useIsDarkMode } from 'hooks/useDarkMode';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { flattenTimeSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
@@ -48,7 +47,6 @@ const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
const mockGenerateColor = generateColor as unknown as jest.Mock;
const mockPreparePie = preparePieData as unknown as jest.Mock;
const mockPrepareScalarTables = prepareScalarTables as unknown as jest.Mock;
const PANEL = {
kind: 'Panel',
@@ -62,17 +60,6 @@ const HISTOGRAM_PANEL = {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/HistogramPanel', spec: {} }, queries: [] },
} as unknown as DashboardtypesPanelDTO;
const scatterPanel = (colorBy?: string[]): DashboardtypesPanelDTO =>
({
kind: 'Panel',
spec: {
plugin: {
kind: 'signoz/ScatterPlotPanel',
spec: { dimensions: { colorBy } },
},
queries: [],
},
}) as unknown as DashboardtypesPanelDTO;
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
// Each flattened series carries the label resolveSeriesLabelV5 should report.
@@ -146,36 +133,6 @@ describe('useLegendSeries', () => {
expect(mockFlatten).not.toHaveBeenCalled();
});
it('resolves scatter plot panels by the colour-by label', () => {
mockPrepareScalarTables.mockReturnValue([
{
queryName: 'A',
legend: '',
columns: [
{ id: 'ns', name: 'ns', queryName: '', isValueColumn: false },
{ id: 'pod', name: 'pod', queryName: '', isValueColumn: false },
{ id: 'A', name: 'A', queryName: 'A', isValueColumn: true },
{ id: 'B', name: 'B', queryName: 'B', isValueColumn: true },
],
rows: [
{ data: { ns: 'prod', pod: 'p1', A: 1, B: 2 } },
{ data: { ns: 'dev', pod: 'p2', A: 3, B: 4 } },
{ data: { ns: 'prod', pod: 'p3', A: 5, B: 6 } },
],
},
]);
const { result } = renderHook(() =>
useLegendSeries(scatterPanel(['ns']), DATA),
);
expect(result.current).toStrictEqual([
{ label: 'prod', defaultColor: 'color:prod' },
{ label: 'dev', defaultColor: 'color:dev' },
]);
expect(mockFlatten).not.toHaveBeenCalled();
});
it('uses the dark palette in dark mode and the light palette otherwise', () => {
mockFlatten.mockReturnValue(seriesWithLabels(['a']));

View File

@@ -39,8 +39,8 @@ function columnUnits(spec: DashboardtypesPanelSpecDTO): unknown {
}
const COLUMNS: TableColumnOption[] = [
{ key: 'A', label: 'A', name: 'A' },
{ key: 'B', label: 'B', name: 'B' },
{ key: 'A', label: 'A' },
{ key: 'B', label: 'B' },
];
const NO_COLUMNS: TableColumnOption[] = [];

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