Merge branch 'main' into qb-json-fixes

This commit is contained in:
Piyush Singariya
2026-02-02 13:58:15 +05:30
committed by GitHub
23 changed files with 2956 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/**
* Represents the visibility state of a single series in a graph
*/
export interface SeriesVisibilityItem {
label: string;
show: boolean;
}
/**
* Represents the stored visibility state for a widget/graph
*/
export interface GraphVisibilityState {
name: string;
dataIndex: SeriesVisibilityItem[];
}

View File

@@ -0,0 +1,74 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { GraphVisibilityState, SeriesVisibilityItem } from '../types';
/**
* Retrieves the visibility map for a specific widget from localStorage
* @param widgetId - The unique identifier of the widget
* @returns A Map of series labels to their visibility state, or null if not found
*/
export function getStoredSeriesVisibility(
widgetId: string,
): Map<string, boolean> | null {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
if (!storedData) {
return null;
}
const visibilityStates: GraphVisibilityState[] = JSON.parse(storedData);
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState?.dataIndex?.length) {
return null;
}
return new Map(widgetState.dataIndex.map((item) => [item.label, item.show]));
} catch {
// Silently handle parsing errors - fall back to default visibility
return null;
}
}
export function updateSeriesVisibilityToLocalStorage(
widgetId: string,
seriesVisibility: SeriesVisibilityItem[],
): void {
try {
const storedData = localStorage.getItem(LOCALSTORAGE.GRAPH_VISIBILITY_STATES);
let visibilityStates: GraphVisibilityState[];
if (!storedData) {
visibilityStates = [
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
visibilityStates = JSON.parse(storedData);
}
const widgetState = visibilityStates.find((state) => state.name === widgetId);
if (!widgetState) {
visibilityStates = [
...visibilityStates,
{
name: widgetId,
dataIndex: seriesVisibility,
},
];
} else {
widgetState.dataIndex = seriesVisibility;
}
localStorage.setItem(
LOCALSTORAGE.GRAPH_VISIBILITY_STATES,
JSON.stringify(visibilityStates),
);
} catch {
// Silently handle parsing errors - fall back to default visibility
}
}

View File

@@ -0,0 +1,91 @@
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 4px;
max-width: min(400px, 100%);
cursor: pointer;
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-marker {
border-width: 2px;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
&:hover {
background: rgba(255, 255, 255, 0.05);
}
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo, useRef } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Tooltip as AntdTooltip } from 'antd';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { LegendPosition } from 'types/api/dashboard/getAll';
import { LegendProps } from '../types';
import { useLegendActions } from './useLegendActions';
import './Legend.styles.scss';
const LEGENDS_PER_SET_DEFAULT = 5;
export default function Legend({
position = LegendPosition.BOTTOM,
config,
legendsPerSet = LEGENDS_PER_SET_DEFAULT,
}: LegendProps): JSX.Element {
const {
legendItemsMap,
focusedSeriesIndex,
setFocusedSeriesIndex,
} = useLegendsSync({ config });
const {
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
} = useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const legendContainerRef = useRef<HTMLDivElement | null>(null);
// Chunk legend items into rows of LEGENDS_PER_ROW items each
const legendRows = useMemo(() => {
const legendItems = Object.values(legendItemsMap);
if (legendsPerSet >= legendItems.length) {
return [legendItems];
}
return legendItems.reduce((acc: LegendItem[][], curr, i) => {
if (i % legendsPerSet === 0) {
acc.push([]);
}
acc[acc.length - 1].push(curr);
return acc;
}, [] as LegendItem[][]);
}, [legendItemsMap, legendsPerSet]);
const renderLegendRow = useCallback(
(rowIndex: number, row: LegendItem[]): JSX.Element => (
<div
key={rowIndex}
className={cx(
'legend-row',
`legend-row-${position.toLowerCase()}`,
legendRows.length === 1 && position === LegendPosition.BOTTOM
? 'legend-single-row'
: '',
)}
>
{row.map((item) => (
<AntdTooltip key={item.seriesIndex} title={item.label}>
<div
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<div
className="legend-marker"
style={{ borderColor: String(item.color) }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</AntdTooltip>
))}
</div>
),
[focusedSeriesIndex, position, legendRows],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
>
<Virtuoso
style={{
height: '100%',
width: '100%',
}}
data={legendRows}
itemContent={(index, row): JSX.Element => renderLegendRow(index, row)}
/>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onToggleSeriesVisibility,
} = usePlotContext();
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
onFocusSeries(seriesIndex);
};
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
};
}

View File

@@ -0,0 +1,59 @@
.uplot-tooltip-container {
font-family: 'Inter';
font-size: 12px;
background: var(--bg-ink-300);
-webkit-font-smoothing: antialiased;
color: var(--bg-vanilla-100);
border-radius: 6px;
padding: 1rem 1rem 0.5rem 1rem;
border: 1px solid var(--bg-ink-100);
display: flex;
flex-direction: column;
gap: 8px;
&.lightMode {
background: var(--bg-vanilla-100);
color: var(--bg-ink-500);
border: 1px solid var(--bg-vanilla-300);
}
.uplot-tooltip-header {
font-size: 13px;
font-weight: 500;
}
.uplot-tooltip-list-container {
height: 100%;
.uplot-tooltip-list {
&::-webkit-scrollbar {
width: 0.3rem;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgb(136, 136, 136);
}
}
}
.uplot-tooltip-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
.uplot-tooltip-item-marker {
border-radius: 50%;
border-width: 2px;
width: 12px;
height: 12px;
flex-shrink: 0;
}
.uplot-tooltip-item-content {
white-space: wrap;
word-break: break-all;
}
}
}

View File

@@ -0,0 +1,94 @@
import { useMemo } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { TooltipContentItem, TooltipProps } from '../types';
import { buildTooltipContent } from './utils';
import './Tooltip.styles.scss';
const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
export default function Tooltip({
seriesIndex,
dataIndexes,
uPlotInstance,
timezone,
yAxisUnit = '',
decimalPrecision,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const headerTitle = useMemo(() => {
const data = uPlotInstance.data;
const cursorIdx = uPlotInstance.cursor.idx;
if (cursorIdx == null) {
return null;
}
return dayjs(data[0][cursorIdx] * 1000)
.tz(timezone)
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
}, [timezone, uPlotInstance.data, uPlotInstance.cursor.idx]);
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: uPlotInstance.data,
series: uPlotInstance.series,
dataIndexes,
activeSeriesIdx: seriesIndex,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}),
[uPlotInstance, seriesIndex, dataIndexes, yAxisUnit, decimalPrecision],
);
return (
<div
className={cx(
'uplot-tooltip-container',
isDarkMode ? 'darkMode' : 'lightMode',
)}
>
<div className="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
<div
style={{
height: Math.min(
content.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
minHeight: 0,
}}
>
{content.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data={content}
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item">
<div
className="uplot-tooltip-item-marker"
style={{ borderColor: item.color }}
data-is-legend-marker={true}
/>
<div
className="uplot-tooltip-item-content"
style={{ color: item.color, fontWeight: item.isActive ? 700 : 400 }}
>
{item.label}: {item.tooltipValue}
</div>
</div>
)}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { TooltipContentItem } from '../types';
const FALLBACK_SERIES_COLOR = '#000000';
export function resolveSeriesColor(
stroke: Series.Stroke | undefined,
u: uPlot,
seriesIdx: number,
): string {
if (typeof stroke === 'function') {
return String(stroke(u, seriesIdx));
}
if (typeof stroke === 'string') {
return stroke;
}
return FALLBACK_SERIES_COLOR;
}
export function buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIdx,
uPlotInstance,
yAxisUnit,
decimalPrecision,
}: {
data: AlignedData;
series: Series[];
dataIndexes: Array<number | null>;
activeSeriesIdx: number | null;
uPlotInstance: uPlot;
yAxisUnit: string;
decimalPrecision?: PrecisionOption;
}): TooltipContentItem[] {
const active: TooltipContentItem[] = [];
const rest: TooltipContentItem[] = [];
for (let idx = 1; idx < series.length; idx += 1) {
const s = series[idx];
if (!s?.show) {
continue;
}
const dataIdx = dataIndexes[idx];
// Skip series with no data at the current cursor position
if (dataIdx === null) {
continue;
}
const raw = data[idx]?.[dataIdx];
const value = Number(raw);
const displayValue = Number.isNaN(value) ? 0 : value;
const isActive = idx === activeSeriesIdx;
const item: TooltipContentItem = {
label: String(s.label ?? ''),
value: displayValue,
tooltipValue: getToolTipValue(displayValue, yAxisUnit, decimalPrecision),
color: resolveSeriesColor(s.stroke, uPlotInstance, idx),
isActive,
};
if (isActive) {
active.push(item);
} else {
rest.push(item);
}
}
return [...active, ...rest];
}

