556 lines
17 KiB
TypeScript
556 lines
17 KiB
TypeScript
import uPlot from "uplot";
|
|
import {
|
|
seriesToUPlotData,
|
|
TimeSeries,
|
|
alignTargetSeriesToTimestamps,
|
|
} from "@/lib/timeseries";
|
|
import {
|
|
createEventHandlers,
|
|
attachEventHandlers,
|
|
HandlerCallbacks,
|
|
} from "./handlers";
|
|
import { BigGraphProps, CreateChartParams } from "./types";
|
|
import { normalizeDataSeries } from "./animation";
|
|
|
|
export function createChart({
|
|
containerRef,
|
|
uplotRef,
|
|
uplotRefOut,
|
|
newData,
|
|
config,
|
|
colors,
|
|
renderValue,
|
|
viewMode,
|
|
selectedTimeWindow,
|
|
isLiveMode,
|
|
startTimeRef,
|
|
manualScaleRef,
|
|
animationRefs,
|
|
handlerRefs,
|
|
graphId,
|
|
syncGraph,
|
|
getHistoricalEndTimestamp,
|
|
updateYAxisScale,
|
|
setViewMode,
|
|
setIsLiveMode,
|
|
setCursorValue,
|
|
setCursorValues,
|
|
visibleSeries,
|
|
showFromTimestamp,
|
|
}: CreateChartParams): (() => void) | undefined {
|
|
if (!containerRef.current) return;
|
|
|
|
// Get ALL series from original data
|
|
const allOriginalSeries = getAllTimeSeries(newData);
|
|
if (allOriginalSeries.length === 0) return;
|
|
|
|
// Use the first available series for timing calculations (visible or not)
|
|
const primarySeries = allOriginalSeries[0].series;
|
|
const [timestamps, primaryValues] = seriesToUPlotData(primarySeries.long);
|
|
if (timestamps.length === 0) return;
|
|
|
|
if (startTimeRef.current === null && timestamps.length > 0) {
|
|
startTimeRef.current = timestamps[0];
|
|
}
|
|
|
|
// Build uPlot data for ALL series (including hidden ones)
|
|
const uPlotData: uPlot.AlignedData = [timestamps as any];
|
|
|
|
allOriginalSeries.forEach(({ series }) => {
|
|
const [, values] = seriesToUPlotData(series.long);
|
|
uPlotData.push(values as any);
|
|
});
|
|
|
|
// Add config lines data
|
|
config.lines?.forEach((line) => {
|
|
if (line.show !== false) {
|
|
const lineData = line.targetSeries
|
|
? alignTargetSeriesToTimestamps(
|
|
line.targetSeries,
|
|
timestamps,
|
|
line.value,
|
|
)
|
|
: new Array(timestamps.length).fill(line.value);
|
|
uPlotData.push(lineData as any);
|
|
}
|
|
});
|
|
|
|
const fullStart = startTimeRef.current ?? timestamps[0] ?? 0;
|
|
|
|
let initialMin: number, initialMax: number;
|
|
if (viewMode === "manual" && manualScaleRef.current) {
|
|
initialMin = manualScaleRef.current.x.min;
|
|
initialMax = manualScaleRef.current.x.max;
|
|
} else if (selectedTimeWindow === "all") {
|
|
initialMin = showFromTimestamp ?? fullStart;
|
|
initialMax = getHistoricalEndTimestamp();
|
|
} else {
|
|
const endTimestamp = getHistoricalEndTimestamp();
|
|
|
|
if (isLiveMode) {
|
|
const defaultViewStart = Math.max(
|
|
endTimestamp - (selectedTimeWindow as number),
|
|
fullStart,
|
|
);
|
|
initialMin = showFromTimestamp ?? defaultViewStart;
|
|
} else {
|
|
initialMin =
|
|
showFromTimestamp ?? endTimestamp - (selectedTimeWindow as number);
|
|
}
|
|
|
|
initialMax = endTimestamp;
|
|
}
|
|
|
|
// Calculate initial Y range from ALL series (not just visible ones)
|
|
// This prevents scale changes when toggling visibility
|
|
const initialAllValues: number[] = [];
|
|
allOriginalSeries.forEach(({ series }) => {
|
|
const [seriesTimestamps, seriesValues] = seriesToUPlotData(series.long);
|
|
for (let i = 0; i < seriesTimestamps.length; i++) {
|
|
if (
|
|
seriesTimestamps[i] >= initialMin &&
|
|
seriesTimestamps[i] <= initialMax
|
|
) {
|
|
initialAllValues.push(seriesValues[i]);
|
|
}
|
|
}
|
|
});
|
|
|
|
config.lines?.forEach((line) => {
|
|
if (line.show !== false) {
|
|
initialAllValues.push(line.value);
|
|
}
|
|
});
|
|
|
|
if (initialAllValues.length === 0) {
|
|
allOriginalSeries.forEach(({ series }) => {
|
|
const [, values] = seriesToUPlotData(series.long);
|
|
initialAllValues.push(...values);
|
|
});
|
|
config.lines?.forEach((line) => {
|
|
if (line.show !== false) {
|
|
initialAllValues.push(line.value);
|
|
}
|
|
});
|
|
}
|
|
|
|
let initialYMin: number, initialYMax: number;
|
|
if (initialAllValues.length > 0) {
|
|
const minY = Math.min(...initialAllValues);
|
|
const maxY = Math.max(...initialAllValues);
|
|
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
|
|
|
initialYMin = minY - range * 0.1;
|
|
initialYMax = maxY + range * 0.1;
|
|
} else {
|
|
initialYMin = -1;
|
|
initialYMax = 1;
|
|
}
|
|
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
const width = rect.width;
|
|
const height = Math.min(rect.height, window.innerHeight * 0.5);
|
|
|
|
// Build series configuration for ALL series (but control visibility)
|
|
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
|
|
|
// Add ALL data series with visibility control
|
|
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
|
allOriginalSeries.forEach(({ title, color }, index) => {
|
|
const isVisible = !visibleSeries || visibleSeries[index];
|
|
|
|
seriesConfig.push({
|
|
label: title || `Series ${index + 1}`,
|
|
stroke: color || defaultColors[index % defaultColors.length],
|
|
width: 2,
|
|
spanGaps: true,
|
|
show: isVisible, // Control visibility here
|
|
points: {
|
|
show: (_u, _seriesIdx, dataIdx) => {
|
|
return isVisible && dataIdx < animationRefs.realPointsCount.current;
|
|
},
|
|
size: 6,
|
|
stroke: color || defaultColors[index % defaultColors.length],
|
|
fill: color || defaultColors[index % defaultColors.length],
|
|
width: 2,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Add config lines
|
|
config.lines?.forEach((line) => {
|
|
if (line.show !== false) {
|
|
const dash =
|
|
line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
|
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
|
|
|
seriesConfig.push({
|
|
label: line.label,
|
|
// Historical dashed targets are rendered in an SVG overlay.
|
|
stroke: isHistoricalDashedTarget ? "rgba(0,0,0,0)" : line.color,
|
|
width: line.width ?? 1,
|
|
dash: isHistoricalDashedTarget ? undefined : dash,
|
|
show: true,
|
|
points: { show: false },
|
|
});
|
|
}
|
|
});
|
|
|
|
// Always destroy existing chart before creating new one
|
|
if (uplotRef.current) {
|
|
if (uplotRefOut?.current != null) {
|
|
uplotRefOut.current = null;
|
|
}
|
|
uplotRef.current.destroy();
|
|
uplotRef.current = null;
|
|
}
|
|
|
|
// Create new chart with initial visibility settings
|
|
uplotRef.current = new uPlot(
|
|
{
|
|
width,
|
|
height,
|
|
padding: [-10, 20, -10, 20],
|
|
cursor: {
|
|
show: true,
|
|
x: true,
|
|
y: true,
|
|
drag: {
|
|
x: true,
|
|
y: false,
|
|
setScale: true,
|
|
},
|
|
sync: { key: "myCursor" },
|
|
},
|
|
legend: {
|
|
show: config.showLegend ?? allOriginalSeries.length > 1,
|
|
},
|
|
hooks: {
|
|
setScale: [
|
|
(u) => {
|
|
if (handlerRefs.isUserZoomingRef.current) {
|
|
const xScale = u.scales.x;
|
|
if (xScale.min !== undefined && xScale.max !== undefined) {
|
|
updateYAxisScale(xScale.min, xScale.max);
|
|
|
|
manualScaleRef.current = {
|
|
x: { min: xScale.min ?? 0, max: xScale.max ?? 0 },
|
|
y: {
|
|
min: u.scales.y?.min ?? 0,
|
|
max: u.scales.y?.max ?? 1,
|
|
},
|
|
};
|
|
|
|
setViewMode("manual");
|
|
setIsLiveMode(false);
|
|
|
|
if (syncGraph?.onZoomChange) {
|
|
syncGraph.onZoomChange(graphId, {
|
|
min: xScale.min ?? 0,
|
|
max: xScale.max ?? 0,
|
|
});
|
|
}
|
|
|
|
if (syncGraph?.onViewModeChange) {
|
|
syncGraph.onViewModeChange(graphId, "manual", false);
|
|
}
|
|
}
|
|
handlerRefs.isUserZoomingRef.current = false;
|
|
}
|
|
},
|
|
],
|
|
setCursor: [
|
|
(u) => {
|
|
if (typeof u.cursor.idx === "number") {
|
|
const timestamp = u.data[0][u.cursor.idx];
|
|
|
|
// Handle single series case
|
|
if (allOriginalSeries.length === 1) {
|
|
if (u.data[1] && u.data[1][u.cursor.idx] !== undefined) {
|
|
const value = u.data[1][u.cursor.idx];
|
|
const cur = allOriginalSeries[0]?.series?.current;
|
|
|
|
const isNearCurrent =
|
|
cur &&
|
|
timestamp !== undefined &&
|
|
Math.abs(timestamp - cur.timestamp) < 1000;
|
|
|
|
const displayValue = isNearCurrent ? cur.value : value;
|
|
setCursorValue(displayValue ?? null);
|
|
} else {
|
|
setCursorValue(null);
|
|
}
|
|
}
|
|
// Handle multiple series case
|
|
else if (setCursorValues && visibleSeries) {
|
|
// Create cursor values array for ALL original series
|
|
const cursorValuesArray: (number | null)[] = new Array(
|
|
allOriginalSeries.length,
|
|
).fill(null);
|
|
|
|
// Since we include ALL series in uPlot data, indices match directly
|
|
allOriginalSeries.forEach((originalSeries, originalIndex) => {
|
|
const dataIndex = originalIndex + 1; // +1 because 0 is timestamps
|
|
|
|
if (
|
|
u.data[dataIndex] &&
|
|
u.data[dataIndex][u.cursor.idx!] !== undefined
|
|
) {
|
|
const value = u.data[dataIndex][u.cursor.idx!];
|
|
const cur = originalSeries.series?.current;
|
|
|
|
const isNearCurrent =
|
|
cur &&
|
|
timestamp !== undefined &&
|
|
Math.abs(timestamp - cur.timestamp) < 1000;
|
|
|
|
const displayValue = isNearCurrent ? cur.value : value;
|
|
cursorValuesArray[originalIndex] = displayValue ?? null;
|
|
}
|
|
});
|
|
|
|
setCursorValues(cursorValuesArray);
|
|
|
|
// Set primary cursor value for backward compatibility
|
|
const firstVisibleValue = cursorValuesArray.find(
|
|
(v) => v !== null,
|
|
);
|
|
setCursorValue(firstVisibleValue ?? null);
|
|
}
|
|
} else {
|
|
setCursorValue(null);
|
|
if (setCursorValues) {
|
|
setCursorValues(new Array(allOriginalSeries.length).fill(null));
|
|
}
|
|
}
|
|
},
|
|
],
|
|
},
|
|
scales: {
|
|
x: {
|
|
time: true,
|
|
auto: true,
|
|
min: initialMin,
|
|
max: initialMax,
|
|
distr: 1,
|
|
},
|
|
y: {
|
|
auto: false,
|
|
min: initialYMin,
|
|
max: initialYMax,
|
|
},
|
|
},
|
|
|
|
axes: [
|
|
{
|
|
stroke: colors.axis,
|
|
labelSize: 12,
|
|
labelFont: "Inter, system-ui, sans-serif",
|
|
grid: { stroke: colors.grid, width: 1 },
|
|
space: 60,
|
|
values: (u, ticks) => {
|
|
const xScale = u.scales.x;
|
|
if (
|
|
!xScale ||
|
|
xScale.min === undefined ||
|
|
xScale.max === undefined
|
|
) {
|
|
return ticks.map((ts) => {
|
|
const date = new Date(ts);
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
});
|
|
}
|
|
|
|
const timeRange = xScale.max - xScale.min;
|
|
|
|
return ticks.map((ts) => {
|
|
const date = new Date(ts);
|
|
|
|
if (timeRange <= 30 * 1000) {
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
} else if (timeRange <= 5 * 60 * 1000) {
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
});
|
|
} else if (timeRange <= 60 * 60 * 1000) {
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
|
return date.toLocaleTimeString(undefined, {
|
|
hour12: false,
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} else {
|
|
return date.toLocaleString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
}
|
|
});
|
|
},
|
|
splits: (_u, _axisIdx, scaleMin, scaleMax) => {
|
|
const timeRange = scaleMax - scaleMin;
|
|
const ticks: number[] = [];
|
|
|
|
if (timeRange <= 10 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 1000) * 1000;
|
|
for (let t = startTime; t <= scaleMax; t += 1000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 30 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 5000) * 5000;
|
|
for (let t = startTime; t <= scaleMax; t += 5000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 10000) * 10000;
|
|
for (let t = startTime; t <= scaleMax; t += 10000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 5 * 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 30000) * 30000;
|
|
for (let t = startTime; t <= scaleMax; t += 30000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 10 * 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 60000) * 60000;
|
|
for (let t = startTime; t <= scaleMax; t += 60000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 30 * 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 120000) * 120000;
|
|
for (let t = startTime; t <= scaleMax; t += 120000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 60 * 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 300000) * 300000;
|
|
for (let t = startTime; t <= scaleMax; t += 300000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
|
const startTime = Math.ceil(scaleMin / 1800000) * 1800000;
|
|
for (let t = startTime; t <= scaleMax; t += 1800000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
} else {
|
|
const startTime = Math.ceil(scaleMin / 3600000) * 3600000;
|
|
for (let t = startTime; t <= scaleMax; t += 3600000) {
|
|
if (t >= scaleMin) ticks.push(t);
|
|
}
|
|
}
|
|
|
|
return ticks;
|
|
},
|
|
},
|
|
{
|
|
stroke: colors.axis,
|
|
labelSize: 12,
|
|
labelFont: "Inter, system-ui, sans-serif",
|
|
grid: { stroke: colors.grid, width: 1 },
|
|
side: 1,
|
|
space: 60,
|
|
values: (_u, ticks): (string | number | null)[] => {
|
|
if (renderValue) {
|
|
const renderedValues = ticks.map(renderValue);
|
|
const uniqueValues = new Set(renderedValues);
|
|
|
|
if (uniqueValues.size === renderedValues.length) {
|
|
return renderedValues as (string | number | null)[];
|
|
}
|
|
}
|
|
|
|
const precision = 0;
|
|
const maxPrecision = 4;
|
|
|
|
for (let p = precision; p <= maxPrecision; p++) {
|
|
const formattedValues = ticks.map((v) => v.toFixed(p));
|
|
const uniqueFormatted = new Set(formattedValues);
|
|
|
|
if (uniqueFormatted.size === formattedValues.length) {
|
|
return formattedValues;
|
|
}
|
|
}
|
|
|
|
return ticks.map((v) => v.toFixed(maxPrecision));
|
|
},
|
|
},
|
|
],
|
|
|
|
series: seriesConfig,
|
|
},
|
|
uPlotData,
|
|
containerRef.current,
|
|
);
|
|
|
|
if (uplotRefOut) {
|
|
uplotRefOut.current = uplotRef.current;
|
|
}
|
|
|
|
// Create handler callbacks
|
|
const handlerCallbacks: HandlerCallbacks = {
|
|
updateYAxisScale,
|
|
setViewMode,
|
|
setIsLiveMode,
|
|
onZoomChange: syncGraph?.onZoomChange,
|
|
onViewModeChange: syncGraph?.onViewModeChange,
|
|
};
|
|
|
|
// Create and attach event handlers
|
|
const handlers = createEventHandlers(
|
|
containerRef,
|
|
uplotRef,
|
|
handlerRefs,
|
|
handlerCallbacks,
|
|
newData,
|
|
config,
|
|
graphId,
|
|
manualScaleRef,
|
|
width,
|
|
);
|
|
|
|
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
|
|
|
animationRefs.lastRenderedData.current = {
|
|
timestamps: [...timestamps],
|
|
values: [...primaryValues],
|
|
};
|
|
|
|
return () => {
|
|
cleanup();
|
|
};
|
|
}
|
|
|
|
// Helper function to get all valid TimeSeries from DataSeries
|
|
export function getAllTimeSeries(
|
|
data: BigGraphProps["newData"],
|
|
): Array<{ series: TimeSeries; title?: string; color?: string }> {
|
|
const normalized = normalizeDataSeries(data);
|
|
return normalized
|
|
.filter((series) => series.newData !== null)
|
|
.map((series) => ({
|
|
series: series.newData!,
|
|
title: series.title,
|
|
color: series.color,
|
|
}));
|
|
}
|