mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-03 08:33:26 +00:00
Merge branch 'main' into test/e2e/alert_data_insert_fixture
This commit is contained in:
11
README.md
11
README.md
@@ -66,6 +66,17 @@ Read [more](https://signoz.io/metrics-and-dashboards/).
|
||||
|
||||

|
||||
|
||||
### LLM Observability
|
||||
|
||||
Monitor and debug your LLM applications with comprehensive observability. Track LLM calls, analyze token usage, monitor performance, and gain insights into your AI application's behavior in production.
|
||||
|
||||
SigNoz LLM observability helps you understand how your language models are performing, identify issues with prompts and responses, track token usage and costs, and optimize your AI applications for better performance and reliability.
|
||||
|
||||
[Get started with LLM Observability →](https://signoz.io/docs/llm-observability/)
|
||||
|
||||

|
||||
|
||||
|
||||
### Alerts
|
||||
|
||||
Use alerts in SigNoz to get notified when anything unusual happens in your application. You can set alerts on any type of telemetry signal (logs, metrics, traces), create thresholds and set up a notification channel to get notified. Advanced features like alert history and anomaly detection can help you create smarter alerts.
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.110.0
|
||||
image: signoz/signoz:v0.110.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.110.0
|
||||
image: signoz/signoz:v0.110.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.110.0}
|
||||
image: signoz/signoz:${VERSION:-v0.110.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.110.0}
|
||||
image: signoz/signoz:${VERSION:-v0.110.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"immer": "11.1.3",
|
||||
"jest": "^27.5.1",
|
||||
"js-base64": "^3.7.2",
|
||||
"less": "^4.1.2",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { DOCS_BASE_URL } from 'constants/app';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetGlobalConfig } from 'hooks/globalConfig/useGetGlobalConfig';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import history from 'lib/history';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
@@ -148,6 +149,8 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
|
||||
const { org } = useAppContext();
|
||||
|
||||
const { data: globalConfig } = useGetGlobalConfig();
|
||||
|
||||
const [setupStepItems, setSetupStepItems] = useState(setupStepItemsBase);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
@@ -233,6 +236,16 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
urlObj.searchParams.set('environment', selectedEnvironment);
|
||||
}
|
||||
|
||||
const ingestionUrl = globalConfig?.data?.ingestion_url;
|
||||
|
||||
if (ingestionUrl) {
|
||||
const parts = ingestionUrl.split('.');
|
||||
if (parts?.length > 1 && parts[0]?.includes('ingest')) {
|
||||
const region = parts[1];
|
||||
urlObj.searchParams.set('region', region);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Return the updated URL as a string
|
||||
const updatedUrl = urlObj.toString();
|
||||
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
findByText,
|
||||
fireEvent,
|
||||
render,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { pipelineApiResponseMockData } from '../mocks/pipeline';
|
||||
import PipelineListsView from '../PipelineListsView';
|
||||
@@ -75,7 +85,20 @@ jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const attributeKeysURL = `${BASE_URL}/api/v3/autocomplete/attribute_keys`;
|
||||
|
||||
describe('PipelinePage container test', () => {
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
it('should render PipelineListsView section', () => {
|
||||
const { getByText, container } = render(
|
||||
<PreferenceContextProvider>
|
||||
@@ -272,6 +295,7 @@ describe('PipelinePage container test', () => {
|
||||
});
|
||||
|
||||
it('should have populated form fields when edit pipeline is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<PreferenceContextProvider>
|
||||
<PipelineListsView
|
||||
@@ -301,5 +325,52 @@ describe('PipelinePage container test', () => {
|
||||
|
||||
// to have length 2
|
||||
expect(screen.queryAllByText('source = nginx').length).toBe(2);
|
||||
|
||||
server.use(
|
||||
rest.get(attributeKeysURL, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
attributeKeys: [
|
||||
{
|
||||
key: 'otelServiceName',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
key: 'service.instance.id',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
{
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Open Filter input and type to trigger suggestions
|
||||
const filterSelect = screen.getByTestId('qb-search-select');
|
||||
const input = within(filterSelect).getByRole('combobox') as HTMLInputElement;
|
||||
|
||||
await user.click(input);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('otelServiceName')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const serviceNameOccurences = await screen.findAllByText('service.name');
|
||||
expect(serviceNameOccurences.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -356,7 +356,10 @@ function QueryBuilderSearch({
|
||||
|
||||
// conditional changes here to use a seperate component to render the example queries based on the option group label
|
||||
const customRendererForLogsExplorer = options.map((option) => (
|
||||
<Select.Option key={option.label} value={option.value}>
|
||||
<Select.Option
|
||||
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
|
||||
value={option.value}
|
||||
>
|
||||
<OptionRendererForLogs
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
@@ -371,6 +374,7 @@ function QueryBuilderSearch({
|
||||
return (
|
||||
<div className="query-builder-search-container">
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
transitionName=""
|
||||
@@ -488,7 +492,10 @@ function QueryBuilderSearch({
|
||||
{isLogsExplorerPage
|
||||
? customRendererForLogsExplorer
|
||||
: options.map((option) => (
|
||||
<Select.Option key={option.label} value={option.value}>
|
||||
<Select.Option
|
||||
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
|
||||
value={option.value}
|
||||
>
|
||||
<OptionRenderer
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
|
||||
19
frontend/src/hooks/dashboard/useDashboardVariables.ts
Normal file
19
frontend/src/hooks/dashboard/useDashboardVariables.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
dashboardVariablesStore,
|
||||
IDashboardVariables,
|
||||
} from '../../providers/Dashboard/store/dashboardVariablesStore';
|
||||
|
||||
export const useDashboardVariables = (): {
|
||||
dashboardVariables: IDashboardVariables;
|
||||
} => {
|
||||
const dashboardVariables = useSyncExternalStore(
|
||||
dashboardVariablesStore.subscribe,
|
||||
dashboardVariablesStore.getSnapshot,
|
||||
);
|
||||
|
||||
return {
|
||||
dashboardVariables,
|
||||
};
|
||||
};
|
||||
30
frontend/src/hooks/dashboard/useDashboardVariablesByType.ts
Normal file
30
frontend/src/hooks/dashboard/useDashboardVariablesByType.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
IDashboardVariable,
|
||||
TVariableQueryType,
|
||||
} from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useDashboardVariables } from './useDashboardVariables';
|
||||
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType: 'values',
|
||||
): IDashboardVariable[];
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType?: 'entries',
|
||||
): [string, IDashboardVariable][];
|
||||
export function useDashboardVariablesByType(
|
||||
variableType: TVariableQueryType,
|
||||
returnType?: 'values' | 'entries',
|
||||
): IDashboardVariable[] | [string, IDashboardVariable][] {
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
return useMemo(() => {
|
||||
const entries = Object.entries(dashboardVariables || {}).filter(
|
||||
(entry): entry is [string, IDashboardVariable] =>
|
||||
Boolean(entry[1].name) && entry[1].type === variableType,
|
||||
);
|
||||
return returnType === 'values' ? entries.map(([, value]) => value) : entries;
|
||||
}, [dashboardVariables, variableType, returnType]);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PANEL_GROUP_TYPES } from 'constants/queryBuilder';
|
||||
import { createDynamicVariableToWidgetsMap } from 'hooks/dashboard/utils';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useDashboardVariablesByType } from './useDashboardVariablesByType';
|
||||
|
||||
/**
|
||||
* Hook to get a map of dynamic variable IDs to widget IDs that use them.
|
||||
* This is useful for determining which widgets need to be refreshed when a dynamic variable changes.
|
||||
*/
|
||||
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
|
||||
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
return useMemo(() => {
|
||||
const widgets =
|
||||
selectedDashboard?.data?.widgets?.filter(
|
||||
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
|
||||
) || [];
|
||||
|
||||
return createDynamicVariableToWidgetsMap(
|
||||
dynamicVariables,
|
||||
widgets as Widgets[],
|
||||
);
|
||||
}, [selectedDashboard, dynamicVariables]);
|
||||
}
|
||||
@@ -193,7 +193,11 @@ export const useOptions = (
|
||||
(option, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(o) => o.label === option.label && o.value === option.value, // to remove duplicate & empty options from list
|
||||
(o) =>
|
||||
o.label === option.label &&
|
||||
o.value === option.value &&
|
||||
(o.type || '') === (option.type || '') &&
|
||||
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
|
||||
) && option.value !== '',
|
||||
) || []
|
||||
).map((option) => {
|
||||
|
||||
@@ -16,13 +16,19 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce((entries: ResizeObserverEntry[]) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setSize({ width, height });
|
||||
}
|
||||
}, debounceTime);
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
const { width, height } = entry.contentRect;
|
||||
setSize({ width, height });
|
||||
}
|
||||
},
|
||||
debounceTime,
|
||||
{
|
||||
leading: true,
|
||||
},
|
||||
);
|
||||
|
||||
const ro = new ResizeObserver(handleResize);
|
||||
const referenceNode = ref.current;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -45,6 +45,8 @@ import APIError from 'types/api/error';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { v4 as generateUUID } from 'uuid';
|
||||
|
||||
import { useDashboardVariables } from '../../hooks/dashboard/useDashboardVariables';
|
||||
import { updateDashboardVariablesStore } from './store/dashboardVariablesStore';
|
||||
import {
|
||||
DashboardSortOrder,
|
||||
IDashboardContext,
|
||||
@@ -196,6 +198,16 @@ export function DashboardProvider({
|
||||
: isDashboardWidgetPage?.params.dashboardId) || '';
|
||||
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard>();
|
||||
const dashboardVariables = useDashboardVariables();
|
||||
|
||||
useEffect(() => {
|
||||
const existingVariables = dashboardVariables;
|
||||
const updatedVariables = selectedDashboard?.data.variables || {};
|
||||
|
||||
if (!isEqual(existingVariables, updatedVariables)) {
|
||||
updateDashboardVariablesStore(updatedVariables);
|
||||
}
|
||||
}, [selectedDashboard]);
|
||||
|
||||
const {
|
||||
currentDashboard,
|
||||
|
||||
@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
|
||||
import { DashboardProvider, useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { useDashboardVariables } from '../../../hooks/dashboard/useDashboardVariables';
|
||||
import { initializeDefaultVariables } from '../initializeDefaultVariables';
|
||||
import { normalizeUrlValueForVariable } from '../normalizeUrlValue';
|
||||
|
||||
@@ -55,6 +56,7 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||
|
||||
function TestComponent(): JSX.Element {
|
||||
const { dashboardResponse, dashboardId, selectedDashboard } = useDashboard();
|
||||
const { dashboardVariables } = useDashboardVariables();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -65,9 +67,7 @@ function TestComponent(): JSX.Element {
|
||||
{dashboardResponse.isFetching.toString()}
|
||||
</div>
|
||||
<div data-testid="dashboard-variables">
|
||||
{selectedDashboard?.data?.variables
|
||||
? JSON.stringify(selectedDashboard.data.variables)
|
||||
: 'null'}
|
||||
{dashboardVariables ? JSON.stringify(dashboardVariables) : 'null'}
|
||||
</div>
|
||||
<div data-testid="dashboard-data">
|
||||
{selectedDashboard?.data?.title || 'No Title'}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import createStore from './store';
|
||||
|
||||
// export type IDashboardVariables = DashboardData['variables'];
|
||||
export type IDashboardVariables = Record<string, IDashboardVariable>;
|
||||
|
||||
export const dashboardVariablesStore = createStore<IDashboardVariables>({});
|
||||
|
||||
export function updateDashboardVariablesStore(
|
||||
variables: Partial<IDashboardVariables>,
|
||||
): void {
|
||||
dashboardVariablesStore.update((currentVariables) => ({
|
||||
...currentVariables,
|
||||
...variables,
|
||||
}));
|
||||
}
|
||||
44
frontend/src/providers/Dashboard/store/store.ts
Normal file
44
frontend/src/providers/Dashboard/store/store.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { produce } from 'immer';
|
||||
type ListenerFn = () => void;
|
||||
|
||||
export default function createStore<T>(
|
||||
init: T,
|
||||
): {
|
||||
set: (setter: any) => void;
|
||||
update: (updater: (draft: T) => void) => void;
|
||||
subscribe: (listener: ListenerFn) => () => void;
|
||||
getSnapshot: () => T;
|
||||
} {
|
||||
let listeners: ListenerFn[] = [];
|
||||
let state = init;
|
||||
|
||||
function emitChange(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function set(setter: any): void {
|
||||
state = produce(state, setter);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function update(updater: (draft: T) => void): void {
|
||||
state = produce(state, updater);
|
||||
emitChange();
|
||||
}
|
||||
|
||||
return {
|
||||
set,
|
||||
update,
|
||||
subscribe(listener: ListenerFn): () => void {
|
||||
listeners = [...listeners, listener];
|
||||
return (): void => {
|
||||
listeners = listeners.filter((l) => l !== listener);
|
||||
};
|
||||
},
|
||||
getSnapshot(): T {
|
||||
return state;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -12030,6 +12030,11 @@ immediate@~3.0.5:
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==
|
||||
|
||||
immer@11.1.3:
|
||||
version "11.1.3"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.3.tgz#78681e1deb6cec39753acf04eb16d7576c04f4d6"
|
||||
integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==
|
||||
|
||||
immer@^9.0.6:
|
||||
version "9.0.21"
|
||||
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176"
|
||||
|
||||
Reference in New Issue
Block a user