Compare commits

..

8 Commits

Author SHA1 Message Date
Nikhil Soni
f15f8f9a79 chore(openapi): regenerate spec for span scope
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:22:55 +05:30
Nikhil Soni
f8d9bd4bdb feat(traces): return instrumentation scope in waterfall, flamegraph and list view
Scope attributes stay flat dotted keys, matching span attributes.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:22:55 +05:30
Nikhil Soni
b065064d89 refactor(tracedetail): drop unused orgID from waterfall module
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:48:02 +05:30
Nikhil Soni
21880d50cb refactor(tracedetail): always read thread JSON attributes, drop unused store orgID
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:25:58 +05:30
Nikhil Soni
e9513112c2 refactor(tracedetail): inline attribute columns, gate thread JSON read on flag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:02:16 +05:30
Nikhil Soni
cb06b13053 refactor(tracedetail): keep orgID on waterfall store reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:49:36 +05:30
Nikhil Soni
439e328f5f refactor(tracedetail): read JSON attributes unconditionally, drop orgID
The use_trace_attributes_json flag only gated the read; per-row
JSON-else-maps fallback makes it unnecessary, and orgID existed only to
evaluate it. The thread keeps plain map columns since its WHERE filters
on them and the blanking aliases would shadow them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:44:37 +05:30
Nikhil Soni
b265a1d723 feat(tracedetail): read waterfall/flamegraph attributes from JSON column
Attributes() now prefers the flattened JSON document per span and falls
back to the legacy maps, so waterfall, flamegraph and thread share one
bag. With the flag on, maps are blanked in SQL for rows whose JSON is
non-empty so dual-written spans ship attributes once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:32:47 +05:30
267 changed files with 2326 additions and 16210 deletions

File diff suppressed because it is too large Load Diff

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;

File diff suppressed because it is too large Load Diff

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';
@@ -275,7 +274,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