View File

@@ -0,0 +1,199 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import * as Sentry from '@sentry/react';
import { Typography } from 'antd';
import { isEqual } from 'lodash-es';
import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
/**
* Check if dimensions have changed
*/
function sameDimensions(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return next.width === prev.width && next.height === prev.height;
}
/**
* Check if data has changed (value equality)
*/
function sameData(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.data, prev.data);
}
/**
* Check if config builder has changed (value equality)
*/
function sameConfig(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.config, prev.config);
}
/**
* Plot component for rendering uPlot charts using the builder pattern
* Manages uPlot instance lifecycle and handles updates efficiently
*/
export default function UPlotChart({
config,
data,
width,
height,
plotRef,
onDestroy,
children,
'data-testid': testId = 'uplot-main-div',
}: UPlotChartProps): JSX.Element {
const { setPlotContextInitialState } = usePlotContext();
const containerRef = useRef<HTMLDivElement>(null);
const plotInstanceRef = useRef<uPlot | null>(null);
const prevPropsRef = useRef<UPlotChartProps | null>(null);
const configUsedForPlotRef = useRef<UPlotConfigBuilder | null>(null);
/**
* Destroy the existing plot instance if present.
*/
const destroyPlot = useCallback((): void => {
if (plotInstanceRef.current) {
onDestroy?.(plotInstanceRef.current);
// Clean up the config builder that was used to create this plot (not the current prop)
if (configUsedForPlotRef.current) {
configUsedForPlotRef.current.destroy();
}
configUsedForPlotRef.current = null;
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
}, [onDestroy, plotRef, setPlotContextInitialState]);
/**
* Initialize or reinitialize the plot
*/
const createPlot = useCallback(() => {
// Destroy existing plot first
destroyPlot();
if (!containerRef.current || width === 0 || height === 0) {
return;
}
// Build configuration from builder
const configOptions = config.getConfig();
// Merge with dimensions
const plotConfig: Options = {
width: Math.floor(width),
height: Math.floor(height),
...configOptions,
} as Options;
// Create new plot instance
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
if (plotRef) {
plotRef(plot);
}
setPlotContextInitialState({
uPlotInstance: plot,
widgetId: config.getWidgetId(),
});
plotInstanceRef.current = plot;
configUsedForPlotRef.current = config;
}, [
config,
data,
width,
height,
plotRef,
destroyPlot,
setPlotContextInitialState,
]);
/**
* Destroy plot when data becomes empty to prevent memory leaks.
* When the "No Data" UI is shown, the container div is unmounted,
* but without this effect the plot instance would remain in memory.
*/
const isDataEmpty = useMemo(() => {
return !!(data && data[0] && data[0].length === 0);
}, [data]);
useEffect(() => {
if (isDataEmpty) {
destroyPlot();
}
}, [isDataEmpty, destroyPlot]);
/**
* Handle initialization and prop changes
*/
useEffect(() => {
const prevProps = prevPropsRef.current;
const currentProps = { config, data, width, height };
// First render - initialize
if (!prevProps) {
createPlot();
prevPropsRef.current = currentProps;
return;
}
// Check if the plot instance's container has been unmounted (e.g., after "No Data" state)
// If so, we need to recreate the plot with the new container
const isPlotOrphaned =
plotInstanceRef.current &&
plotInstanceRef.current.root !== containerRef.current;
// Update dimensions without reinitializing if only size changed
if (
!sameDimensions(prevProps, currentProps) &&
plotInstanceRef.current &&
!isPlotOrphaned
) {
plotInstanceRef.current.setSize({
width: Math.floor(width),
height: Math.floor(height),
});
}
// Reinitialize if config changed or if the plot was orphaned (container changed)
if (!sameConfig(prevProps, currentProps) || isPlotOrphaned) {
createPlot();
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
plotInstanceRef.current.setData(data as AlignedData);
}
prevPropsRef.current = currentProps;
}, [config, data, width, height, createPlot]);
if (isDataEmpty) {
return (
<div
className="uplot-no-data not-found"
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
<LineChart size={48} strokeWidth={0.5} />
<Typography>No Data</Typography>
</div>
);
}
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div style={{ position: 'relative' }}>
<div ref={containerRef} data-testid={testId} />
{children}
</div>
</Sentry.ErrorBoundary>
);
}

View File

@@ -0,0 +1,87 @@
import { ReactNode } from 'react';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
/**
* Props for the Plot component
*/
export interface UPlotChartProps {
/**
* uPlot configuration builder
*/
config: UPlotConfigBuilder;
/**
* Chart data in uPlot.AlignedData format
*/
data: uPlot.AlignedData;
/**
* Chart width in pixels
*/
width: number;
/**
* Chart height in pixels
*/
height: number;
/**
* Optional callback when plot instance is created or destroyed.
* Called with the uPlot instance on create, and with null when the plot is destroyed.
*/
plotRef?: (u: uPlot | null) => void;
/**
* Optional callback when plot is destroyed
*/
onDestroy?: (u: uPlot) => void;
/**
* Children elements (typically plugins)
*/
children?: ReactNode;
/**
* Test ID for the container div
*/
'data-testid'?: string;
}
export interface TooltipRenderArgs {
uPlotInstance: uPlot;
dataIndexes: Array<number | null>;
seriesIndex: number | null;
isPinned: boolean;
dismiss: () => void;
viaSync: boolean;
}
export type TooltipProps = TooltipRenderArgs & {
timezone: string;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
};
export enum LegendPosition {
BOTTOM = 'bottom',
RIGHT = 'right',
}
export interface LegendConfig {
position: LegendPosition;
}
export interface LegendProps {
position?: LegendPosition;
config: UPlotConfigBuilder;
legendsPerSet?: number;
}
export interface TooltipContentItem {
label: string;
value: number;
tooltipValue: string;
color: string;
isActive: boolean;
}

View File

