Compare commits

..
Author SHA1 Message Date
just-some-entity 4fb62acab4 remove locking 2026-02-25 13:34:57 +01:00
just-some-entity db5edc1832 add logging 2026-02-25 13:20:17 +01:00
35 changed files with 605 additions and 3098 deletions

No files matched your search

-3
View File
@@ -8,9 +8,6 @@ on:
branches:
- master
permissions:
contents: read
defaults:
run:
working-directory: electron
-3
View File
@@ -8,9 +8,6 @@ on:
branches:
- master
permissions:
contents: read
env:
NIX_PATH: nixpkgs=channel:nixos-25.05
-3
View File
@@ -12,9 +12,6 @@ env:
CARGO_TERM_COLOR: always
RUST_VERSION: "1.86"
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
+37 -87
View File
@@ -71,7 +71,7 @@
"@types/react": "^19.1.16",
"@types/react-dom": "^19.0.4",
"@types/react-virtualized": "^9.22.2",
"electron": "^40.6.1",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
@@ -4698,15 +4698,8 @@
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
"deep-eql": "^5.0.1",
"loupe": "^3.1.0",
"pathval": "^2.0.0"
},
"engines": {
"node": "*"
"node": ">=18"
}
},
"node_modules/chalk": {
@@ -4766,16 +4759,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -5057,16 +5040,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -5223,15 +5196,15 @@
}
},
"node_modules/electron": {
"version": "40.6.1",
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
"version": "37.10.3",
"resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz",
"integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^24.9.0",
"@types/node": "^22.7.7",
"extract-zip": "^2.0.1"
},
"bin": {
@@ -5312,6 +5285,16 @@
"global-agent": "^3.0.0"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/electron/node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
@@ -5347,6 +5330,13 @@
"semver": "bin/semver.js"
}
},
"node_modules/electron/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/electron/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -7970,13 +7960,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -8954,18 +8937,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -9345,16 +9316,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -10495,31 +10456,8 @@
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
"seroval-plugins": "~1.3.0"
}
},
"node_modules/solid-js/node_modules/seroval": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
},
"node_modules/solid-js/node_modules/seroval-plugins": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
"integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
"seroval": "~1.5.0",
"seroval-plugins": "~1.5.0"
}
},
"node_modules/sonner": {
@@ -11320,6 +11258,18 @@
"yaku": "^0.16.6"
}
},
"node_modules/unzip-crx-3/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",
+1 -1
View File
@@ -34,7 +34,7 @@
"@types/react": "^19.1.16",
"@types/react-dom": "^19.0.4",
"@types/react-virtualized": "^9.22.2",
"electron": "^40.6.1",
"electron": "^37.3.1",
"electron-playwright-helpers": "^1.7.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.2",
+34 -39
View File
@@ -20,7 +20,6 @@ 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[]) => {
@@ -111,26 +110,10 @@ export function BigGraph({
// State for series visibility
const normalizedSeries = normalizeDataSeries(newData);
const latestExportContextRef = useRef({
normalizedSeries,
config,
unit,
renderValue,
});
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
new Array(normalizedSeries.length).fill(true),
);
// Keep latest export context in a ref so export callbacks always read fresh data.
useEffect(() => {
latestExportContextRef.current = {
normalizedSeries,
config,
unit,
renderValue,
};
}, [normalizedSeries, config, unit, renderValue]);
// Enhanced configuration with visible series lines
const enhancedConfig = React.useMemo(() => {
return mergeConfigWithVisibleLines(config, newData, visibleSeries);
@@ -223,27 +206,35 @@ export function BigGraph({
// Register export functionality for the graph
useEffect(() => {
if (onRegisterForExport) {
// Unregister any previous registrations for this graph
if (onUnregisterFromExport) {
// Clear any existing registrations for this graph's series
normalizedSeries.forEach((_, index) => {
const seriesId = `${graphId}-series-${index}`;
onUnregisterFromExport(seriesId);
});
}
// Register each series as a separate export entry
normalizedSeries.forEach((_, index) => {
normalizedSeries.forEach((series, index) => {
if (!series.newData) return;
const seriesId = `${graphId}-series-${index}`;
const seriesTitle = series.title || `Series ${index + 1}`;
const getSeriesExportData = (): GraphExportData | null => {
const current = latestExportContextRef.current;
const series = current.normalizedSeries[index];
if (!series?.newData) return null;
// Create export data for this specific series only
const seriesLines = series.lines || [];
const exportConfig = {
...current.config,
title: current.config.title, // Keep original graph title
lines: [...(current.config.lines || []), ...seriesLines],
...config,
title: config.title, // Keep original graph title
lines: [...(config.lines || []), ...seriesLines],
};
// Create a single-series data structure for this series
const singleSeriesData: SeriesData = {
newData: series.newData,
title: series.title || `Series ${index + 1}`,
title: seriesTitle,
color: series.color,
lines: series.lines,
};
@@ -251,8 +242,8 @@ export function BigGraph({
return {
config: exportConfig,
data: singleSeriesData, // Return ONLY this specific series
unit: current.unit,
renderValue: current.renderValue,
unit,
renderValue,
};
};
@@ -273,7 +264,19 @@ export function BigGraph({
onRegisterForExport,
onUnregisterFromExport,
graphId,
config,
normalizedSeries.length,
unit,
renderValue,
// Add dependencies for series data
JSON.stringify(
normalizedSeries.map((s) => ({
title: s.title,
color: s.color,
hasData: !!s.newData,
linesCount: s.lines?.length || 0,
})),
),
]);
// Updates Y-axis scale dynamically based on visible data
@@ -592,18 +595,10 @@ export function BigGraph({
<div className="flex-1 overflow-hidden rounded-b-3xl pt-4">
<div
className={`relative h-full w-full overflow-hidden transition-opacity duration-100`}
ref={containerRef}
className={`h-full w-full overflow-hidden transition-opacity duration-100`}
style={{ backgroundColor: colors.background }}
>
<div ref={containerRef} className="h-full w-full overflow-hidden" />
<TargetDashOverlay
uplotRef={uplotRef}
newData={newData}
config={enhancedConfig}
selectedTimeWindow={selectedTimeWindow}
isLiveMode={isLiveMode}
/>
</div>
/>
</div>
</div>
</div>
@@ -1,411 +0,0 @@
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<number | null>,
): 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<number | null>,
): 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<uPlot | null>;
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<OverlayLine[]>([]);
const [clipRect, setClipRect] = useState<ClipRect | null>(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<number | null>
| 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 (
<svg
className="pointer-events-none absolute inset-0 h-full w-full"
aria-hidden
>
{clipRect && (
<defs>
<clipPath id={clipPathId}>
<rect
x={clipRect.x}
y={clipRect.y}
width={clipRect.width}
height={clipRect.height}
/>
</clipPath>
</defs>
)}
<g clipPath={clipRect ? `url(#${clipPathId})` : undefined}>
{lines.map((line) => {
return (
<path
key={line.key}
d={line.d}
fill="none"
stroke={line.color}
strokeWidth={line.width}
strokeLinecap="butt"
strokeDasharray={line.dash.join(" ")}
strokeDashoffset={0}
/>
);
})}
</g>
</svg>
);
}
+152 -1
View File
@@ -12,6 +12,15 @@ 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,
@@ -152,6 +161,8 @@ 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"];
@@ -177,21 +188,35 @@ 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 in an SVG overlay.
// Historical dashed targets are rendered manually in draw hook to
// avoid dash shimmer while the live x-axis slides.
stroke: isHistoricalDashedTarget ? "rgba(0,0,0,0)" : line.color,
width: line.width ?? 1,
dash: isHistoricalDashedTarget ? undefined : dash,
show: true,
points: { show: false },
});
visibleLineIndex++;
}
});
@@ -321,6 +346,119 @@ 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<number | null>
| 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: {
@@ -522,12 +660,25 @@ 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();
};
}
@@ -0,0 +1,318 @@
import * as XLSX from "xlsx";
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
import { renderUnitSymbol, Unit } from "@/control/units";
import { GraphConfig, SeriesData, GraphLine } from "./types";
export type GraphExportData = {
config: GraphConfig;
data: SeriesData; // Always a single series
unit?: Unit;
renderValue?: (value: number) => string;
};
export function exportGraphsToExcel(
graphDataMap: Map<string, () => GraphExportData | null>,
groupId: string,
): void {
try {
// Filter out invalid series IDs (those without "-series-")
const filteredMap = new Map<string, () => GraphExportData | null>();
graphDataMap.forEach((getDataFn, seriesId) => {
if (seriesId.includes("-series-")) {
filteredMap.set(seriesId, getDataFn);
}
});
const workbook = XLSX.utils.book_new();
const exportTimestamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.slice(0, 19);
const usedSheetNames = new Set<string>(); // Track unique sheet names
let processedCount = 0;
// Process each valid series
filteredMap.forEach((getDataFn, seriesId) => {
const exportData = getDataFn();
if (!exportData?.data?.newData) {
console.warn(`No data for series: ${seriesId}`);
return;
}
const series = exportData.data;
const seriesTitle = series.title || `Series ${processedCount + 1}`;
if (!series.newData) {
console.warn(`Series ${seriesTitle} has null data`);
return;
}
const targetLines: GraphLine[] = [
...(exportData.config.lines || []),
...(series.lines || []),
];
const graphLineData = {
graphTitle: exportData.config.title,
lineTitle: seriesTitle,
series: series.newData,
color: series.color,
unit: exportData.unit,
renderValue: exportData.renderValue,
config: exportData.config,
targetLines: targetLines,
};
// Create and append statistics sheet
const statsData = createGraphLineStatsSheet(graphLineData);
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
const statsSheetName = generateUniqueSheetName(
`${seriesTitle} Stats`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
// Create and append data sheet
const dataRows = createGraphLineDataSheet(graphLineData);
if (dataRows.length > 0) {
const dataWorksheet = XLSX.utils.json_to_sheet(dataRows);
const dataSheetName = generateUniqueSheetName(
`${seriesTitle} Data`,
usedSheetNames,
);
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
}
processedCount++;
});
if (processedCount === 0) {
alert("No data available to export from any graphs in this group");
return;
}
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
XLSX.writeFile(workbook, filename);
} catch (error) {
alert(
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
);
}
}
// Generate statistics sheet for a graph line
function createGraphLineStatsSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[][] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
const statsData = [
[`Graph Line Statistics: ${graphLine.lineTitle}`, ""],
["Graph", graphLine.graphTitle],
["Line Name", graphLine.lineTitle],
["Line Color", graphLine.color || "Default"],
["Generated", new Date()],
["", ""],
["Data Points Information", ""],
["Total Data Points", timestamps.length.toString()],
];
if (timestamps.length > 0) {
statsData.push(["Time Range Start", new Date(timestamps[0])]);
statsData.push([
"Time Range End",
new Date(timestamps[timestamps.length - 1]),
]);
const duration = timestamps[timestamps.length - 1] - timestamps[0];
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
statsData.push(["Duration (hours)", durationHours]);
if (values.length > 0) {
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
const stdDev = Math.sqrt(
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
values.length,
);
statsData.push(["", ""], ["Value Statistics", ""]);
statsData.push([
`Minimum Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(minValue)
: minValue.toFixed(3),
]);
statsData.push([
`Maximum Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxValue)
: maxValue.toFixed(3),
]);
statsData.push([
`Average Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(avgValue)
: avgValue.toFixed(3),
]);
statsData.push([
`Standard Deviation (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(stdDev)
: stdDev.toFixed(3),
]);
statsData.push([
`Range (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxValue - minValue)
: (maxValue - minValue).toFixed(3),
]);
// Percentiles
const sortedValues = [...values].sort((a, b) => a - b);
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
statsData.push(["", ""], ["Percentiles", ""]);
statsData.push([
`25th Percentile (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3),
]);
statsData.push([
`50th Percentile/Median (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3),
]);
statsData.push([
`75th Percentile (${unitSymbol})`,
graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3),
]);
}
}
// Add target line information
if (graphLine.targetLines.length > 0) {
statsData.push(["", ""], ["Target Lines", ""]);
graphLine.targetLines.forEach((line, index) => {
statsData.push([
`Target Line ${index + 1}`,
line.label || `Line ${line.value}`,
]);
statsData.push([
` Value (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(line.value)
: line.value.toFixed(3),
]);
statsData.push([` Type`, line.type || "reference"]);
statsData.push([` Color`, line.color || "default"]);
statsData.push([` Show`, line.show !== false ? "Yes" : "No"]);
if (line.type === "threshold" && values.length > 0) {
const withinThreshold = values.filter(
(val) => Math.abs(val - line.value) <= line.value * 0.05,
).length;
const percentageWithin = (
(withinThreshold / values.length) *
100
).toFixed(1);
statsData.push([
` Points Within Threshold (5%)`,
`${withinThreshold} (${percentageWithin}%)`,
]);
const differences = values.map((val) => Math.abs(val - line.value));
const minDifference = Math.min(...differences);
const maxDifference = Math.max(...differences);
statsData.push([
` Closest Approach (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(minDifference)
: minDifference.toFixed(3),
]);
statsData.push([
` Furthest Distance (${unitSymbol})`,
graphLine.renderValue
? graphLine.renderValue(maxDifference)
: maxDifference.toFixed(3),
]);
}
if (index < graphLine.targetLines.length - 1) {
statsData.push([""]);
}
});
} else {
statsData.push(["", ""], ["Target Lines", ""]);
statsData.push(["No target lines defined", ""]);
}
return statsData;
}
// Generate data sheet for a graph line
function createGraphLineDataSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
if (timestamps.length === 0) return [];
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
return timestamps.map((timestamp, index) => {
const value = values[index];
return {
Timestamp: new Date(timestamp),
[`Value (${unitSymbol})`]: graphLine.renderValue
? graphLine.renderValue(value)
: value?.toFixed(3) || "",
};
});
}
// Ensure sheet names are unique and valid for Excel
function generateUniqueSheetName(
name: string,
usedSheetNames: Set<string>,
): string {
let baseSheetName = name
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
.substring(0, 31); // Excel sheet name limit
if (!baseSheetName || baseSheetName.trim().length === 0) {
baseSheetName = "Sheet";
}
let sheetName = baseSheetName;
let counter = 1;
while (usedSheetNames.has(sheetName)) {
const suffix = `_${counter}`;
const maxBaseLength = 31 - suffix.length;
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
counter++;
}
usedSheetNames.add(sheetName);
return sheetName;
}
@@ -1,309 +0,0 @@
import * as XLSX from "xlsx";
import { LogEntry } from "@/stores/logsStore";
import { ExportConfig, IExportConfig } from "./excelExportConfig";
import {
IValueFormatter,
ValueFormatter,
TimestampConverter,
ArrayUtils,
} from "./excelFormatters";
import { MetadataProviderFactory } from "./excelMetadata";
import { ChartAxisCalculator, ExcelCellSanitizer } from "./excelUtils";
import { CommentManager } from "./excelCommentManager";
import { CombinedSheetData, PidData } from "./excelExportTypes";
/**
* Creates the combined analysis sheet with all series data
*/
export class AnalysisSheetBuilder {
private config: IExportConfig;
private formatter: IValueFormatter;
constructor(
private allSheetData: CombinedSheetData[],
private groupId: string,
private logs: LogEntry[],
private pidData?: PidData,
config?: IExportConfig,
formatter?: IValueFormatter,
) {
this.config = config || new ExportConfig();
this.formatter = formatter || new ValueFormatter();
}
async build(): Promise<XLSX.WorkSheet> {
// Get sorted timestamps
const sortedTimestamps = this.getSortedTimestamps();
const startTime = sortedTimestamps[0];
const endTime = sortedTimestamps[sortedTimestamps.length - 1];
// Filter relevant comments
const relevantComments = CommentManager.filterRelevant(
this.logs,
startTime,
endTime,
);
// Build data by timestamp map
const dataByTimestamp = this.buildDataByTimestampMap();
// Build columns
const columns = this.buildColumns();
// Create sheet data array
const sheetData: any[][] = [];
// Add title row
const timeRangeTitle = this.formatter.formatTimeRange(startTime, endTime);
sheetData.push(
ArrayUtils.createRow(
[`${this.groupId} - ${timeRangeTitle}`],
columns.length,
),
);
sheetData.push(ArrayUtils.createEmptyArray(columns.length));
// Add target values row if applicable
this.addTargetValuesRow(sheetData, columns.length);
// Add header row
sheetData.push(columns);
// Add data rows
const dataStartRow = sheetData.length;
let maxSeconds = 0;
sortedTimestamps.forEach((timestamp) => {
const row = this.buildDataRow(
timestamp,
startTime,
dataByTimestamp,
relevantComments,
);
const secondsFromStart = TimestampConverter.toSecondsFromStart(
timestamp,
startTime,
);
maxSeconds = Math.max(maxSeconds, secondsFromStart);
sheetData.push(row);
});
// Add metadata
await this.addMetadata(sheetData, columns.length, relevantComments);
// Add chart instructions
this.addChartInstructions(
sheetData,
columns.length,
dataStartRow,
maxSeconds,
timeRangeTitle,
);
const sanitizedSheetData = sheetData.map((row) =>
ExcelCellSanitizer.sanitizeRow(row),
);
// Convert to worksheet
const worksheet = XLSX.utils.aoa_to_sheet(sanitizedSheetData);
// Configure worksheet
this.configureWorksheet(worksheet, columns.length);
return worksheet;
}
private getSortedTimestamps(): number[] {
const allTimestamps = new Set<number>();
this.allSheetData.forEach((data) => {
data.timestamps.forEach((ts) => allTimestamps.add(ts));
});
return Array.from(allTimestamps).sort((a, b) => a - b);
}
private buildDataByTimestampMap(): Map<number, Map<string, number>> {
const dataByTimestamp = new Map<number, Map<string, number>>();
this.allSheetData.forEach((sheetData) => {
sheetData.timestamps.forEach((ts, idx) => {
if (!dataByTimestamp.has(ts)) {
dataByTimestamp.set(ts, new Map());
}
dataByTimestamp
.get(ts)!
.set(sheetData.sheetName, sheetData.values[idx]);
});
});
return dataByTimestamp;
}
private buildColumns(): string[] {
const columns: string[] = ["Timestamp"];
const availableColumns = this.allSheetData.map((d) => d.sheetName);
columns.push(...availableColumns);
columns.push("User Comments");
return columns;
}
private addTargetValuesRow(sheetData: any[][], columnCount: number): void {
const targetValues: any[] = ["Target Values"];
let hasTargets = false;
this.allSheetData.forEach((sheetDataEntry) => {
if (sheetDataEntry.targetLines.length > 0) {
const targetLine = sheetDataEntry.targetLines.find(
(line) => line.type === "target",
);
if (targetLine) {
targetValues.push(this.formatter.formatNumber(targetLine.value));
hasTargets = true;
} else {
targetValues.push("");
}
} else {
targetValues.push("");
}
});
targetValues.push(""); // Empty for comments column
if (hasTargets) {
sheetData.push(targetValues);
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
}
}
private buildDataRow(
timestamp: number,
startTime: number,
dataByTimestamp: Map<number, Map<string, number>>,
relevantComments: LogEntry[],
): any[] {
const row: any[] = [];
// Calculate seconds from start using TimestampConverter
const secondsFromStart = TimestampConverter.toSecondsFromStart(
timestamp,
startTime,
);
row.push(secondsFromStart);
// Add data for each column
this.allSheetData.forEach((sheetDataEntry) => {
const tsData = dataByTimestamp.get(timestamp);
if (tsData && tsData.has(sheetDataEntry.sheetName)) {
row.push(
this.sanitizeNumber(tsData.get(sheetDataEntry.sheetName) ?? NaN),
);
} else {
row.push("");
}
});
// Check for comments at this timestamp
const comment = CommentManager.findAtTimestamp(relevantComments, timestamp);
row.push(comment ? comment.message : "");
return row;
}
private sanitizeNumber(value: number): number | "" {
return Number.isFinite(value) ? value : "";
}
private async addMetadata(
sheetData: any[][],
columnCount: number,
relevantComments: LogEntry[],
): Promise<void> {
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
// Use MetadataProvider to build metadata
const metadataProvider = MetadataProviderFactory.createForExport({
softwareName: this.config.getSoftwareName(),
exportDate: this.formatter.formatDate(new Date()),
pidData: this.pidData,
commentCount: relevantComments.length,
});
sheetData.push(...metadataProvider.buildRows(columnCount));
}
private addChartInstructions(
sheetData: any[][],
columnCount: number,
dataStartRow: number,
maxSeconds: number,
timeRangeTitle: string,
): void {
// Calculate optimal Y-axis range from all data
const allValues = this.allSheetData.flatMap((sheet) => sheet.values);
const yAxisRange = ChartAxisCalculator.calculateOptimalRange(allValues);
const yAxisInstruction = ChartAxisCalculator.formatRangeInstruction(
yAxisRange.min,
yAxisRange.max,
);
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createEmptyArray(columnCount));
sheetData.push(ArrayUtils.createRow(["Chart Instructions"], columnCount));
sheetData.push(
ArrayUtils.createRow(
[`1. Select all data from row ${dataStartRow} to the last data row`],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
["2. Insert > Chart > Scatter Chart with Straight Lines and Markers"],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
["3. X-axis: Time (seconds), Y-axis: All measurement columns"],
columnCount,
),
);
sheetData.push(
ArrayUtils.createRow(
[`4. Set X-axis range: 0 to ${maxSeconds}`],
columnCount,
),
);
sheetData.push(ArrayUtils.createRow([yAxisInstruction], columnCount));
sheetData.push(
ArrayUtils.createRow(["6. Position legend at bottom"], columnCount),
);
sheetData.push(
ArrayUtils.createRow(
[`7. Chart Title: ${this.groupId} - ${timeRangeTitle}`],
columnCount,
),
);
}
private configureWorksheet(
worksheet: XLSX.WorkSheet,
columnCount: number,
): void {
// Merge title cells
if (!worksheet["!merges"]) worksheet["!merges"] = [];
worksheet["!merges"].push({
s: { r: 0, c: 0 },
e: { r: 0, c: columnCount - 1 },
});
// Set column widths
const colWidths = [
{ wch: 12 }, // Timestamp
...this.allSheetData.map(() => ({ wch: 12 })),
{ wch: 40 }, // Comments
];
worksheet["!cols"] = colWidths;
}
}
@@ -1,30 +0,0 @@
import { LogEntry } from "@/stores/logsStore";
/**
* Filters and manages log comments for export
*/
export class CommentManager {
static filterRelevant(
logs: LogEntry[],
startTime: number,
endTime: number,
): LogEntry[] {
return logs.filter(
(log) =>
log.timestamp.getTime() >= startTime &&
log.timestamp.getTime() <= endTime &&
log.level === "info" &&
log.message.toLowerCase().includes("comment"),
);
}
static findAtTimestamp(
comments: LogEntry[],
timestamp: number,