From 35864b4de79f2864dd7dfd1b765bff8246ef68ef Mon Sep 17 00:00:00 2001
From: Achraf Jalleli <150543929+JalleliAchraf@users.noreply.github.com>
Date: Wed, 25 Feb 2026 16:42:37 +0100
Subject: [PATCH] 1186 target line static (#1162)
* graph: render historical dashed targets as static overlay
* movement improved
* formatting
* more performance optimizations
* formatting
---
electron/src/components/graph/BigGraph.tsx | 15 +-
.../components/graph/TargetDashOverlay.tsx | 411 ++++++++++++++++++
electron/src/components/graph/createChart.ts | 153 +------
3 files changed, 424 insertions(+), 155 deletions(-)
create mode 100644 electron/src/components/graph/TargetDashOverlay.tsx
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 (
+
+ );
+}
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();
};
}