@@ -0,0 +1,284 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Axis } from 'uplot';
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
import getGridColor from '../../uPlotLib/utils/getGridColor';
import { AxisProps, ConfigBuilder } from './types';
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
PANEL_TYPES.TIME_SERIES,
PANEL_TYPES.BAR,
PANEL_TYPES.PIE,
];
/**
* Builder for uPlot axis configuration
* Handles creation and merging of axis settings
* Based on getAxes utility function patterns
*/
export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
/**
* Build grid configuration based on theme and scale type.
* Supports partial grid config: provided values override defaults.
*/
private buildGridConfig(): uPlot.Axis.Grid | undefined {
const { grid, isDarkMode, isLogScale } = this.props;
const defaultStroke = getGridColor(isDarkMode ?? false);
const defaultWidth = isLogScale ? 0.1 : 0.2;
const defaultShow = true;
// Merge partial or full grid config with defaults
if (grid) {
return {
stroke: grid.stroke ?? defaultStroke,
width: grid.width ?? defaultWidth,
show: grid.show ?? defaultShow,
};
}
return {
stroke: defaultStroke,
width: defaultWidth,
show: defaultShow,
};
}
/**
* Build ticks configuration
*/
private buildTicksConfig(): uPlot.Axis.Ticks | undefined {
const { ticks } = this.props;
// If explicit ticks config provided, use it
if (ticks) {
return ticks;
}
// Build default ticks config
return {
width: 0.3,
show: true,
};
}
/**
* Build values formatter for X-axis (time)
*/
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
const { panelType } = this.props;
if (
panelType &&
PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT.includes(panelType)
) {
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
}
return undefined;
}
/**
* Build values formatter for Y-axis (values with units)
*/
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
const { yAxisUnit, decimalPrecision } = this.props;
return (_, t): string[] =>
t.map((v) => {
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
});
}
/**
* Build values formatter based on axis type and props
*/
private buildValuesFormatter(): uPlot.Axis.Values | undefined {
const { values, scaleKey } = this.props;
// If explicit values formatter provided, use it
if (values) {
return values;
}
// Route to appropriate formatter based on scale key
return scaleKey === 'x'
? this.buildXAxisValuesFormatter()
: scaleKey === 'y'
? this.buildYAxisValuesFormatter()
: undefined;
}
/**
* Calculate axis size from existing size property
*/
private getExistingAxisSize(
self: uPlot,
axis: Axis,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number {
const internalSize = (axis as { _size?: number })._size;
if (internalSize !== undefined) {
return internalSize;
}
const existingSize = axis.size;
if (typeof existingSize === 'function') {
return existingSize(self, values ?? [], axisIdx, cycleNum);
}
return existingSize ?? 0;
}
/**
* Calculate text width for longest value
*/
private calculateTextWidth(
self: uPlot,
axis: Axis,
values: string[] | undefined,
): number {
if (!values || values.length === 0) {
return 0;
}
// Find longest value
const longestVal = values.reduce(
(acc, val) => (val.length > acc.length ? val : acc),
'',
);
if (longestVal === '' || !axis.font?.[0]) {
return 0;
}
// eslint-disable-next-line prefer-destructuring, no-param-reassign
self.ctx.font = axis.font[0];
return self.ctx.measureText(longestVal).width / devicePixelRatio;
}
/**
* Build Y-axis dynamic size calculator
*/
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
return (
self: uPlot,
values: string[] | undefined,
axisIdx: number,
cycleNum: number,
): number => {
const axis = self.axes[axisIdx];
// Bail out, force convergence
if (cycleNum > 1) {
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
}
const gap = this.props.gap ?? 5;
let axisSize = (axis.ticks?.size ?? 0) + gap;
axisSize += this.calculateTextWidth(self, axis, values);
return Math.ceil(axisSize);
};
}
/**
* Build dynamic size calculator for Y-axis
*/
private buildSizeCalculator(): uPlot.Axis.Size | undefined {
const { size, scaleKey } = this.props;
// If explicit size calculator provided, use it
if (size) {
return size;
}
// Y-axis needs dynamic sizing based on text width
if (scaleKey === 'y') {
return this.buildYAxisSizeCalculator();
}
return undefined;
}
/**
* Build stroke color based on props
*/
private buildStrokeColor(): string | undefined {
const { stroke, isDarkMode } = this.props;
if (stroke !== undefined) {
return stroke;
}
if (isDarkMode !== undefined) {
return isDarkMode ? 'white' : 'black';
}
return undefined;
}
getConfig(): Axis {
const {
scaleKey,
label,
show = true,
side = 2, // bottom by default
space,
gap = 5, // default gap is 5
} = this.props;
const grid = this.buildGridConfig();
const ticks = this.buildTicksConfig();
const values = this.buildValuesFormatter();
const size = this.buildSizeCalculator();
const stroke = this.buildStrokeColor();
const axisConfig: Axis = {
scale: scaleKey,
show,
side,
};
// Add properties conditionally
if (label) {
axisConfig.label = label;
}
if (stroke) {
axisConfig.stroke = stroke;
}
if (grid) {
axisConfig.grid = grid;
}
if (ticks) {
axisConfig.ticks = ticks;
}
if (values) {
axisConfig.values = values;
}
if (gap !== undefined) {
axisConfig.gap = gap;
}
if (space !== undefined) {
axisConfig.space = space;
}
if (size) {
axisConfig.size = size;
}
return axisConfig;
}
merge(props: Partial<AxisProps>): void {
this.props = { ...this.props, ...props };
}
}
export type { AxisProps };

View File