@@ -18,7 +18,6 @@ import {
STEP_INTERVAL_PLACEHOLDER,
} from '../../queryBuilderFields.utils';
import { isScalarPanelType } from '../panelTypes';
import QueryAggregationSelect from './QueryAggregationSelect';
import './QueryAggregation.styles.scss';
@@ -46,7 +45,10 @@ function QueryAggregationOptions({
const isNonMetricSource =
dataSource === DataSource.TRACES || dataSource === DataSource.LOGS;
if (isNonMetricSource && isScalarPanelType(panelType)) {
if (
isNonMetricSource &&
(panelType === PANEL_TYPES.TABLE || panelType === PANEL_TYPES.PIE)
) {
return { hidden: true, disabled: false, pinned: false };
}

View File

@@ -9,6 +9,7 @@ import {
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import cx from 'classnames';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
@@ -26,7 +27,6 @@ import {
resolveQueryBuilderField,
} from '../queryBuilderFields.utils';
import { isScalarPanelType } from './panelTypes';
import MetricsAggregateSection from './MerticsAggregateSection/MetricsAggregateSection';
import { MetricsSelect } from './MetricsSelect/MetricsSelect';
import QueryAddOns from './QueryAddOns/QueryAddOns';
@@ -112,7 +112,11 @@ export const QueryV2 = forwardRef(function QueryV2(
}
const showReduceTo = useMemo(
() => dataSource === DataSource.METRICS && isScalarPanelType(panelType),
() =>
dataSource === DataSource.METRICS &&
(panelType === PANEL_TYPES.TABLE ||
panelType === PANEL_TYPES.PIE ||
panelType === PANEL_TYPES.VALUE),
[dataSource, panelType],
);

View File

@@ -1,13 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
/** Panels that reduce each query to one value per group over the whole window. */
const SCALAR_PANEL_TYPES: ReadonlySet<string> = new Set<string>([
PANEL_TYPES.TABLE,
PANEL_TYPES.SCATTER,
PANEL_TYPES.PIE,
PANEL_TYPES.VALUE,
]);
export function isScalarPanelType(panelType?: string | null): boolean {
return panelType != null && SCALAR_PANEL_TYPES.has(panelType);
}

View File

@@ -14,17 +14,6 @@ jest.mock('../QueryV2/QueryAggregation/QueryAggregationSelect', () => ({
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 placeholder', () => {
it('shows the configured placeholder', () => {
render(
@@ -45,27 +34,14 @@ describe('QueryAggregationOptions step interval placeholder', () => {
});
it('falls back to Auto', () => {
renderFor(PANEL_TYPES.TIME_SERIES, DataSource.TRACES);
render(
<QueryAggregationOptions
dataSource={DataSource.TRACES}
panelType={PANEL_TYPES.TIME_SERIES}
onAggregationIntervalChange={jest.fn()}
queryData={queryData}
/>,
);
expect(screen.getByPlaceholderText('Auto')).toBeInTheDocument();
});
});
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

@@ -1 +0,0 @@
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;

View File

@@ -2,9 +2,10 @@ import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;

View File

@@ -6,6 +6,44 @@
border-bottom: 1px solid var(--l1-border);
}
.sectionActions {
width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
width 0s linear 0.16s;
}
.checkboxFilter:hover .sectionActions,
.sectionActions.sectionActionsPinned {
width: auto;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sectionActionsPinned .sectionActionHoverOnly {
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);

View File

@@ -11,7 +11,6 @@ import {
QuickFiltersSource,
} from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
export const DEFAULT_FILTER: IQuickFiltersConfig = {
type: FiltersType.CHECKBOX,
@@ -31,13 +30,6 @@ export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
existingQuery: null,
};
// Enough extra values to push a section past the count that shows its search box.
// Named to sort last, so a test's own values stay within the first page.
export const EXTRA_VALUES = Array.from(
{ length: DEFAULT_VISIBLE_ITEMS_COUNT },
(_, index) => `zz-extra-value-${index + 1}`,
);
export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import classNames from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
@@ -16,7 +17,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';
@@ -45,8 +45,14 @@ export default function CheckboxFilterV2(
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const handleToggleSearch = (): void => {
setIsSearchOpen((prev) => !prev);
setSearchText('');
};
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
@@ -115,14 +121,6 @@ export default function CheckboxFilterV2(
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
const handleToggleOpen = (): void => {
if (isOpen) {
setSearchTextDebounced.cancel();
setSearchText('');
}
onToggleOpen();
};
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
@@ -168,22 +166,19 @@ export default function CheckboxFilterV2(
relatedExclusions,
});
// Kept while a search is typed, so narrowing the list does not remove the box,
// and while a refetch still shows the previous, narrowed results.
const wasSearchShown = useRef(false);
const isSearchShown =
searchText !== '' ||
totalCount > DEFAULT_VISIBLE_ITEMS_COUNT ||
(isFetching && wasSearchShown.current);
wasSearchShown.current = isSearchShown;
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
canReset={isSomeFilterPresentForCurrentAttribute}
onToggleOpen={handleToggleOpen}
actionsClassName={classNames(styles.sectionActions, {
[styles.sectionActionsPinned]: isSearchOpen,
})}
resetActionClassName={
isSearchOpen ? styles.sectionActionHoverOnly : undefined
}
onToggleOpen={onToggleOpen}
onToggleSearch={handleToggleSearch}
onClear={onClear}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
@@ -193,9 +188,10 @@ export default function CheckboxFilterV2(
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
{isSearchShown && (
{isSearchOpen && (
<section className={styles.search}>
<Input
autoFocus
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}

View File

@@ -1,25 +1,31 @@
import { useState } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
import classNames from 'classnames';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
canReset: boolean;
actionsClassName?: string;
resetActionClassName?: string;
onToggleOpen: () => void;
onToggleSearch: () => void;
onClear: () => void;
}
export function CheckboxFilterV2Header({
title,
isOpen,
canReset,
actionsClassName,
resetActionClassName,
onToggleOpen,
onToggleSearch,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
@@ -58,14 +64,21 @@ export function CheckboxFilterV2Header({
)}
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
</section>
{isOpen && canReset && (
<section className={styles.rightAction}>
{isOpen && (
<section className={classNames(styles.rightAction, actionsClassName)}>
<SectionActionButton
icon={<Undo2 size={14} />}
className={resetActionClassName}
tooltip="Reset"
onClick={onClear}
testId="checkbox-filter-clear-all"
/>
<SectionActionButton
icon={<Search size={14} />}
tooltip="Search"
onClick={onToggleSearch}
testId="checkbox-filter-search-toggle"
/>
</section>
)}
</section>

View File

@@ -10,7 +10,6 @@ import {
buildQueryBuilderOverrides,
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
getFilterFromCall,
mockFieldsValuesAPI,
renderWithFilter,
@@ -50,7 +49,7 @@ describe('CheckboxFilterV2 - interactions', () => {
const values =
searchTextReceived === ''
? ['production', 'staging', 'development', ...EXTRA_VALUES]
? ['production', 'staging', 'development']
: ['production'];
return res(
@@ -80,6 +79,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -92,41 +92,6 @@ describe('CheckboxFilterV2 - interactions', () => {
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('checkbox-filter-search')).toBeInTheDocument();
});
it('shows no search box when every value fits', async () => {
mockFieldsValuesAPI({ stringValues: EXTRA_VALUES });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId(`checkbox-value-row-${EXTRA_VALUES[0]}`);
expect(
screen.queryByTestId('checkbox-filter-search'),
).not.toBeInTheDocument();
});
it('shows the search box once there are more values than fit', async () => {
mockFieldsValuesAPI({ stringValues: ['production', ...EXTRA_VALUES] });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(
screen.findByTestId('checkbox-filter-search'),
).resolves.toBeInTheDocument();
});
it('filters values via search while preserving existingQuery context', async () => {
@@ -146,7 +111,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: ['production'],
stringValues: ['staging', 'development', ...EXTRA_VALUES],
stringValues: ['staging', 'development'],
numberValues: [],
},
},
@@ -198,6 +163,7 @@ describe('CheckboxFilterV2 - interactions', () => {
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -225,10 +191,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues:
searchText === ''
? ['prod', 'staging', ...EXTRA_VALUES]
: ['prod-match'],
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
numberValues: [],
},
},
@@ -250,6 +213,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -275,8 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues:
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
stringValues: searchText === '' ? ['prod', 'staging'] : [],
numberValues: [],
},
},
@@ -295,6 +258,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
@@ -371,9 +335,7 @@ describe('CheckboxFilterV2 - interactions', () => {
const relatedValues =
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
const stringValues =
searchText === ''
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
: ['pod-a-v2']; // filtered to match 'pod-a'
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
return res(
ctx.status(200),
@@ -404,6 +366,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-pod-a-v1');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
@@ -538,56 +501,6 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
it('clears the search when the filter is collapsed', async () => {
const user = userEvent.setup();
const values = Array.from(
{ length: 12 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: values.filter((value) => value.includes(searchText)),
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
await user.type(screen.getByTestId('checkbox-filter-search'), '11');
await waitFor(() =>
expect(
screen.queryByTestId('checkbox-value-row-value-00'),
).not.toBeInTheDocument(),
);
const header = screen.getByTestId('checkbox-filter-header');
await user.click(header);
await user.click(header);
await screen.findByTestId('checkbox-value-row-value-00');
expect(screen.getByTestId('checkbox-filter-search')).toHaveValue('');
});
});
describe('show more functionality', () => {
@@ -683,7 +596,7 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides reset when the section has no filter applied', async () => {
it('shows the reset action when expanded even with no active filter', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
@@ -698,9 +611,45 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
// Reset is always available on an expanded section now (hover-gated via
// CSS), not conditional on an active filter.
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('does not dispatch on clear when the key has no filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: '' },
},
],
},
}),
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).not.toHaveBeenCalled();
});
it('calls onFilterChange when clear clicked', async () => {

View File

@@ -9,7 +9,6 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
mockFieldsValuesAPI,
mockFieldsValuesAPILoading,
setupServer,
@@ -90,7 +89,7 @@ describe('CheckboxFilterV2 - states', () => {
data: {
values: {
relatedValues: [],
stringValues: ['production', 'staging', ...EXTRA_VALUES],
stringValues: ['production', 'staging'],
numberValues: [],
},
},
@@ -111,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');

View File

@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
canReset: true,
onToggleOpen: jest.fn(),
onToggleSearch: jest.fn(),
onClear: jest.fn(),
};
@@ -30,9 +30,12 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not render reset when collapsed', () => {
it('does not render the section actions when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(
screen.queryByTestId('checkbox-filter-search-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
@@ -47,18 +50,13 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'open');
});
it('renders reset when the section has a filter applied', () => {
it('renders both search and reset actions when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides reset when there is nothing to reset', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen canReset={false} />);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
screen.getByTestId('checkbox-filter-search-toggle'),
).toBeInTheDocument();
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
});
@@ -101,6 +99,25 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleSearch on search click without toggling open', async () => {
const user = userEvent.setup();
const onToggleSearch = jest.fn();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleSearch={onToggleSearch}
onToggleOpen={onToggleOpen}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
expect(onToggleSearch).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
it('calls onClear on reset click without toggling open', async () => {
const user = userEvent.setup();
const onClear = jest.fn();

View File

@@ -9,7 +9,6 @@ import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
import { useCloseOnOutsidePress } from './useCloseOnOutsidePress';
import styles from './QuickFiltersLayout.module.scss';
@@ -62,20 +61,12 @@ function QuickFiltersLayout({
const isSliding = hasQuickFilters && isViewsListOpen;
const openViews = (): void => setIsViewsListOpen(true);
const toggleViews = (): void => setIsViewsListOpen((isOpen) => !isOpen);
const closeViews = (): void => setIsViewsListOpen(false);
const markSidebarPress = useCloseOnOutsidePress(
showSidebar && isSliding,
closeViews,
);
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isViewsListVisible ? undefined : openViews}
isViewsOpen={isViewsListOpen}
onToggleViews={isViewsListPinned ? undefined : toggleViews}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
/>
) : undefined;
@@ -86,7 +77,6 @@ function QuickFiltersLayout({
{showSidebar && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
onPointerDownCapture={markSidebarPress}
data-testid="quick-filters-layout-filters"
>
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
@@ -108,7 +98,9 @@ function QuickFiltersLayout({
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={isViewsListPinned ? undefined : closeViews}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>

View File

@@ -218,62 +218,6 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('opens and closes the panel from the view name', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const toggle = await screen.findByTestId('saved-views-name-toggle');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await user.click(toggle);
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
await user.click(toggle);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
it('closes the panel on a click outside the sidebar, not inside it', async () => {
render(
<>
<button type="button" data-testid="page-tab">
Pipelines
</button>
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div data-testid="page-content">content</div>
</QuickFiltersLayout>
</>,
);
const user = userEvent.setup();
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('saved-views-panel-header'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
await user.click(screen.getByTestId('page-tab'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('page-content'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
@@ -314,9 +258,6 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-panel-header'),
).not.toBeInTheDocument();

View File

@@ -1,63 +0,0 @@
import { createPortal } from 'react-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
function Harness({
enabled,
onClose,
}: {
enabled: boolean;
onClose: () => void;
}): JSX.Element {
const markInside = useCloseOnOutsidePress(enabled, onClose);
return (
<>
<div onPointerDownCapture={markInside}>
<button type="button" data-testid="inside">
inside
</button>
{createPortal(
<button type="button" data-testid="portal">
menu item
</button>,
document.body,
)}
</div>
<button type="button" data-testid="outside">
outside
</button>
</>
);
}
describe('useCloseOnOutsidePress', () => {
it('closes on a press outside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open on a press inside, including a portal rendered from inside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside'));
await userEvent.click(screen.getByTestId('portal'));
expect(onClose).not.toHaveBeenCalled();
});
it('does nothing while disabled', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled={false} onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,39 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Calls `onClose` on any press outside the element given the returned handler as
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
* inside: React events follow the component tree, so their presses reach the
* handler before the document listener runs.
*/
export function useCloseOnOutsidePress(
enabled: boolean,
onClose: () => void,
): () => void {
const isPressInside = useRef(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!enabled) {
return undefined;
}
isPressInside.current = false;
const handlePointerDown = (): void => {
if (isPressInside.current) {
isPressInside.current = false;
return;
}
onCloseRef.current();
};
document.addEventListener('pointerdown', handlePointerDown);
return (): void =>
document.removeEventListener('pointerdown', handlePointerDown);
}, [enabled]);
return useCallback((): void => {
isPressInside.current = true;
}, []);
}

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

@@ -12,7 +12,6 @@ import {
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
MY_VIEW_NAME,
@@ -26,7 +25,6 @@ import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsNameToggle from './SavedViewsNameToggle';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
@@ -35,16 +33,11 @@ import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
isViewsOpen = false,
onToggleViews,
onCollapse,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
onOpenViews?: () => void;
isViewsOpen?: boolean;
// Absent while the list is pinned open.
onToggleViews?: () => void;
onCollapse?: () => void;
}): JSX.Element {
const {
@@ -57,11 +50,6 @@ function SavedViewsHeader({
} = useActiveSavedView(source);
const { viewKey } = useGetSavedViewParams();
const readPermission = buildSavedViewReadPermission(viewKey);
const { isLoading: isCheckingList, deniedPermissions: deniedList } = useAuthZ(
[SavedViewListPermission],
{ enabled: !!onToggleViews },
);
const canListViews = !isCheckingList && deniedList.length === 0;
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
@@ -95,9 +83,6 @@ function SavedViewsHeader({
name = SAVED_VIEW_LOAD_FAILED_NAME;
}
const isDirty = !isError && !!view && hasUnsavedChanges;
// Same gates as the All views button; an open panel can always be closed.
const canToggleViews =
isViewsOpen || (canListViews && !isDirty && !isReadDenied);
const nameChip = (
<span
@@ -138,17 +123,10 @@ function SavedViewsHeader({
className={styles.skeleton}
testId="saved-views-loading"
/>
) : isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<SavedViewsNameToggle
isOpen={isViewsOpen}
onToggle={canToggleViews ? onToggleViews : undefined}
>
{isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
)}
</SavedViewsNameToggle>
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
)}
</div>

View File

@@ -1,14 +0,0 @@
.toggle {
display: flex;
min-width: 0;
--button-height: auto;
// A flex label lets the chip shrink and use its own ellipsis.
--button-label-display: flex;
}
// The chip inherits its type from the button; doubled to outrank the button's
// size rule so the chip reads the same as when it is not a toggle.
.toggle.toggle > button {
font: inherit;
letter-spacing: inherit;
}

View File

@@ -1,36 +0,0 @@
import { ReactElement } from 'react';
import { Button } from '@signozhq/ui/button';
import styles from './SavedViewsNameToggle.module.scss';
function SavedViewsNameToggle({
isOpen,
onToggle,
children,
}: {
isOpen: boolean;
onToggle?: () => void;
children: ReactElement;
}): JSX.Element {
if (!onToggle) {
return children;
}
return (
<span className={styles.toggle}>
<Button
variant="link"
color="secondary"
size="sm"
textOverflow="hidden"
onClick={onToggle}
aria-expanded={isOpen}
testId="saved-views-name-toggle"
>
{children}
</Button>
</span>
);
}
export default SavedViewsNameToggle;

View File

@@ -52,7 +52,6 @@ function renderHeader(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onToggleViews={jest.fn()}
/>,
url,
);
@@ -263,46 +262,4 @@ describe('SavedViews - AuthZ', () => {
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
);
});
describe('view name toggle', () => {
it('toggles the views from the name when list is allowed', async () => {
mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await expect(
screen.findByTestId('saved-views-name-toggle'),
).resolves.toBeInTheDocument();
});
it('leaves the name plain without list, the All views button says why', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewListPermission));
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).toHaveAttribute(
'aria-disabled',
'true',
),
);
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
it('leaves the name plain on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -30,14 +30,11 @@ const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
isViewsOpen = false,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
isViewsOpen={isViewsOpen}
onToggleViews={jest.fn()}
/>,
url,
);
@@ -48,9 +45,8 @@ const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
isViewsOpen = false,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view), isViewsOpen);
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
@@ -230,36 +226,4 @@ describe('SavedViewsHeader saving', () => {
);
expect(requests.created).toHaveLength(0);
});
describe('view name toggle with unsaved changes', () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
it('stops opening the views from the name once the view changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await screen.findByTestId('saved-views-name-toggle');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
it('still closes open views from the name', async () => {
mockSavedViewsApi([errors]);
await openDirtyView(errors, true);
expect(screen.getByTestId('saved-views-name-toggle')).toBeInTheDocument();
});
});
});

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';
};
@@ -151,7 +150,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,94 +0,0 @@
// Sections own the padding so the footer spans edge to edge.
.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;
}
.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);
}
.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,139 +0,0 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { Pin } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { ScatterTooltipProps } from '../types';
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
import Styles from './ScatterTooltip.module.scss';
/** Own component: the shared Tooltip lists one row per series at a shared x. */
export default function ScatterTooltip({
uPlotInstance,
dataIndexes,
seriesIndex,
channels,
resolvePointGroupValues,
decimalPrecision,
isPinned,
dismiss,
renderTooltipFooter,
}: ScatterTooltipProps): JSX.Element | null {
const hoveredPoint = useMemo(
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
[uPlotInstance, seriesIndex, dataIndexes],
);
const rows = useMemo(
() =>
hoveredPoint
? buildChannelRows(hoveredPoint, channels, decimalPrecision)
: [],
[hoveredPoint, channels, decimalPrecision],
);
const labels = useMemo(
() =>
hoveredPoint
? (resolvePointGroupValues?.(
hoveredPoint.seriesIndex,
hoveredPoint.dataIndex,
) ?? [])
: [],
[hoveredPoint, resolvePointGroupValues],
);
if (!hoveredPoint) {
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: hoveredPoint.color,
borderColor: hoveredPoint.color,
}}
/>
<Typography.Text
as="span"
className={Styles.title}
title={hoveredPoint.label}
testId="scatter-tooltip-title"
>
{hoveredPoint.label}
</Typography.Text>
{isPinned && (
<Button
variant="ghost"
color="secondary"
size="sm"
prefix={<Pin size={12} />}
testId="scatter-tooltip-status"
>
Pinned
</Button>
)}
</div>
<span className={Styles.divider} />
<div className={Styles.rows}>
{rows.map((row) => (
<div
key={row.label}
className={Styles.row}
data-testid="scatter-tooltip-row"
>
<Typography.Text as="span" className={Styles.rowLabel}>
{row.label}
</Typography.Text>
<Typography.Text as="span" className={Styles.rowValue}>
{row.value}
</Typography.Text>
</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"
>
<Typography.Text
as="span"
className={Styles.rowLabel}
title={label.key}
>
{label.key}
</Typography.Text>
<Typography.Text
as="span"
className={Styles.rowValue}
title={label.value}
>
{label.value}
</Typography.Text>
</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,97 +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;
}
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,16 +119,6 @@ export interface HeatmapTooltipProps
groupColor: string;
}
export interface ScatterTooltipProps
extends BaseTooltipProps, TooltipRenderArgs {
channels: ScatterChannels;
/** The group values behind a point, e.g. `service.name` → `cart`. */
resolvePointGroupValues?: (
seriesIndex: number,
dataIndex: number,
) => ScatterPointLabel[];
}
export type TooltipProps =
| TimeSeriesTooltipProps
| BarTooltipProps

View File

@@ -39,16 +39,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
};
}
/** Styled like the grid. */
private buildBorderConfig(
grid: uPlot.Axis.Grid | undefined,
): uPlot.Axis.Border | undefined {
if (this.props.showBorder === false || !grid || grid.show === false) {
return undefined;
}
return { show: true, stroke: grid.stroke, width: grid.width };
}
/**
* Build ticks configuration
*/
@@ -69,47 +59,31 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build values formatter for X-axis (time)
* A non-time x axis with a unit or precision formats like a value axis.
*/
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 Y-axis (values with units)
* Blanks splits outside the scale; uPlot's asinh splits always include ±threshold.
*/
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}`;
});
};
}
/**
@@ -127,7 +101,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildValueAxisFormatter()
? this.buildYAxisValuesFormatter()
: undefined;
}
@@ -175,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();
@@ -205,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;
}
@@ -220,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

@@ -20,7 +20,6 @@ import {
ConfigBuilder,
ConfigBuilderProps,
LegendItem,
PlotMode,
SelectionPreferencesSource,
StackMode,
} from './types';
@@ -67,8 +66,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private stackMode: StackMode = StackMode.None;
private mode: PlotMode = PlotMode.Aligned;
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
@@ -166,10 +163,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
return this.stackMode;
}
setMode(mode: PlotMode): void {
this.mode = mode;
}
/**
* Add or merge a scale configuration
*/
@@ -526,10 +519,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

@@ -40,7 +40,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
range,
thresholds,
logBase = 10,
asinhThreshold,
padMinBy = 0,
padMaxBy = 0.05,
} = this.props;
@@ -100,7 +99,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
time,
distr,
logBase,
asinhThreshold,
});
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =

View File

@@ -88,8 +88,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) {
@@ -125,7 +123,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
return { paths: pathBuilder };
}
if (isMarkerOnlyDrawStyle(drawStyle)) {
if (drawStyle === DrawStyle.Points) {
return { paths: (): null => null };
}
@@ -197,10 +195,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;
}
@@ -232,7 +226,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
show = true,
metric,
partialTimestamps,
facets,
} = this.props;
const resolvedLineColor = this.getLineColor();
@@ -262,7 +255,6 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
...(facets && { facets }),
};
}
}
@@ -278,11 +270,6 @@ interface PathBuilders {
/**
* Get path builder based on draw style and interpolation
*/
/** Draws standalone markers, never a connecting line. */
function isMarkerOnlyDrawStyle(drawStyle: DrawStyle): boolean {
return drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter;
}
function getPathBuilder({
drawStyle,
lineInterpolation,

View File

@@ -120,41 +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('leaves the axis line off when the grid is hidden', () => {
expect(
new UPlotAxisBuilder(createAxisProps({ grid: { show: 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(
@@ -411,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
@@ -672,14 +667,3 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
});
});
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.getConfig().mode).toBe(2);
});
});

View File

@@ -425,39 +425,3 @@ describe('UPlotSeriesBuilder', () => {
);
});
});
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

@@ -80,10 +80,6 @@ export interface AxisProps {
values?: uPlot.Axis.Values;
/** 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,7 +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 y axis ticks are formatted in (`spec.formatting.unit`); 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
@@ -113,13 +109,6 @@ export interface AxisProps {
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
/** arcsinh: linear within ±`asinhThreshold`, log beyond; places zero and negatives. */
SymmetricLog = 'symlog',
}
export enum PlotMode {
Aligned = 1,
Faceted = 2,
}
export interface ScaleProps {
@@ -136,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 {
@@ -159,7 +146,6 @@ export enum DrawStyle {
Points = 'points',
Bar = 'bar',
Histogram = 'histogram',
Scatter = 'scatter',
}
export enum LineInterpolation {
@@ -246,8 +232,6 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
metric?: { [key: string]: string };
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
partialTimestamps?: Set<number>;
/** Mode 2 only: the scales the series' own x and y columns are read against. */
facets?: Series.Facet[];
}
export interface LegendItem {

View File

@@ -1,56 +0,0 @@
import type uPlot from 'uplot';
import { thresholdsDrawHook } from '../useThresholdsDrawHook';
function mockPlot(ori: 0 | 1): { plot: uPlot; ctx: Record<string, jest.Mock> } {
const ctx = {
save: jest.fn(),
restore: jest.fn(),
setLineDash: jest.fn(),
beginPath: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
stroke: jest.fn(),
fillText: jest.fn(),
measureText: jest.fn(() => ({ width: 40 })),
};
const plot = {
ctx: { ...ctx, canvas: { height: 300 } },
bbox: { left: 10, top: 20, width: 400, height: 200 },
scales: { s: { ori } },
valToPos: (): number => 100,
} as unknown as uPlot;
return { plot, ctx };
}
const draw = (plot: uPlot): void =>
(
thresholdsDrawHook({
scaleKey: 's',
thresholds: [{ thresholdValue: 5, thresholdLabel: 'SLO' }],
}) as (u: uPlot) => void
)(plot);
describe('thresholdsDrawHook', () => {
it('draws a horizontal line across a vertical scale', () => {
const { plot, ctx } = mockPlot(1);
draw(plot);
expect(ctx.moveTo).toHaveBeenCalledWith(10, 100);
expect(ctx.lineTo).toHaveBeenCalledWith(410, 100);
expect(ctx.fillText).toHaveBeenCalledWith('SLO', 350, 85);
});
it('draws a vertical line across a horizontal scale', () => {
const { plot, ctx } = mockPlot(0);
draw(plot);
expect(ctx.moveTo).toHaveBeenCalledWith(100, 20);
expect(ctx.lineTo).toHaveBeenCalledWith(100, 220);
expect(ctx.fillText).toHaveBeenCalledWith(
'SLO',
expect.any(Number),
expect.any(Number),
);
});
});

View File

@@ -29,42 +29,13 @@ export function thresholdsDrawHook(
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
ctx.strokeStyle = color;
// A horizontal scale (e.g. a scatter plot's x) takes a vertical line.
if (u.scales[scaleKey]?.ori === 0) {
const x = Math.round(scaleVal);
const top = Math.round(u.bbox.top);
ctx.beginPath();
ctx.moveTo(x, top);
ctx.lineTo(x, Math.round(u.bbox.top + u.bbox.height));
ctx.stroke();
if (threshold.thresholdLabel) {
const gap = 6 * uPlot.pxRatio;
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
const fitsRight = x + gap + textWidth <= u.bbox.left + u.bbox.width;
// uPlot leaves the last axis's text alignment on the context.
ctx.save();
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(
threshold.thresholdLabel,
fitsRight ? x + gap : x - gap - textWidth,
top + gap,
);
ctx.restore();
}
continue;
}
const x0 = Math.round(u.bbox.left);
const y0 = Math.round(scaleVal);
const x1 = Math.round(u.bbox.left + u.bbox.width);
const y1 = Math.round(scaleVal);
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);

View File

@@ -1,116 +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();
// Inside the disc's bounding square, outside the disc.
expect(resolveHit(tree, 15.8, 15.8, 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,290 +0,0 @@
import uPlot from 'uplot';
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;
});
let orient: jest.SpyInstance;
beforeEach(() => {
orient = jest
.spyOn(uPlot, 'orient')
.mockImplementation(orientWithIdentityScales);
});
afterEach(() => {
orient.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(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,89 +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;
}
/** Size extent across all series, so equal values draw equal discs. */
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;
}
/**
* Diameter for a size-channel value. Grows the disc's area, not its width,
* linearly with the value, so a value twice as large reads as twice the ink.
*/
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);
}
/**
* The disc under the cursor, counting `tolerance` px beyond its edge as a hit.
* Where discs overlap, the one whose centre is closest wins; `null` if none.
*/
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 dx = cx - (hit.x + hit.w / 2);
const dy = cy - (hit.y + hit.h / 2);
const distance = dx * dx + dy * dy;
const reach = hit.w / 2 + tolerance;
if (distance > reach * reach) {
return;
}
if (distance < bestDistance) {
best = hit;
bestDistance = distance;
}
},
);
return best;
}

View File

@@ -1,225 +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';
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 ceils a cursor point's left/top but not its size; snap so the ring stays centred. */
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 {
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;
// Spans every series; cached per dataset.
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 };
},
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 holds only what the path builder drew, so force a rebuild.
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,54 +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;
}
/** Parallel columns; a `null` size 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;
}
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,24 +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,
};
// Faceted (scatter): no shared x, so `posToIdx` would throw.
if (plot.data[0] == null) {
return {
xValue,
yValue,
focusedSeries: null,
clickedDataTimestamp: xValue,
...pointer,
};
}
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
const dataIndex = plot.posToIdx(event.offsetX);
@@ -211,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,66 +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('steps evenly through a range too narrow for two 1/2/5 splits', () => {
expect(splitsFor(7.6, 9.4)).toStrictEqual([8, 9]);
expect(splitsFor(8.12, 8.19)).toStrictEqual([
8.12, 8.13, 8.14, 8.15, 8.16, 8.17, 8.18, 8.19,
]);
});
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,64 +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);
// A zero-size query exactly on both midlines.
expect(collect(tree, 50, 50, 0, 0).has(99)).toBe(true);
});
});

View File

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

View File

@@ -1,92 +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];
/** Decades finer than the range's own span that the fallback steps through. */
const FALLBACK_STEP_DECADES = 3;
/** Rounded through toPrecision so 0.1 × 3 noise can't fall outside [min, max]. */
function roundSplit(value: number): number {
return Number(value.toPrecision(12));
}
/** Evenly stepped splits for a range too narrow to hold two 1/2/5 splits. */
function narrowRangeSplits(min: number, max: number): number[] {
const coarsest = Math.floor(Math.log10(max - min));
for (
let exponent = coarsest;
exponent > coarsest - FALLBACK_STEP_DECADES;
exponent--
) {
const step = 10 ** exponent;
const first = Math.ceil(roundSplit(min / step));
const last = Math.floor(roundSplit(max / step));
if (last > first) {
return Array.from({ length: last - first + 1 }, (_, index) =>
roundSplit((first + index) * step),
);
}
}
return [];
}
/** 1, 2, 5 × 10ⁿ in range; uPlot's log splits step from an unsnapped min (10.79, 21.58…). */
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) => {
const split = roundSplit(multiple * 10 ** exponent);
if (split >= min && split <= max) {
splits.push(split);
}
});
}
if (splits.length >= 2) {
return splits;
}
const narrow = narrowRangeSplits(min, max);
return narrow.length > splits.length ? narrow : splits;
};
/** Labels every decade, plus any 2 or 5 at least `space` px clear. */
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,101 +0,0 @@
export interface QuadtreeRect {
x: number;
y: number;
w: number;
h: number;
}
const MAX_OBJECTS = 10;
const MAX_LEVELS = 4;
/** Rect index for cursor hit-testing; items straddling quadrants are stored in each, so `get` may repeat them. */
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),
);
}
}
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;
// Inclusive, so a zero-size query on a midline still reaches a quadrant.
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,

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 } 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 { ScatterAxisScale, type ScatterSeries } from './types';
import { buildScatterConfig, prepareScatterChartData } 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 Y_THRESHOLDS: Threshold[] = [
{
thresholdValue: 300,
thresholdUnit: 'ms',
thresholdColor: '#E5484D',
thresholdLabel: 'p99 SLO',
},
];
const X_THRESHOLDS: Threshold[] = [
{
thresholdValue: 60,
thresholdUnit: 'reqps',
thresholdColor: '#3E63DD',
thresholdLabel: 'Capacity',
},
];
function ScatterStory(props: ScatterStoryProps): JSX.Element {
const {
xScale,
yScale,
sized,
thresholds,
pointSize,
fillOpacity,
width,
height,
} = props;
const isDarkMode = useIsDarkMode();
const series = useMemo(() => buildSeries(props), [props]);
const config = useMemo(
() =>
buildScatterConfig({
id: 'scatter-story',
series,
isDarkMode,
x: {
unit: 'reqps',
scale: xScale,
thresholds: thresholds ? X_THRESHOLDS : undefined,
},
y: {
unit: 'ms',
scale: yScale,
thresholds: thresholds ? Y_THRESHOLDS : undefined,
},
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
fillOpacity,
}),
[series, isDarkMode, xScale, yScale, pointSize, fillOpacity, thresholds],
);
const data = useMemo(() => prepareScatterChartData(series), [series]);
const resolvePointGroupValues = (
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' } }),
}}
resolvePointGroupValues={resolvePointGroupValues}
canPinTooltip
/>
</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' },
};
/** Labelled lines on both axes; each scale stretches to include its line. */
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,98 +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 { getSeriesStroke } from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { ScatterChartProps } from 'lib/visualization/charts/types';
import uPlot from 'uplot';
import { getCursorHit } from './utils';
// Faceted uPlot can't mount without series; empty aligned data shows no-data instead.
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
export default function Scatter(props: ScatterChartProps): JSX.Element {
const {
children,
customTooltip,
channels,
resolvePointGroupValues,
pinnedTooltipElement,
onPointClick,
plotRef,
...rest
} = props;
const plotInstanceRef = useRef<uPlot | null>(null);
const handlePlotRef = useCallback(
(plot: uPlot | null): void => {
plotInstanceRef.current = plot;
plotRef?.(plot);
},
[plotRef],
);
// Faceted plots have no shared x; take the hit from the cursor.
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,
resolvePointGroupValues,
decimalPrecision: rest.decimalPrecision,
canPinTooltip: rest.canPinTooltip,
renderTooltipFooter: rest.renderTooltipFooter,
};
return <ScatterTooltip {...tooltipProps} />;
},
[
customTooltip,
channels,
resolvePointGroupValues,
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,112 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { resolveAxisDistribution } from '../axisDistribution';
import { ScatterAxisScale } from '../types';
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', () => {
const SKEWED = [1, 2, 3, 5, 8, 10, 20, 50, 1000];
it.each([
['skewed positive values', SKEWED, DistributionType.Logarithmic],
[
'values spread evenly across decades',
[1, 250, 500, 750, 1000],
DistributionType.Linear,
],
[
'a single outlier among clustered values',
[0.001, 40, 45, 50, 55, 60],
DistributionType.Linear,
],
[
'skewed values spanning under two decades',
[10, 10, 10, 11, 50],
DistributionType.Linear,
],
['no finite values', [Number.NaN], DistributionType.Linear],
])('picks a scale for %s', (_, values, distribution) => {
expect(resolveAxisDistribution(values, ScatterAxisScale.Auto)).toStrictEqual(
{ distribution },
);
});
it('picks symlog for skewed values that include zero', () => {
expect(
resolveAxisDistribution(
[0, 0, 1, 2, 5, 10, 50, 100, 10_000],
ScatterAxisScale.Auto,
),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
it('picks symlog when a soft limit reaches zero or below', () => {
expect(
resolveAxisDistribution(SKEWED, ScatterAxisScale.Auto, [0]),
).toStrictEqual({
distribution: DistributionType.SymmetricLog,
asinhThreshold: 1,
});
});
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(SKEWED)).toStrictEqual({
distribution: DistributionType.Logarithmic,
});
});
});
});

View File

@@ -1,113 +0,0 @@
import type uPlot from 'uplot';
import { ScatterAxisScale, type ScatterSeries } from '../types';
import { buildScatterConfig } 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('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({
y: {
unit: 'ms',
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
},
}).getConfig();
expect(config.hooks?.draw).toHaveLength(1);
expect(build().getConfig().hooks?.draw).toBeUndefined();
});
it('registers one draw hook per axis with thresholds', () => {
const config = build({
x: { unit: 'reqps', thresholds: [{ thresholdValue: 15 }] },
y: { unit: 'ms', thresholds: [{ thresholdValue: 300 }] },
}).getConfig();
expect(config.hooks?.draw).toHaveLength(2);
});
it('stretches the x range to reach an x threshold', () => {
const config = build({
x: { unit: 'reqps', thresholds: [{ thresholdValue: 1000 }] },
}).getConfig();
const range = config.scales?.x?.range as uPlot.Range.Function;
const plot = { bbox: { width: 0, height: 0 } } as uPlot;
const [, max] = range(plot, 10, 30, 'x');
expect(max).toBeGreaterThanOrEqual(1000);
});
});

View File

@@ -1,98 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import uPlot from 'uplot';
import { createScatterRange } from '../scatterRange';
import type { ScatterRangeOptions } from '../types';
/** 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,51 +0,0 @@
import type uPlot from 'uplot';
import type { ScatterSeries } from '../types';
import { getCursorHit, prepareScatterChartData } 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();
});
});

View File

@@ -1,130 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { getScaleTransform } from './scatterRange';
import { type AxisDistribution, ScatterAxisScale } from './types';
const LINEAR_DISTRIBUTION: AxisDistribution = {
distribution: DistributionType.Linear,
};
/** Orders of magnitude `auto` needs before it considers leaving linear. */
export const AUTO_MIN_DECADES = 2;
/** How many times more axis the middle half of the points must cover for `auto` to leave linear. */
export const AUTO_SPREAD_GAIN = 2;
function getMagnitudeRange(values: number[]): {
minMagnitude: number;
maxMagnitude: number;
hasNonPositive: boolean;
} {
let minMagnitude = Infinity;
let maxMagnitude = -Infinity;
let hasNonPositive = false;
for (const value of values) {
if (!Number.isFinite(value)) {
continue;
}
if (value <= 0) {
hasNonPositive = true;
}
if (value !== 0) {
minMagnitude = Math.min(minMagnitude, Math.abs(value));
maxMagnitude = Math.max(maxMagnitude, Math.abs(value));
}
}
return { minMagnitude, maxMagnitude, hasNonPositive };
}
/** The linear band ends at the smallest non-zero magnitude so small values still spread. */
function symmetricLogDistribution(minMagnitude: number): AxisDistribution {
const asinhThreshold = Number.isFinite(minMagnitude)
? 10 ** Math.floor(Math.log10(minMagnitude))
: 1;
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
}
function quantile(sorted: number[], q: number): number {
const position = (sorted.length - 1) * q;
const lower = Math.floor(position);
const upper = Math.ceil(position);
return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}
/** Share of the axis the middle half of the points covers. */
function middleHalfSpread(
sorted: number[],
[min, max]: [number, number],
distribution: AxisDistribution,
): number {
const { forward } = getScaleTransform(distribution);
const span = forward(max) - forward(min);
return span > 0
? (forward(quantile(sorted, 0.75)) - forward(quantile(sorted, 0.25))) / span
: 0;
}
/**
* Log (symlog with zero or negatives) when it spreads the points out clearly
* more than linear does; min/max alone would let one outlier decide.
*/
function resolveAutoDistribution(
values: number[],
softLimits: number[],
): AxisDistribution {
const sorted = values
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b);
if (sorted.length < 2) {
return LINEAR_DISTRIBUTION;
}
const { minMagnitude, maxMagnitude, hasNonPositive } = getMagnitudeRange([
...sorted,
...softLimits,
]);
if (!(Math.log10(maxMagnitude / minMagnitude) >= AUTO_MIN_DECADES)) {
return LINEAR_DISTRIBUTION;
}
const candidate = hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
const extent: [number, number] = [
Math.min(sorted[0], ...softLimits),
Math.max(sorted[sorted.length - 1], ...softLimits),
];
const candidateSpread = middleHalfSpread(sorted, extent, candidate);
const linearSpread = middleHalfSpread(sorted, extent, LINEAR_DISTRIBUTION);
return candidateSpread > 0 &&
candidateSpread >= AUTO_SPREAD_GAIN * linearSpread
? candidate
: LINEAR_DISTRIBUTION;
}
/** `log` falls back to symlog rather than drop zero or negatives; callers filter first to drop them. */
export function resolveAxisDistribution(
values: number[],
scale: ScatterAxisScale = ScatterAxisScale.Auto,
/** Soft limits and thresholds the axis must reach; only `auto` weighs them. */
softLimits: Array<number | null | undefined> = [],
): AxisDistribution {
switch (scale) {
case ScatterAxisScale.Linear:
return LINEAR_DISTRIBUTION;
case ScatterAxisScale.SymLog:
return symmetricLogDistribution(getMagnitudeRange(values).minMagnitude);
case ScatterAxisScale.Log: {
const { minMagnitude, hasNonPositive } = getMagnitudeRange(values);
return hasNonPositive
? symmetricLogDistribution(minMagnitude)
: { distribution: DistributionType.Logarithmic };
}
case ScatterAxisScale.Auto:
default:
return resolveAutoDistribution(
values,
softLimits.filter(
(limit): limit is number => limit != null && Number.isFinite(limit),
),
);
}
}

View File

@@ -1,92 +0,0 @@
import { DistributionType } from 'lib/uPlotV2/config/types';
import { DEFAULT_ASINH_THRESHOLD } from 'lib/uPlotV2/utils/scale';
import uPlot from 'uplot';
import type {
AxisDistribution,
ScaleTransform,
ScatterRangeOptions,
} from './types';
export function getScaleTransform({
distribution,
asinhThreshold = DEFAULT_ASINH_THRESHOLD,
}: 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,
};
}
}
/** 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;
}
/** Data extent padded by `marginPx` so edge dots draw whole; log stays unsnapped (snapping after the margin can add an 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,67 +0,0 @@
import type { PrecisionOption } from 'components/Graph/types';
import type {
DistributionType,
SelectionPreferencesSource,
} from 'lib/uPlotV2/config/types';
import type { Threshold } from 'lib/uPlotV2/hooks/types';
import type { ScatterPointSize } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
export interface ScatterSeries {
label: string;
xs: number[];
ys: number[];
/** Optional third channel, in the caller's units. */
sizes?: Array<number | null>;
}
export enum ScatterAxisScale {
/** Log or symlog when it spreads the points out more than linear does. */
Auto = 'auto',
Linear = 'linear',
Log = 'log',
/** Log-like, but places zero and negatives. */
SymLog = 'symlog',
}
export interface ScatterAxisOptions {
label?: string;
unit?: string;
softMin?: number | null;
softMax?: number | null;
scale?: ScatterAxisScale;
/** Lines drawn across the plot at values on this axis. */
thresholds?: Threshold[];
}
export interface BuildScatterConfigArgs {
id: string;
series: ScatterSeries[];
isDarkMode: boolean;
x: ScatterAxisOptions;
y: ScatterAxisOptions;
pointSize?: ScatterPointSize;
/** 0–1. */
fillOpacity?: number;
colorMapping?: Record<string, string>;
decimalPrecision?: PrecisionOption;
selectionPreferencesSource?: SelectionPreferencesSource;
shouldSaveSelectionPreference?: boolean;
}
export interface AxisDistribution {
distribution: DistributionType;
asinhThreshold?: number;
}
export interface ScaleTransform {
forward: (value: number) => number;
inverse: (position: number) => number;
}
export interface ScatterRangeOptions extends AxisDistribution {
dimension: 'x' | 'y';
/** CSS pixels kept clear at each end so the largest dot draws whole. */
marginPx: number;
softMin?: number | null;
softMax?: number | null;
}

View File

@@ -1,209 +0,0 @@
import {
type AxisProps,
DistributionType,
DrawStyle,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import {
applyScatterPlugin,
createScatterPlugin,
SCATTER_FACETS,
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
import {
DEFAULT_SCATTER_POINT_SIZE,
ScatterChartData,
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 { resolveAxisDistribution } from './axisDistribution';
import { createScatterRange } from './scatterRange';
import type {
AxisDistribution,
BuildScatterConfigArgs,
ScatterAxisOptions,
ScatterSeries,
} from './types';
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;
/** The plugin's `dataIdx` answers only for the hit series. */
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;
}
/** 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 }
: {};
}
function getThresholdOptions(
scaleKey: 'x' | 'y',
{ thresholds, unit }: ScatterAxisOptions,
): ThresholdsDrawHookOptions | undefined {
return thresholds && thresholds.length > 0
? { scaleKey, thresholds, yAxisUnit: unit }
: undefined;
}
export function buildScatterConfig({
id,
series,
isDarkMode,
x,
y,
pointSize = DEFAULT_SCATTER_POINT_SIZE,
fillOpacity,
colorMapping = {},
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 xSoftLimits = adjustSoftLimitsWithThresholds(
x.softMin ?? null,
x.softMax ?? null,
x.thresholds,
x.unit,
);
const ySoftLimits = adjustSoftLimitsWithThresholds(
y.softMin ?? null,
y.softMax ?? null,
y.thresholds,
y.unit,
);
const xDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.xs),
x.scale,
[xSoftLimits.softMin, xSoftLimits.softMax],
);
const yDistribution = resolveAxisDistribution(
series.flatMap((entry) => entry.ys),
y.scale,
[ySoftLimits.softMin, ySoftLimits.softMax],
);
const xThresholds = getThresholdOptions('x', x);
const yThresholds = getThresholdOptions('y', y);
// 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,
range: createScatterRange({
dimension: 'x',
marginPx,
...xSoftLimits,
...xDistribution,
}),
...xDistribution,
});
builder.addScale({
scaleKey: 'y',
time: false,
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,
});
});
[xThresholds, yThresholds].forEach((options) => {
if (options) {
builder.addThresholds(options);
}
});
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,26 +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;
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;
resolvePointGroupValues?: (
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 columns of the joined scalar result. */
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,
@@ -136,7 +133,6 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
@@ -164,7 +160,6 @@ function ConfigPane({
defaults={defaults}
legendSeries={legendSeries}
tableColumns={tableColumns}
groupColumns={groupColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}

View File

@@ -12,11 +12,6 @@ import type { SectionEditorContext } from '../sectionContext';
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import { isDifferent } from '../utils/changes';
import {
getScatterAxisColumnNames,
getScatterAxisColumnUnits,
} from '../utils/scatterAxisColumns';
import { getSizeColumnLabel } from '../utils/sizeColumnLabel';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
type SectionSlotProps = {
@@ -68,7 +63,6 @@ function SectionSlot({
bare,
legendSeries,
tableColumns,
groupColumns,
signal,
panelKind,
onChangePanelKind,
@@ -80,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);
@@ -138,10 +129,6 @@ function SectionSlot({
legendSeries,
yAxisUnit,
tableColumns,
groupColumns,
sizeColumnLabel: getSizeColumnLabel(spec, tableColumns),
axisColumnNames: getScatterAxisColumnNames(spec, tableColumns),
axisColumnUnits: getScatterAxisColumnUnits(spec, tableColumns),
signal,
panelKind,
onChangePanelKind,

View File

@@ -1,49 +0,0 @@
import { ComboboxSimple } from '@signozhq/ui/combobox';
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[];
placeholder?: string;
items: ConfigMultiSelectItem<T>[];
onChange: (value: T[]) => void;
disabled?: boolean;
}
/** Emits picks in items order, so the stored value is stable. */
function ConfigMultiSelect<T extends string = string>({
testId,
value,
placeholder,
items,
onChange,
disabled,
}: ConfigMultiSelectProps<T>): JSX.Element {
return (
<ComboboxSimple
multiple
className={styles.select}
testId={testId}
value={value}
placeholder={placeholder}
disabled={disabled}
items={items}
onChange={(next): void => {
const picked = Array.isArray(next) ? next : [];
onChange(
items
.map((item) => item.value)
.filter((candidate) => picked.includes(candidate)),
);
}}
/>
);
}
export default ConfigMultiSelect;

View File

@@ -1,48 +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'));
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,21 +0,0 @@
.row {
display: flex;
align-items: center;
gap: 12px;
}
.slider {
flex: 1;
min-width: 0;
// Cancel the slider's half-thumb track inset so it aligns with the other controls.
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,47 +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;
formatValue?: (value: [number, number]) => string;
onChange: (value: [number, number]) => void;
}
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,14 +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[];
/** Scatter Plot's size column label, if bound. */
sizeColumnLabel?: string;
/** Name of the column each Scatter Plot axis plots, for its label's placeholder. */
axisColumnNames?: { x?: string; y?: string };
/** Unit of the column each Scatter Plot axis plots. */
axisColumnUnits?: { 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,86 +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;
sizeColumnLabel?: string;
}
function PointsFields({
value,
savedValue,
onChange,
sizeColumnLabel,
}: PointsFieldsProps): JSX.Element {
const reset = createFieldResetter(value, savedValue, onChange);
// Renderer defaults, so thumbs start 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,24 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: 18px;
}
.labelWithInfo {
display: inline-flex;
align-items: center;
gap: 6px;
}
.info {
flex-shrink: 0;
color: var(--l3-foreground);
cursor: help;
}
.description {
margin: 0;
color: var(--l2-foreground);
font-size: 12px;
line-height: 17px;
}

View File

@@ -1,111 +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 LabelWithInfo from './LabelWithInfo';
import {
buildColorKeyItems,
buildDimensionItems,
formatAutoOption,
formatColumnOption,
UNSET_DIMENSION,
} from './utils';
import styles from './DimensionsSection.module.scss';
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.',
};
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"
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,27 +0,0 @@
import { Info } from '@signozhq/icons';
import { Tooltip } 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}
<Tooltip title={info}>
<Info size={12} className={styles.info} data-testid={testId} />
</Tooltip>
</span>
);
}
export default LabelWithInfo;

View File

@@ -1,184 +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']}
/>,
);
await user.click(screen.getByTestId('panel-editor-v2-dimension-colorBy'));
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'),
).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -1,45 +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';
}
/** Keeps a bound key the result lacks, 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;
}
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,42 +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'>;
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,64 +0,0 @@
import { Input } from '@signozhq/ui/input';
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;
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
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,24 +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.";
export const SCALE_HELP: Record<DashboardtypesAxisScaleDTO, string> = {
...AXIS_SCALE_HELP,
[DashboardtypesAxisScaleDTO.auto]:
'Logarithmic (Symlog with zero or negatives) when that spreads the points out more than linear does; otherwise linear.',
[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,16 +0,0 @@
import {
DashboardtypesAxisScaleDTO,
type DashboardtypesScatterPlotAxisDTO,
} from 'api/generated/services/sigNoz.schemas';
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

@@ -4,10 +4,9 @@ import { Button } from '@signozhq/ui/button';
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesComparisonThresholdDTO,
type DashboardtypesScatterPlotThresholdDTO,
type DashboardtypesTableThresholdDTO,
DashboardtypesThresholdAxisDTO,
DashboardtypesThresholdFormatDTO,
type DashboardtypesThresholdWithLabelDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AnyThreshold,
@@ -45,7 +44,6 @@ const ADD_TESTID: Record<ThresholdVariant, string> = {
function defaultThreshold(
variant: ThresholdVariant,
tableColumns: TableColumnOption[],
withAxis: boolean,
): AnyThreshold {
switch (variant) {
case 'comparison':
@@ -64,23 +62,18 @@ function defaultThreshold(
format: DashboardtypesThresholdFormatDTO.background,
};
default:
return {
value: 0,
color: DEFAULT_THRESHOLD_COLOR,
label: '',
...(withAxis && { axis: DashboardtypesThresholdAxisDTO.y }),
};
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
}
}
type ThresholdsSectionProps = {
value: AnyThreshold[] | undefined;
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant; axis?: boolean };
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
} & Pick<
SectionEditorContext,
'yAxisUnit' | 'tableColumns' | 'axisColumnUnits' | 'registerHeaderAction'
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
>;
/**
@@ -95,11 +88,9 @@ function ThresholdsSection({
onChange,
yAxisUnit,
tableColumns = [],
axisColumnUnits,
registerHeaderAction,
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const withAxis = !!controls?.axis;
const thresholds = value ?? [];
// Which row is being edited, and whether it was just added (so Discard removes it).
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@@ -116,10 +107,10 @@ function ThresholdsSection({
const addThreshold = useCallback((): void => {
const current = value ?? [];
onChange([...current, defaultThreshold(variant, tableColumns, withAxis)]);
onChange([...current, defaultThreshold(variant, tableColumns)]);
setEditingIndex(current.length);
setUnsavedIndex(current.length);
}, [value, onChange, variant, tableColumns, withAxis]);
}, [value, onChange, variant, tableColumns]);
useEffect(() => {
registerHeaderAction?.(addThreshold);
@@ -198,9 +189,7 @@ function ThresholdsSection({
<LabelThresholdRow
// eslint-disable-next-line react/no-array-index-key
key={index}
threshold={threshold as DashboardtypesScatterPlotThresholdDTO}
showAxis={withAxis}
axisUnits={axisColumnUnits}
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
{...common}
/>
);

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