diff --git a/electron/src/components/graph/BigGraph.tsx b/electron/src/components/graph/BigGraph.tsx index 8a8796ff..803b4bc8 100644 --- a/electron/src/components/graph/BigGraph.tsx +++ b/electron/src/components/graph/BigGraph.tsx @@ -20,6 +20,7 @@ import { TouchButton } from "../touch/TouchButton"; import { seriesToUPlotData } from "@/lib/timeseries"; import { ControlCard } from "@/control/ControlCard"; import { getAllTimeSeries } from "./createChart"; +import { TargetDashOverlay } from "./TargetDashOverlay"; // Collects lines connected to visible series for rendering const getVisibleLines = (data: any, visibleSeries: boolean[]) => { @@ -595,10 +596,18 @@ export function BigGraph({
+ > +
+ +
diff --git a/electron/src/components/graph/TargetDashOverlay.tsx b/electron/src/components/graph/TargetDashOverlay.tsx new file mode 100644 index 00000000..6639b276 --- /dev/null +++ b/electron/src/components/graph/TargetDashOverlay.tsx @@ -0,0 +1,411 @@ +import React, { useEffect, useMemo, useState, useId } from "react"; +import uPlot from "uplot"; +import { BigGraphProps, GraphConfig } from "./types"; +import { getAllTimeSeries } from "./createChart"; + +type OverlayLine = { + key: string; + color: string; + width: number; + dash: number[]; + d: string; +}; + +type ClipRect = { + x: number; + y: number; + width: number; + height: number; +}; + +function findFirstGe(arr: number[], value: number): number { + let lo = 0; + let hi = arr.length - 1; + let ans = arr.length; + + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (arr[mid] >= value) { + ans = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + return ans; +} + +function findLastLe(arr: number[], value: number): number { + let lo = 0; + let hi = arr.length - 1; + let ans = -1; + + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (arr[mid] <= value) { + ans = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return ans; +} + +function buildSteppedPath( + u: uPlot, + xData: number[], + yData: Array, +): string { + const xMin = u.scales.x?.min; + const xMax = u.scales.x?.max; + + if (xData.length < 2 || xMin === undefined || xMax === undefined) { + return ""; + } + + const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1); + const lastVisibleIdx = Math.min( + xData.length - 1, + findLastLe(xData, xMax) + 1, + ); + + if (lastVisibleIdx < firstVisibleIdx) { + return ""; + } + + const parts: string[] = []; + let started = false; + let prevX = 0; + let prevY = 0; + + for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) { + const value = yData[i]; + if (value === null || value === undefined) continue; + + const x = u.valToPos(xData[i], "x", true); + const y = u.valToPos(value, "y", true); + + if (!started) { + parts.push(`M ${x} ${y}`); + started = true; + prevX = x; + prevY = y; + continue; + } + + if (x !== prevX) { + parts.push(`L ${x} ${prevY}`); + } + if (y !== prevY) { + parts.push(`L ${x} ${y}`); + } + + prevX = x; + prevY = y; + } + + return parts.join(" "); +} + +function buildFlatLatestValuePath( + u: uPlot, + yData: Array, +): string { + let latestValue: number | null = null; + + for (let i = yData.length - 1; i >= 0; i--) { + const value = yData[i]; + if (value !== null && value !== undefined) { + latestValue = value; + break; + } + } + + if (latestValue === null) { + return ""; + } + + const y = u.valToPos(latestValue, "y", true); + const left = u.bbox.left; + const right = u.bbox.left + u.bbox.width; + + return `M ${left} ${y} L ${right} ${y}`; +} + +function getHistoricalDashTargets( + data: BigGraphProps["newData"], + config: GraphConfig, +): Array<{ + dataIndex: number; + dash: number[]; + color: string; + width: number; +}> { + const allOriginalSeries = getAllTimeSeries(data); + const firstConfigLineDataIndex = 1 + allOriginalSeries.length; + let visibleLineIndex = 0; + + const targets: Array<{ + dataIndex: number; + dash: number[]; + color: string; + width: number; + }> = []; + + config.lines?.forEach((line) => { + if (line.show === false) return; + + const dash = line.dash ?? (line.type === "threshold" ? [5, 5] : undefined); + const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length; + + if (isHistoricalDashedTarget) { + targets.push({ + dataIndex: firstConfigLineDataIndex + visibleLineIndex, + dash: dash!, + color: line.color, + width: line.width ?? 1, + }); + } + + visibleLineIndex++; + }); + + return targets; +} + +function areDashArraysEqual(a: number[], b: number[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function areOverlayLinesEqual(a: OverlayLine[], b: OverlayLine[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if ( + a[i].key !== b[i].key || + a[i].color !== b[i].color || + a[i].width !== b[i].width || + a[i].d !== b[i].d || + !areDashArraysEqual(a[i].dash, b[i].dash) + ) { + return false; + } + } + return true; +} + +function isSameClipRect(a: ClipRect | null, b: ClipRect): boolean { + return ( + !!a && + a.x === b.x && + a.y === b.y && + a.width === b.width && + a.height === b.height + ); +} + +export function TargetDashOverlay({ + uplotRef, + newData, + config, + selectedTimeWindow, + isLiveMode, +}: { + uplotRef: React.RefObject; + newData: BigGraphProps["newData"]; + config: GraphConfig; + selectedTimeWindow: number | "all"; + isLiveMode: boolean; +}) { + const targetMeta = useMemo( + () => getHistoricalDashTargets(newData, config), + [newData, config], + ); + const clipPathId = useId().replace(/:/g, ""); + + const [lines, setLines] = useState([]); + const [clipRect, setClipRect] = useState(null); + + useEffect(() => { + if (targetMeta.length === 0) { + setLines([]); + setClipRect(null); + } + }, [targetMeta]); + + useEffect(() => { + let rafId: number | null = null; + let bootstrapRafId: number | null = null; + let recalcScheduled = false; + let hooksAttached = false; + const removeHookFns: Array<() => void> = []; + let resizeObserver: ResizeObserver | null = null; + + const recalc = () => { + recalcScheduled = false; + const u = uplotRef.current; + if (!u || targetMeta.length === 0) { + return; + } + + const xData = u.data[0] as number[] | undefined; + if (!xData || xData.length < 2) { + return; + } + + const nextLines = targetMeta + .map((meta, index) => { + const yData = u.data[meta.dataIndex] as + | Array + | undefined; + if (!yData || yData.length < 2) return null; + + const useFlatShortWindowPath = + isLiveMode && + selectedTimeWindow !== "all" && + selectedTimeWindow <= 60_000; + + const d = useFlatShortWindowPath + ? buildFlatLatestValuePath(u, yData) + : buildSteppedPath(u, xData, yData); + if (!d) return null; + + return { + key: `${meta.dataIndex}-${index}`, + color: meta.color, + width: meta.width, + dash: meta.dash, + d, + } as OverlayLine; + }) + .filter((line): line is OverlayLine => !!line); + + if (nextLines.length > 0) { + setLines((prev) => + areOverlayLinesEqual(prev, nextLines) ? prev : nextLines, + ); + const nextClipRect: ClipRect = { + x: u.bbox.left, + y: u.bbox.top, + width: u.bbox.width, + height: u.bbox.height, + }; + setClipRect((prev) => + isSameClipRect(prev, nextClipRect) ? prev : nextClipRect, + ); + } + }; + + const scheduleRecalc = () => { + if (recalcScheduled) return; + recalcScheduled = true; + rafId = window.requestAnimationFrame(recalc); + }; + + scheduleRecalc(); + + if (targetMeta.length === 0) { + return; + } + + const addHook = (u: any, hookName: string, fn: () => void) => { + const hooks = u.hooks?.[hookName]; + if (!Array.isArray(hooks)) return; + hooks.push(fn); + removeHookFns.push(() => { + const idx = hooks.indexOf(fn); + if (idx >= 0) hooks.splice(idx, 1); + }); + }; + + const onWindowResize = () => scheduleRecalc(); + window.addEventListener("resize", onWindowResize); + + const attachHooks = (): boolean => { + if (hooksAttached) return true; + const u = uplotRef.current as any; + if (!u) return false; + + addHook(u, "draw", scheduleRecalc); + addHook(u, "setScale", scheduleRecalc); + addHook(u, "setData", scheduleRecalc); + + const rootElement = u.root as HTMLElement | undefined; + if (rootElement && typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => scheduleRecalc()); + resizeObserver.observe(rootElement); + } + + hooksAttached = true; + scheduleRecalc(); + return true; + }; + + if (!attachHooks()) { + const tryAttach = () => { + if (attachHooks()) return; + bootstrapRafId = window.requestAnimationFrame(tryAttach); + }; + bootstrapRafId = window.requestAnimationFrame(tryAttach); + } + + return () => { + if (rafId !== null) { + window.cancelAnimationFrame(rafId); + } + if (bootstrapRafId !== null) { + window.cancelAnimationFrame(bootstrapRafId); + } + removeHookFns.forEach((fn) => fn()); + window.removeEventListener("resize", onWindowResize); + resizeObserver?.disconnect(); + }; + }, [uplotRef, targetMeta, isLiveMode, selectedTimeWindow]); + + if (lines.length === 0) { + return null; + } + + return ( + + {clipRect && ( + + + + + + )} + + + {lines.map((line) => { + return ( + + ); + })} + + + ); +} diff --git a/electron/src/components/graph/createChart.ts b/electron/src/components/graph/createChart.ts index da400664..fdcbc87d 100644 --- a/electron/src/components/graph/createChart.ts +++ b/electron/src/components/graph/createChart.ts @@ -12,15 +12,6 @@ import { import { BigGraphProps, CreateChartParams } from "./types"; import { normalizeDataSeries } from "./animation"; -type CustomDashedTargetLine = { - dataIndex: number; - dash: number[]; - color: string; - width: number; -}; - -const DASH_FLOW_PX_PER_SEC = 180; - export function createChart({ containerRef, uplotRef, @@ -161,8 +152,6 @@ export function createChart({ // Build series configuration for ALL series (but control visibility) const seriesConfig: uPlot.Series[] = [{ label: "Time" }]; - const customDashedTargetLines: CustomDashedTargetLine[] = []; - const dashFlowStartTime = performance.now(); // Add ALL data series with visibility control const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"]; @@ -188,35 +177,21 @@ export function createChart({ }); // Add config lines - let visibleLineIndex = 0; - const firstConfigLineDataIndex = 1 + allOriginalSeries.length; config.lines?.forEach((line) => { if (line.show !== false) { const dash = line.dash ?? (line.type === "threshold" ? [5, 5] : undefined); - const lineDataIndex = firstConfigLineDataIndex + visibleLineIndex; const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length; - if (isHistoricalDashedTarget) { - customDashedTargetLines.push({ - dataIndex: lineDataIndex, - dash: dash!, - color: line.color, - width: line.width ?? 1, - }); - } - seriesConfig.push({ label: line.label, - // Historical dashed targets are rendered manually in draw hook to - // avoid dash shimmer while the live x-axis slides. + // 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 }, }); - visibleLineIndex++; } }); @@ -346,119 +321,6 @@ export function createChart({ } }, ], - draw: [ - (u) => { - if (customDashedTargetLines.length === 0) return; - const xData = u.data[0] as number[] | undefined; - if (!xData || xData.length < 2) return; - - const xMin = u.scales.x?.min; - const xMax = u.scales.x?.max; - if (xMin === undefined || xMax === undefined) return; - - const ctx = u.ctx; - const left = u.bbox.left; - const top = u.bbox.top; - const snap = (v: number) => Math.round(v) + 0.5; - const findFirstGe = (arr: number[], value: number): number => { - let lo = 0; - let hi = arr.length - 1; - let ans = arr.length; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - if (arr[mid] >= value) { - ans = mid; - hi = mid - 1; - } else { - lo = mid + 1; - } - } - return ans; - }; - const findLastLe = (arr: number[], value: number): number => { - let lo = 0; - let hi = arr.length - 1; - let ans = -1; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - if (arr[mid] <= value) { - ans = mid; - lo = mid + 1; - } else { - hi = mid - 1; - } - } - return ans; - }; - const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1); - const lastVisibleIdx = Math.min( - xData.length - 1, - findLastLe(xData, xMax) + 1, - ); - if (lastVisibleIdx < firstVisibleIdx) return; - - ctx.save(); - ctx.beginPath(); - ctx.rect(left, top, u.bbox.width, u.bbox.height); - ctx.clip(); - ctx.lineCap = "butt"; - - customDashedTargetLines.forEach( - ({ dataIndex, dash, color, width }) => { - const yData = u.data[dataIndex] as - | Array - | undefined; - if (!yData || yData.length < 2) return; - - const dashPeriod = dash.reduce((acc, curr) => acc + curr, 0); - ctx.setLineDash(dash); - const elapsedMs = performance.now() - dashFlowStartTime; - const dashTravelPx = (elapsedMs * DASH_FLOW_PX_PER_SEC) / 1000; - ctx.lineDashOffset = - dashPeriod > 0 ? -(dashTravelPx % dashPeriod) : 0; - ctx.strokeStyle = color; - ctx.lineWidth = width; - ctx.beginPath(); - - let started = false; - let prevX = 0; - let prevY = 0; - - for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) { - const value = yData[i]; - if (value === null || value === undefined) continue; - - const x = snap(u.valToPos(xData[i], "x", true)); - const y = snap(u.valToPos(value, "y", true)); - - if (!started) { - ctx.moveTo(x, y); - started = true; - prevX = x; - prevY = y; - continue; - } - - if (x !== prevX) { - ctx.lineTo(x, prevY); - } - if (y !== prevY) { - ctx.lineTo(x, y); - } - - prevX = x; - prevY = y; - } - - if (started) { - ctx.stroke(); - } - }, - ); - - ctx.restore(); - }, - ], }, scales: { x: { @@ -660,25 +522,12 @@ export function createChart({ const cleanup = attachEventHandlers(containerRef.current, handlers); - let dashFlowAnimationFrame: number | null = null; - if (customDashedTargetLines.length > 0) { - const animateDashFlow = () => { - if (!uplotRef.current) return; - uplotRef.current.redraw(); - dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow); - }; - dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow); - } - animationRefs.lastRenderedData.current = { timestamps: [...timestamps], values: [...primaryValues], }; return () => { - if (dashFlowAnimationFrame !== null) { - cancelAnimationFrame(dashFlowAnimationFrame); - } cleanup(); }; }