@@ -0,0 +1,289 @@
import { getStoredSeriesVisibility } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { merge } from 'lodash-es';
import noop from 'lodash-es/noop';
import uPlot, { Cursor, Hooks, Options } from 'uplot';
import {
ConfigBuilder,
ConfigBuilderProps,
DEFAULT_CURSOR_CONFIG,
DEFAULT_PLOT_CONFIG,
LegendItem,
} from './types';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
/**
* Type definitions for uPlot option objects
*/
type LegendConfig = {
show?: boolean;
live?: boolean;
isolate?: boolean;
[key: string]: unknown;
};
/**
* Main builder orchestrator for uPlot configuration
* Manages axes, scales, series, and hooks in a composable way
*/
export class UPlotConfigBuilder extends ConfigBuilder<
ConfigBuilderProps,
Partial<Options>
> {
series: UPlotSeriesBuilder[] = [];
private axes: Record<string, UPlotAxisBuilder> = {};
readonly scales: UPlotScaleBuilder[] = [];
private bands: uPlot.Band[] = [];
private cursor: Cursor | undefined;
private hooks: Hooks.Arrays = {};
private plugins: uPlot.Plugin[] = [];
private padding: [number, number, number, number] | undefined;
private legend: LegendConfig | undefined;
private focus: uPlot.Focus | undefined;
private select: uPlot.Select | undefined;
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private tzDate: ((timestamp: number) => Date) | undefined;
private widgetId: string | undefined;
private onDragSelect: (startTime: number, endTime: number) => void;
private cleanups: Array<() => void> = [];
constructor(args?: ConfigBuilderProps) {
super(args ?? {});
const { widgetId, onDragSelect } = args ?? {};
if (widgetId) {
this.widgetId = widgetId;
}
this.onDragSelect = noop;
if (onDragSelect) {
this.onDragSelect = onDragSelect;
// Add a hook to handle the select event
const cleanup = this.addHook('setSelect', (self: uPlot): void => {
const selection = self.select;
// Only trigger onDragSelect when there's an actual drag range (width > 0)
// A click without dragging produces width === 0, which should be ignored
if (selection && selection.width > 0) {
const startTime = self.posToVal(selection.left, 'x');
const endTime = self.posToVal(selection.left + selection.width, 'x');
this.onDragSelect(startTime * 1000, endTime * 1000);
}
});
this.cleanups.push(cleanup);
}
}
/**
* Add or merge an axis configuration
*/
addAxis(props: AxisProps): void {
const { scaleKey } = props;
if (this.axes[scaleKey]) {
this.axes[scaleKey].merge?.(props);
return;
}
this.axes[scaleKey] = new UPlotAxisBuilder(props);
}
/**
* Add or merge a scale configuration
*/
addScale(props: ScaleProps): void {
const current = this.scales.find((v) => v.props.scaleKey === props.scaleKey);
if (current) {
current.merge?.(props);
return;
}
this.scales.push(new UPlotScaleBuilder(props));
}
/**
* Add a series configuration
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
}
/**
* Add a hook for extensibility
*/
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]): () => void {
if (!this.hooks[type]) {
this.hooks[type] = [];
}
(this.hooks[type] as Hooks.Defs[T][]).push(hook);
// Return a function to remove the hook when the component unmounts
return (): void => {
const idx = (this.hooks[type] as Hooks.Defs[T][]).indexOf(hook);
if (idx !== -1) {
(this.hooks[type] as Hooks.Defs[T][]).splice(idx, 1);
}
};
}
/**
* Add a plugin
*/
addPlugin(plugin: uPlot.Plugin): void {
this.plugins.push(plugin);
}
/**
* Add thresholds configuration
*/
addThresholds(options: ThresholdsDrawHookOptions): void {
if (!this.thresholds[options.scaleKey]) {
this.thresholds[options.scaleKey] = options;
const cleanup = this.addHook('draw', thresholdsDrawHook(options));
this.cleanups.push(cleanup);
}
}
/**
* Set bands for stacked charts
*/
setBands(bands: uPlot.Band[]): void {
this.bands = bands;
}
/**
* Set cursor configuration
*/
setCursor(cursor: Cursor): void {
this.cursor = merge({}, this.cursor, cursor);
}
/**
* Set padding
*/
setPadding(padding: [number, number, number, number]): void {
this.padding = padding;
}
/**
* Set legend configuration
*/
setLegend(legend: LegendConfig): void {
this.legend = legend;
}
/**
* Set focus configuration
*/
setFocus(focus: uPlot.Focus): void {
this.focus = focus;
}
/**
* Set select configuration
*/
setSelect(select: uPlot.Select): void {
this.select = select;
}
/**
* Set timezone date function
*/
setTzDate(tzDate: (timestamp: number) => Date): void {
this.tzDate = tzDate;
}
/**
* Get legend items with visibility state restored from localStorage if available
*/
getLegendItems(): Record<number, LegendItem> {
const visibilityMap = this.widgetId
? getStoredSeriesVisibility(this.widgetId)
: null;
return this.series.reduce((acc, s: UPlotSeriesBuilder, index: number) => {
const seriesConfig = s.getConfig();
const label = seriesConfig.label ?? '';
const seriesIndex = index + 1; // +1 because the first series is the timestamp
// Priority: stored visibility > series config > default (true)
const show = visibilityMap?.get(label) ?? seriesConfig.show ?? true;
acc[seriesIndex] = {
seriesIndex,
color: seriesConfig.stroke,
label,
show,
};
return acc;
}, {} as Record<number, LegendItem>);
}
/**
* Remove all hooks and cleanup functions
*/
destroy(): void {
this.cleanups.forEach((cleanup) => cleanup());
}
/**
* Get the widget id
*/
getWidgetId(): string | undefined {
return this.widgetId;
}
/**
* Build the final uPlot.Options configuration
*/
getConfig(): Partial<Options> {
const config: Partial<Options> = {
...DEFAULT_PLOT_CONFIG,
};
config.series = [
{ value: (): string => '' }, // Base series for timestamp
...this.series.map((s) => s.getConfig()),
];
config.axes = Object.values(this.axes).map((a) => a.getConfig());
config.scales = this.scales.reduce(
(acc, s) => ({ ...acc, ...s.getConfig() }),
{} as Record<string, uPlot.Scale>,
);
config.hooks = this.hooks;
config.select = this.select;
config.cursor = merge({}, DEFAULT_CURSOR_CONFIG, this.cursor);
config.tzDate = this.tzDate;
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
config.bands = this.bands.length > 0 ? this.bands : undefined;
if (Array.isArray(this.padding)) {
config.padding = this.padding;
}
if (this.legend) {
config.legend = this.legend;
}
if (this.focus) {
config.focus = this.focus;
}
return config;
}
}

View File

@@ -0,0 +1,155 @@
import { Scale } from 'uplot';
import {
adjustSoftLimitsWithThresholds,
createRangeFunction,
getDistributionConfig,
getFallbackMinMaxTimeStamp,
getRangeConfig,
normalizeLogScaleLimits,
} from '../utils/scale';
import { ConfigBuilder, ScaleProps } from './types';
/**
* Builder for uPlot scale configuration
* Handles creation and merging of scale settings
*/
export class UPlotScaleBuilder extends ConfigBuilder<
ScaleProps,
Record<string, Scale>
> {
private softMin: number | null;
private softMax: number | null;
private min: number | null;
private max: number | null;
constructor(props: ScaleProps) {
super(props);
this.softMin = props.softMin ?? null;
this.softMax = props.softMax ?? null;
this.min = props.min ?? null;
this.max = props.max ?? null;
}
getConfig(): Record<string, Scale> {
const {
scaleKey,
time,
range,
thresholds,
logBase = 10,
padMinBy = 0,
padMaxBy = 0.1,
} = this.props;
// Special handling for time scales (X axis)
if (time) {
let minTime = this.min ?? 0;
let maxTime = this.max ?? 0;
// Fallback when min/max are not provided
if (!minTime || !maxTime) {
const { fallbackMin, fallbackMax } = getFallbackMinMaxTimeStamp();
minTime = fallbackMin;
maxTime = fallbackMax;
}
// Align max time to "endTime - 1 minute", rounded down to minute precision
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
const currentDate = new Date(oneMinuteAgoTimestamp);
currentDate.setSeconds(0);
currentDate.setMilliseconds(0);
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
maxTime = unixTimestampSeconds;
return {
[scaleKey]: {
time: true,
auto: false,
range: [minTime, maxTime],
},
};
}
const distr = this.props.distribution;
// Adjust softMin/softMax to include threshold values
// This ensures threshold lines are visible within the scale range
const thresholdList = thresholds?.thresholds;
const {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
} = adjustSoftLimitsWithThresholds(
this.softMin,
this.softMax,
thresholdList,
thresholds?.yAxisUnit,
);
const { min, max, softMin, softMax } = normalizeLogScaleLimits({
distr,
logBase,
limits: {
min: this.min,
max: this.max,
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
},
});
const distribution = getDistributionConfig({
time,
distr,
logBase,
});
const {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
} = getRangeConfig(min, max, softMin, softMax, padMinBy, padMaxBy);
const rangeFn = createRangeFunction({
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
min,
max,
});
let auto = this.props.auto;
auto ??= !time && !hasFixedRange;
return {
[scaleKey]: {
time,
auto,
range: range ?? rangeFn,
...distribution,
},
};
}
merge(props: Partial<ScaleProps>): void {
this.props = { ...this.props, ...props };
if (props.softMin !== undefined) {
this.softMin = props.softMin ?? null;
}
if (props.softMax !== undefined) {
this.softMax = props.softMax ?? null;
}
if (props.min !== undefined) {
this.min = props.min ?? null;
}
if (props.max !== undefined) {
this.max = props.max ?? null;
}
}
}
export type { ScaleProps };

View File

