mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
Merge branch 'main' into qb-json-fixes
This commit is contained in:
@@ -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[];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
105
frontend/src/lib/uPlotV2/components/Legend/Legend.tsx
Normal file
105
frontend/src/lib/uPlotV2/components/Legend/Legend.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/lib/uPlotV2/components/Legend/useLegendActions.ts
Normal file
118
frontend/src/lib/uPlotV2/components/Legend/useLegendActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
94
frontend/src/lib/uPlotV2/components/Tooltip/Tooltip.tsx
Normal file
94
frontend/src/lib/uPlotV2/components/Tooltip/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/lib/uPlotV2/components/Tooltip/utils.ts
Normal file
76
frontend/src/lib/uPlotV2/components/Tooltip/utils.ts
Normal 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];
|
||||
}
|
||||
199
frontend/src/lib/uPlotV2/components/UPlotChart.tsx
Normal file
199
frontend/src/lib/uPlotV2/components/UPlotChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/lib/uPlotV2/components/types.ts
Normal file
87
frontend/src/lib/uPlotV2/components/types.ts
Normal 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;
|
||||
}
|
||||
284
frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts
Normal file
284
frontend/src/lib/uPlotV2/config/UPlotAxisBuilder.ts
Normal 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 };
|
||||
289
frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts
Normal file
289
frontend/src/lib/uPlotV2/config/UPlotConfigBuilder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
155
frontend/src/lib/uPlotV2/config/UPlotScaleBuilder.ts
Normal file
155
frontend/src/lib/uPlotV2/config/UPlotScaleBuilder.ts
Normal 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 };
|
||||
232
frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts
Normal file
232
frontend/src/lib/uPlotV2/config/UPlotSeriesBuilder.ts
Normal 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 };
|
||||
199
frontend/src/lib/uPlotV2/config/types.ts
Normal file
199
frontend/src/lib/uPlotV2/config/types.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
136
frontend/src/lib/uPlotV2/context/PlotContext.tsx
Normal file
136
frontend/src/lib/uPlotV2/context/PlotContext.tsx
Normal 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;
|
||||
};
|
||||
12
frontend/src/lib/uPlotV2/hooks/types.ts
Normal file
12
frontend/src/lib/uPlotV2/hooks/types.ts
Normal 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;
|
||||
}
|
||||
142
frontend/src/lib/uPlotV2/hooks/useLegendsSync.ts
Normal file
142
frontend/src/lib/uPlotV2/hooks/useLegendsSync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
frontend/src/lib/uPlotV2/hooks/useThresholdsDrawHook.ts
Normal file
65
frontend/src/lib/uPlotV2/hooks/useThresholdsDrawHook.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
53
frontend/src/lib/uPlotV2/utils/dataUtils.ts
Normal file
53
frontend/src/lib/uPlotV2/utils/dataUtils.ts
Normal 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;
|
||||
}
|
||||
415
frontend/src/lib/uPlotV2/utils/scale.ts
Normal file
415
frontend/src/lib/uPlotV2/utils/scale.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
frontend/src/lib/uPlotV2/utils/threshold.ts
Normal file
39
frontend/src/lib/uPlotV2/utils/threshold.ts
Normal 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];
|
||||
}
|
||||
17
frontend/src/lib/uPlotV2/utils/types.ts
Normal file
17
frontend/src/lib/uPlotV2/utils/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user