@@ -0,0 +1,232 @@
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import uPlot, { Series } from 'uplot';
import {
ConfigBuilder,
DrawStyle,
FillStyle,
LineInterpolation,
SeriesProps,
VisibilityMode,
} from './types';
/**
* Builder for uPlot series configuration
* Handles creation of series settings
*/
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
private buildLineConfig(
lineColor: string,
lineWidth?: number,
lineStyle?: { fill?: FillStyle; dash?: number[] },
): Partial<Series> {
const lineConfig: Partial<Series> = {
stroke: lineColor,
width: lineWidth ?? 2,
};
if (lineStyle && lineStyle.fill !== FillStyle.Solid) {
if (lineStyle.fill === FillStyle.Dot) {
lineConfig.cap = 'round';
}
lineConfig.dash = lineStyle.dash ?? [10, 10];
}
return lineConfig;
}
/**
* Build path configuration
*/
private buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
}: {
pathBuilder?: Series.PathBuilder | null;
drawStyle: DrawStyle;
lineInterpolation?: LineInterpolation;
}): Partial<Series> {
if (pathBuilder) {
return { paths: pathBuilder };
}
if (drawStyle === DrawStyle.Points) {
return { paths: (): null => null };
}
if (drawStyle !== null) {
return {
paths: (
self: uPlot,
seriesIdx: number,
idx0: number,
idx1: number,
): Series.Paths | null => {
const pathsBuilder = getPathBuilder(drawStyle, lineInterpolation);
return pathsBuilder(self, seriesIdx, idx0, idx1);
},
};
}
return {};
}
/**
* Build points configuration
*/
private buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder,
pointsFilter,
drawStyle,
showPoints,
}: {
lineColor: string;
lineWidth?: number;
pointSize?: number;
pointsBuilder: Series.Points.Show | null;
pointsFilter: Series.Points.Filter | null;
drawStyle: DrawStyle;
showPoints?: VisibilityMode;
}): Partial<Series.Points> {
const pointsConfig: Partial<Series.Points> = {
stroke: lineColor,
fill: lineColor,
size: !pointSize || pointSize < (lineWidth ?? 2) ? undefined : pointSize,
filter: pointsFilter || undefined,
};
if (pointsBuilder) {
pointsConfig.show = pointsBuilder;
} else if (drawStyle === DrawStyle.Points) {
pointsConfig.show = true;
} else if (showPoints === VisibilityMode.Never) {
pointsConfig.show = false;
} else if (showPoints === VisibilityMode.Always) {
pointsConfig.show = true;
}
return pointsConfig;
}
private getLineColor(): string {
const { colorMapping, label, lineColor, isDarkMode } = this.props;
if (!label) {
return lineColor ?? (isDarkMode ? themeColors.white : themeColors.black);
}
return (
lineColor ??
colorMapping[label] ??
generateColor(
label,
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
)
);
}
getConfig(): Series {
const {
drawStyle,
pathBuilder,
pointsBuilder,
pointsFilter,
lineInterpolation,
lineWidth,
lineStyle,
showPoints,
pointSize,
scaleKey,
label,
spanGaps,
show = true,
} = this.props;
const lineColor = this.getLineColor();
const lineConfig = this.buildLineConfig(lineColor, lineWidth, lineStyle);
const pathConfig = this.buildPathConfig({
pathBuilder,
drawStyle,
lineInterpolation,
});
const pointsConfig = this.buildPointsConfig({
lineColor,
lineWidth,
pointSize,
pointsBuilder: pointsBuilder ?? null,
pointsFilter: pointsFilter ?? null,
drawStyle,
showPoints,
});
return {
scale: scaleKey,
label,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
value: (): string => '',
pxAlign: true,
show,
...lineConfig,
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
};
}
}
interface PathBuilders {
linear: Series.PathBuilder;
spline: Series.PathBuilder;
stepBefore: Series.PathBuilder;
stepAfter: Series.PathBuilder;
[key: string]: Series.PathBuilder;
}
let builders: PathBuilders | null = null;
/**
* Get path builder based on draw style and interpolation
*/
function getPathBuilder(
style: DrawStyle,
lineInterpolation?: LineInterpolation,
): Series.PathBuilder {
const pathBuilders = uPlot.paths;
if (!builders) {
const linearBuilder = pathBuilders.linear;
const splineBuilder = pathBuilders.spline;
const steppedBuilder = pathBuilders.stepped;
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
throw new Error('Required uPlot path builders are not available');
}
builders = {
linear: linearBuilder(),
spline: splineBuilder(),
stepBefore: steppedBuilder({ align: -1 }),
stepAfter: steppedBuilder({ align: 1 }),
};
}
if (style === DrawStyle.Line) {
if (lineInterpolation === LineInterpolation.StepBefore) {
return builders.stepBefore;
}
if (lineInterpolation === LineInterpolation.StepAfter) {
return builders.stepAfter;
}
if (lineInterpolation === LineInterpolation.Linear) {
return builders.linear;
}
}
return builders.spline;
}
export type { SeriesProps };

View File

@@ -0,0 +1,199 @@
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import uPlot, { Cursor, Options, Series } from 'uplot';
import { ThresholdsDrawHookOptions } from '../hooks/types';
/**
* Base abstract class for all configuration builders
* Provides a common interface for building uPlot configuration components
*/
export abstract class ConfigBuilder<P, T> {
constructor(public props: P) {}
/**
* Builds and returns the configuration object
*/
abstract getConfig(): T;
/**
* Merges additional properties into the existing configuration
*/
merge?(props: Partial<P>): void;
}
/**
* Props for configuring the uPlot config builder
*/
export interface ConfigBuilderProps {
widgetId?: string;
onDragSelect?: (startTime: number, endTime: number) => void;
}
/**
* Props for configuring an axis
*/
export interface AxisProps {
scaleKey: string;
label?: string;
show?: boolean;
side?: 0 | 1 | 2 | 3; // top, right, bottom, left
stroke?: string;
grid?: {
stroke?: string;
width?: number;
show?: boolean;
};
ticks?: {
stroke?: string;
width?: number;
show?: boolean;
size?: number;
};
values?: uPlot.Axis.Values;
gap?: number;
size?: uPlot.Axis.Size;
formatValue?: (v: number) => string;
space?: number; // Space for log scale axes
isDarkMode?: boolean;
isLogScale?: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
decimalPrecision?: PrecisionOption;
}
/**
* Props for configuring a scale
*/
export enum DistributionType {
Linear = 'linear',
Logarithmic = 'logarithmic',
}
export interface ScaleProps {
scaleKey: string;
time?: boolean;
min?: number;
max?: number;
softMin?: number;
softMax?: number;
thresholds?: ThresholdsDrawHookOptions;
padMinBy?: number;
padMaxBy?: number;
range?: uPlot.Scale.Range;
auto?: boolean;
logBase?: uPlot.Scale.LogBase;
distribution?: DistributionType;
}
/**
* Props for configuring a series
*/
export enum FillStyle {
Solid = 'solid',
Dash = 'dash',
Dot = 'dot',
Square = 'square',
}
export interface LineStyle {
dash?: Array<number>;
fill?: FillStyle;
}
export enum DrawStyle {
Line = 'line',
Points = 'points',
}
export enum LineInterpolation {
Linear = 'linear',
Spline = 'spline',
StepAfter = 'stepAfter',
StepBefore = 'stepBefore',
}
export enum VisibilityMode {
Always = 'always',
Auto = 'auto',
Never = 'never',
}
export interface SeriesProps {
scaleKey: string;
label?: string;
colorMapping: Record<string, string>;
drawStyle: DrawStyle;
pathBuilder?: Series.PathBuilder;
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
spanGaps?: boolean;
isDarkMode?: boolean;
// Line config
lineColor?: string;
lineInterpolation?: LineInterpolation;
lineStyle?: LineStyle;
lineWidth?: number;
// Points config
pointColor?: string;
pointSize?: number;
showPoints?: VisibilityMode;
}
export interface LegendItem {
seriesIndex: number;
label: uPlot.Series['label'];
color: uPlot.Series['stroke'];
show: boolean;
}
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,
},
cursor: {
focus: {
prox: 30,
},
},
legend: {
show: false,
},
padding: [16, 16, 8, 8],
series: [],
hooks: {},
};
const POINTS_FILL_COLOR = '#FFFFFF';
export const DEFAULT_CURSOR_CONFIG: Cursor = {
drag: { setScale: true },
points: {
one: true,
size: (u, seriesIdx) => (u.series[seriesIdx]?.points?.size ?? 0) * 3,
width: (_u, _seriesIdx, size) => size / 4,
stroke: (u, seriesIdx): string => {
const points = u.series[seriesIdx]?.points;
const strokeFn =
typeof points?.stroke === 'function' ? points.stroke : undefined;
const strokeValue =
strokeFn !== undefined
? strokeFn(u, seriesIdx)
: typeof points?.stroke === 'string'
? points.stroke
: '';
return `${strokeValue}90`;
},
fill: (): string => POINTS_FILL_COLOR,
},
focus: {
prox: 30,
},
};

View File

@@ -0,0 +1,136 @@
import {
createContext,
PropsWithChildren,
useCallback,
useContext,
useMemo,
useRef,
} from 'react';
import type { SeriesVisibilityItem } from 'container/DashboardContainer/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
export interface PlotContextInitialState {
uPlotInstance: uPlot | null;
widgetId?: string;
}
export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
onFocusSeries: (seriesIndex: number | null) => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
const uPlotInstanceRef = useRef<uPlot | null>(null);
const activeSeriesIndex = useRef<number | undefined>(undefined);
const widgetIdRef = useRef<string | undefined>(undefined);
const setPlotContextInitialState = useCallback(
({ uPlotInstance, widgetId }: PlotContextInitialState): void => {
uPlotInstanceRef.current = uPlotInstance;
widgetIdRef.current = widgetId;
activeSeriesIndex.current = undefined;
},
[],
);
const onToggleSeriesVisibility = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const isReset = activeSeriesIndex.current === seriesIndex;
activeSeriesIndex.current = isReset ? undefined : seriesIndex;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
const currentSeriesIndex = index;
plot.setSeries(currentSeriesIndex, {
show: isReset || currentSeriesIndex === seriesIndex,
});
});
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
});
}, []);
const onToggleSeriesOnOff = useCallback((seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
const series = plot.series[seriesIndex];
if (!series) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (widgetIdRef.current) {
const seriesVisibility: SeriesVisibilityItem[] = plot.series.map(
(series) => ({
label: series.label ?? '',
show: series.show ?? true,
}),
);
updateSeriesVisibilityToLocalStorage(widgetIdRef.current, seriesVisibility);
}
}, []);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
plot.setSeries(
seriesIndex,
{
focus: true,
},
false,
);
}, []);
const value = useMemo(
() => ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onFocusSeries,
],
);
return <PlotContext.Provider value={value}>{children}</PlotContext.Provider>;
};
export const usePlotContext = (): IPlotContext => {
const context = useContext(PlotContext);
if (!context) {
throw new Error('Should be used inside the context');
}
return context;
};

View File

@@ -0,0 +1,12 @@
export interface Threshold {
thresholdValue: number;
thresholdColor?: string;
thresholdUnit?: string;
thresholdLabel?: string;
}
export interface ThresholdsDrawHookOptions {
scaleKey: string;
thresholds: Threshold[];
yAxisUnit?: string;
}

View File

@@ -0,0 +1,142 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { LegendItem } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
/**
* Syncs legend UI state with the uPlot chart: which series is focused and each series' visibility.
* Subscribes to the config's setSeries hook so legend items stay in sync when series are toggled
* from the chart or from the Legend component.
*
* @param config - UPlot config builder; used to read legend items and to register the setSeries hook
* @param subscribeToFocusChange - When true, updates focusedSeriesIndex when a series gains focus via setSeries
* @returns focusedSeriesIndex, setFocusedSeriesIndex, and legendItemsMap for the Legend component
*/
export default function useLegendsSync({
config,
subscribeToFocusChange = true,
}: {
config: UPlotConfigBuilder;
subscribeToFocusChange?: boolean;
}): {
focusedSeriesIndex: number | null;
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
legendItemsMap: Record<number, LegendItem>;
} {
const [legendItemsMap, setLegendItemsMap] = useState<
Record<number, LegendItem>
>({});
const [focusedSeriesIndex, setFocusedSeriesIndex] = useState<number | null>(
null,
);
/** Pending visibility updates (series index -> show) to apply in the next RAF. */
const visibilityUpdatesRef = useRef<Record<number, boolean>>({});
/** RAF id for the batched visibility update; null when no update is scheduled. */
const visibilityRafIdRef = useRef<number | null>(null);
/**
* Applies a batch of visibility updates to legendItemsMap.
* Only updates entries that exist and whose show value changed; returns prev state if nothing changed.
*/
const applyVisibilityUpdates = useCallback(
(updates: Record<number, boolean>): void => {
setLegendItemsMap(
(prev): Record<number, LegendItem> => {
let hasChanges = false;
const next = { ...prev };
for (const [idxStr, show] of Object.entries(updates)) {
const idx = Number(idxStr);
const current = next[idx];
if (!current || current.show === show) {
continue;
}
next[idx] = { ...current, show };
hasChanges = true;
}
return hasChanges ? next : prev;
},
);
},
[],
);
/**
* Queues a single series visibility update and schedules at most one state update per frame.
* Batches multiple visibility changes (e.g. from setSeries) into one setLegendItemsMap call.
*/
const queueVisibilityUpdate = useCallback(
(seriesIndex: number, show: boolean): void => {
visibilityUpdatesRef.current[seriesIndex] = show;
if (visibilityRafIdRef.current !== null) {
return;
}
visibilityRafIdRef.current = requestAnimationFrame(() => {
const updates = visibilityUpdatesRef.current;
visibilityUpdatesRef.current = {};
visibilityRafIdRef.current = null;
applyVisibilityUpdates(updates);
});
},
[applyVisibilityUpdates],
);
/**
* Handler for uPlot's setSeries hook. Updates focused series when opts.focus is set,
* and queues legend visibility updates when opts.show changes so the legend stays in sync.
*/
const handleSetSeries = useCallback(
(_u: uPlot, seriesIndex: number | null, opts: uPlot.Series): void => {
if (subscribeToFocusChange && get(opts, 'focus', false)) {
setFocusedSeriesIndex(seriesIndex);
}
if (!seriesIndex || typeof opts.show !== 'boolean') {
return;
}
queueVisibilityUpdate(seriesIndex, opts.show);
},
[queueVisibilityUpdate, subscribeToFocusChange],
);
// Initialize legend items from config and subscribe to setSeries; cleanup on unmount or config change.
useLayoutEffect(() => {
setLegendItemsMap(config.getLegendItems());
const removeHook = config.addHook('setSeries', handleSetSeries);
return (): void => {
removeHook();
};
}, [config, handleSetSeries]);
// Cancel any pending RAF on unmount to avoid state updates after unmount.
useEffect(
() => (): void => {
if (visibilityRafIdRef.current != null) {
cancelAnimationFrame(visibilityRafIdRef.current);
}
},
[],
);
return {
focusedSeriesIndex,
setFocusedSeriesIndex,
legendItemsMap,
};
}

View File

@@ -0,0 +1,65 @@
import { convertValue } from 'lib/getConvertedValue';
import uPlot, { Hooks } from 'uplot';
import { Threshold, ThresholdsDrawHookOptions } from './types';
export function thresholdsDrawHook(
options: ThresholdsDrawHookOptions,
): Hooks.Defs['draw'] {
const dashSegments = [10, 5];
function addLines(u: uPlot, scaleKey: string, thresholds: Threshold[]): void {
const ctx = u.ctx;
ctx.save();
ctx.lineWidth = 2;
ctx.setLineDash(dashSegments);
const threshold90Percent = ctx.canvas.height * 0.9;
for (let idx = 0; idx < thresholds.length; idx++) {
const threshold = thresholds[idx];
const color = threshold.thresholdColor || 'red';
const yValue = convertValue(
threshold.thresholdValue,
threshold.thresholdUnit,
options.yAxisUnit,
);
const scaleVal = u.valToPos(Number(yValue), scaleKey, true);
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);
ctx.stroke();
// Draw threshold label if present
if (threshold.thresholdLabel) {
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
const textX = x1 - textWidth - 20;
const yposHeight = ctx.canvas.height - y1;
const textY = yposHeight > threshold90Percent ? y0 + 15 : y0 - 15;
ctx.fillStyle = color;
ctx.fillText(threshold.thresholdLabel, textX, textY);
}
}
}
const { scaleKey, thresholds } = options;
return (u: uPlot): void => {
const ctx = u.ctx;
addLines(u, scaleKey, thresholds);
ctx.restore();
};
}

View File

@@ -0,0 +1,53 @@
/**
* Checks if a value is invalid for plotting
*
* @param value - The value to check
* @returns true if the value is invalid (should be replaced with null), false otherwise
*/
export function isInvalidPlotValue(value: unknown): boolean {
// Check for null or undefined
if (value === null || value === undefined) {
return true;
}
// Handle number checks
if (typeof value === 'number') {
// Check for NaN, Infinity, -Infinity
return !Number.isFinite(value);
}
// Handle string values
if (typeof value === 'string') {
// Check for string representations of infinity
if (['+Inf', '-Inf', 'Infinity', '-Infinity', 'NaN'].includes(value)) {
return true;
}
// Try to parse the string as a number
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
// Value is valid for plotting
return false;
}
export function normalizePlotValue(
value: number | string | null | undefined,
): number | null {
if (isInvalidPlotValue(value)) {
return null;
}
// Convert string numbers to actual numbers
if (typeof value === 'string') {
return parseFloat(value);
}
// Already a valid number
return value as number;
}

View File

@@ -0,0 +1,415 @@
/**
* Scale utilities for uPlot Y-axis configuration.
* Handles linear/log distribution, range computation (with padding and soft/hard limits),
* log-scale snapping, and threshold-aware soft limits.
*/
import uPlot, { Range, Scale } from 'uplot';
import { DistributionType, ScaleProps } from '../config/types';
import { Threshold } from '../hooks/types';
import { findMinMaxThresholdValues } from './threshold';
import { LogScaleLimits, RangeFunctionParams } from './types';
/**
* Rounds a number down to the nearest multiple of incr.
* Used for linear scale min so the axis starts on a clean tick.
*/
export function incrRoundDn(num: number, incr: number): number {
return Math.floor(num / incr) * incr;
}
/**
* Rounds a number up to the nearest multiple of incr.
* Used for linear scale max so the axis ends on a clean tick.
*/
export function incrRoundUp(num: number, incr: number): number {
return Math.ceil(num / incr) * incr;
}
/**
* Snaps min/max/softMin/softMax to valid log-scale values (powers of logBase).
* Only applies when distribution is logarithmic; otherwise returns limits unchanged.
* Ensures axis bounds align to log "magnitude" for readable tick labels.
*/
export function normalizeLogScaleLimits({
distr,
logBase,
limits,
}: {
distr?: DistributionType;
logBase: number;
limits: LogScaleLimits;
}): LogScaleLimits {
if (distr !== DistributionType.Logarithmic) {
return limits;
}
const logFn = logBase === 2 ? Math.log2 : Math.log10;
return {
min: normalizeLogLimit(limits.min, logBase, logFn, Math.floor),
max: normalizeLogLimit(limits.max, logBase, logFn, Math.ceil),
softMin: normalizeLogLimit(limits.softMin, logBase, logFn, Math.floor),
softMax: normalizeLogLimit(limits.softMax, logBase, logFn, Math.ceil),
};
}
/**
* Converts a single limit value to the nearest valid log-scale value.
* Rounds the log(value) with roundFn, then returns logBase^exp.
* Values <= 0 or null are returned as-is (log scale requires positive values).
*/
function normalizeLogLimit(
value: number | null,
logBase: number,
logFn: (v: number) => number,
roundFn: (v: number) => number,
): number | null {
if (value == null || value <= 0) {
return value;
}
const exp = roundFn(logFn(value));
return logBase ** exp;
}
/**
* 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,
}: {
time: ScaleProps['time'];
distr?: DistributionType;
logBase?: number;
}): Partial<Scale> {
if (time) {
return {};
}
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
return {
distr: distr === DistributionType.Logarithmic ? 3 : 1,
log: resolvedLogBase,
};
}
/**
* Builds uPlot range config and flags for the range function.
* - rangeConfig: pad, hard, soft, mode for min and max (used by uPlot.rangeNum / rangeLog).
* - hardMinOnly / hardMaxOnly: true when only a hard limit is set (no soft), so range uses that bound.
* - hasFixedRange: true when both min and max are hard-only (fully fixed axis).
*/
export function getRangeConfig(
min: number | null,
max: number | null,
softMin: number | null,
softMax: number | null,
padMinBy: number,
padMaxBy: number,
): {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
} {
// uPlot: mode 3 = auto pad from data; mode 1 = respect soft limit
const softMinMode: Range.SoftMode = softMin == null ? 3 : 1;
const softMaxMode: Range.SoftMode = softMax == null ? 3 : 1;
const rangeConfig: Range.Config = {
min: {
pad: padMinBy,
hard: min ?? -Infinity,
soft: softMin !== null ? softMin : undefined,
mode: softMinMode,
},
max: {
pad: padMaxBy,
hard: max ?? Infinity,
soft: softMax !== null ? softMax : undefined,
mode: softMaxMode,
},
};
const hardMinOnly = softMin == null && min != null;
const hardMaxOnly = softMax == null && max != null;
const hasFixedRange = hardMinOnly && hardMaxOnly;
return {
rangeConfig,
hardMinOnly,
hardMaxOnly,
hasFixedRange,
};
}
/**
* Initial [min, max] for the range pipeline. Returns null when we have no data and no fixed range
* (so the caller can bail and return [dataMin, dataMax] unchanged).
*/
function getInitialMinMax(
dataMin: number | null,
dataMax: number | null,
hasFixedRange: boolean,
): Range.MinMax | null {
if (!hasFixedRange && dataMin == null && dataMax == null) {
return null;
}
return [dataMin, dataMax];
}
/**
* Computes the linear-scale range using uPlot.rangeNum.
* Uses hard min/max when hardMinOnly/hardMaxOnly; otherwise uses data min/max. Applies padding via rangeConfig.
*/
function getLinearScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
): Range.MinMax {
const { rangeConfig, hardMinOnly, hardMaxOnly, min, max } = params;
const resolvedMin = hardMinOnly ? min : dataMin;
const resolvedMax = hardMaxOnly ? max : dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeNum(resolvedMin, resolvedMax, rangeConfig);
}
/**
* Computes the log-scale range using uPlot.rangeLog.
* Resolves min/max from params or data, then delegates to uPlot's log range helper.
*/
function getLogScaleRange(
minMax: Range.MinMax,
params: RangeFunctionParams,
dataMin: number | null,
dataMax: number | null,
logBase?: uPlot.Scale['log'],
): Range.MinMax {
const { min, max } = params;
const resolvedMin = min ?? dataMin;
const resolvedMax = max ?? dataMax;
if (resolvedMin == null || resolvedMax == null) {
return minMax;
}
return uPlot.rangeLog(
resolvedMin,
resolvedMax,
(logBase ?? 10) as 2 | 10,
true,
);
}
/**
* Snaps linear scale min down and max up to whole numbers so axis bounds are clean.
*/
function roundLinearRange(minMax: Range.MinMax): Range.MinMax {
const [currentMin, currentMax] = minMax;
let roundedMin = currentMin;
let roundedMax = currentMax;
if (roundedMin != null) {
roundedMin = incrRoundDn(roundedMin, 1);
}
if (roundedMax != null) {
roundedMax = incrRoundUp(roundedMax, 1);
}
return [roundedMin, roundedMax];
}
/**
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
*/
function adjustLogRange(
minMax: Range.MinMax,
logBase: number,
logFn: (v: number) => number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (currentMin != null) {
const minExp = Math.floor(logFn(currentMin));
currentMin = logBase ** minExp;
}
if (currentMax != null) {
const maxExp = Math.ceil(logFn(currentMax));
currentMax = logBase ** maxExp;
if (currentMin === currentMax) {
currentMax *= logBase;
}
}
return [currentMin, currentMax];
}
/**
* For linear scales (distr === 1), clamps the computed range to the configured hard min/max when
* hardMinOnly/hardMaxOnly are set. No-op for log scales.
*/
function applyHardLimits(
minMax: Range.MinMax,
params: RangeFunctionParams,
distr: number,
): Range.MinMax {
let [currentMin, currentMax] = minMax;
if (distr !== 1) {
return [currentMin, currentMax];
}
const { hardMinOnly, hardMaxOnly, min, max } = params;
if (hardMinOnly && min != null) {
currentMin = min;
}
if (hardMaxOnly && max != null) {
currentMax = max;
}
return [currentMin, currentMax];
}
/**
* If the range is invalid (min >= max), returns a safe default: [1, 100] for log (distr 3), [0, 100] for linear.
*/
function enforceValidRange(minMax: Range.MinMax, distr: number): Range.MinMax {
const [currentMin, currentMax] = minMax;
if (currentMin != null && currentMax != null && currentMin >= currentMax) {
return [distr === 3 ? 1 : 0, 100];
}
return minMax;
}
/**
* Creates the uPlot range function for a scale. Called by uPlot with (u, dataMin, dataMax, scaleKey).
* Pipeline: initial min/max -> linear or log range (with padding) -> rounding/snapping -> hard limits -> valid range.
*/
export function createRangeFunction(
params: RangeFunctionParams,
): Range.Function {
return (
u: uPlot,
dataMin: number | null,
dataMax: number | null,
scaleKey: string,
): Range.MinMax => {
const scale = u.scales[scaleKey];
const initialMinMax = getInitialMinMax(
dataMin,
dataMax,
params.hasFixedRange,
);
if (!initialMinMax) {
return [dataMin, dataMax];
}
let minMax: Range.MinMax = initialMinMax;
const logBase = scale.log;
if (scale.distr === 1) {
minMax = getLinearScaleRange(minMax, params, dataMin, dataMax);
minMax = roundLinearRange(minMax);
} else if (scale.distr === 3) {
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
}
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
return enforceValidRange(minMax, scale.distr ?? 1);
};
}
/**
* Expands softMin/softMax so that all threshold lines fall within the soft range and stay visible.
* Converts threshold values to yAxisUnit, then takes the min/max; softMin is lowered (or set) to
* include the smallest threshold, softMax is raised (or set) to include the largest.
*/
export function adjustSoftLimitsWithThresholds(
softMin: number | null,
softMax: number | null,
thresholds?: Threshold[],
yAxisUnit?: string,
): {
softMin: number | null;
softMax: number | null;
} {
if (!thresholds || thresholds.length === 0) {
return { softMin, softMax };
}
const [minThresholdValue, maxThresholdValue] = findMinMaxThresholdValues(
thresholds,
yAxisUnit,
);
if (minThresholdValue === null && maxThresholdValue === null) {
return { softMin, softMax };
}
const adjustedSoftMin =
minThresholdValue !== null
? softMin !== null
? Math.min(softMin, minThresholdValue)
: minThresholdValue
: softMin;
const adjustedSoftMax =
maxThresholdValue !== null
? softMax !== null
? Math.max(softMax, maxThresholdValue)
: maxThresholdValue
: softMax;
return {
softMin: adjustedSoftMin,
softMax: adjustedSoftMax,
};
}
/**
* Returns fallback time bounds (min/max) as Unix timestamps in seconds when no
* data range is available. Uses the last 24 hours: from one day ago to now.
*/
export function getFallbackMinMaxTimeStamp(): {
fallbackMin: number;
fallbackMax: number;
} {
const currentDate = new Date();
// Get the Unix timestamp (milliseconds since January 1, 1970)
const currentTime = currentDate.getTime();
const currentUnixTimestamp = Math.floor(currentTime / 1000);
// Calculate the date and time one day ago
const oneDayAgoUnixTimestamp = Math.floor(
(currentDate.getTime() - 86400000) / 1000,
); // 86400000 milliseconds in a day
return {
fallbackMin: oneDayAgoUnixTimestamp,
fallbackMax: currentUnixTimestamp,
};
}

View File

@@ -0,0 +1,39 @@
import { convertValue } from 'lib/getConvertedValue';
import { Threshold } from '../hooks/types';
/**
* Find min and max threshold values after converting to the target unit
*/
export function findMinMaxThresholdValues(
thresholds: Threshold[],
yAxisUnit?: string,
): [number | null, number | null] {
if (!thresholds || thresholds.length === 0) {
return [null, null];
}
let minThresholdValue: number | null = null;
let maxThresholdValue: number | null = null;
thresholds.forEach((threshold) => {
const { thresholdValue, thresholdUnit } = threshold;
if (thresholdValue === undefined) {
return;
}
const compareValue = convertValue(thresholdValue, thresholdUnit, yAxisUnit);
if (compareValue === null) {
return;
}
if (minThresholdValue === null || compareValue < minThresholdValue) {
minThresholdValue = compareValue;
}
if (maxThresholdValue === null || compareValue > maxThresholdValue) {
maxThresholdValue = compareValue;
}
});
return [minThresholdValue, maxThresholdValue];
}

View File

@@ -0,0 +1,17 @@
import { Range } from 'uplot';
export type LogScaleLimits = {
min: number | null;
max: number | null;
softMin: number | null;
softMax: number | null;
};
export type RangeFunctionParams = {
rangeConfig: Range.Config;
hardMinOnly: boolean;
hardMaxOnly: boolean;
hasFixedRange: boolean;
min: number | null;
max: number | null;
};