From 70188e9d90a8a71d35a110497691914c1497dd45 Mon Sep 17 00:00:00 2001 From: qitech Date: Mon, 8 Dec 2025 14:35:01 +0100 Subject: [PATCH 01/12] button to add new marker --- .../src/machines/mock/mock1/Mock1Graph.tsx | 122 ++++++++++++++++-- 1 file changed, 114 insertions(+), 8 deletions(-) diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index 5f0805d96..a9e473a2f 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -6,6 +6,7 @@ import { type GraphConfig, } from "@/components/graph"; import React from "react"; +import { useState } from "react"; import { useMock1 } from "./useMock"; import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries"; @@ -13,6 +14,96 @@ export function Mock1GraphPage() { const { sineWaveSum } = useMock1(); const syncHook = useGraphSync("mock-graphs"); + const graph1Ref = React.useRef(null); + const [marker, setMarker] = useState(null); + const [markers, setMarkers] = useState<{ timestamp: number; name: string }[]>([]); + + const handleAddMarker = () => { + const inputEl = document.getElementById("marker-input") as HTMLInputElement; + if (inputEl && sineWaveSum.current) { + const ts = sineWaveSum.current.timestamp; + const name = inputEl.value; + setMarkers((prev) => [...prev, { timestamp: ts, name }]); + const tsStr = new Date(ts).toLocaleTimeString("en-GB", { hour12: false }); + setMarker(`${name} @ ${tsStr}`); + } else { + setMarker("No data"); + } + }; + + function createMarkerElement( + timestamp: number, + value: number, + name: string, + startTime: number, + endTime: number, + graphWidth: number, + graphHeight: number, + ) { + const ratio = (timestamp - startTime) / (endTime - startTime); + const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth; + const yPos = graphHeight - value; + + + const line = document.createElement("div"); + line.style.position = "absolute"; + line.style.left = `${xPos}px`; + line.style.top = `${yPos}px`; + line.style.height = `${value}px`; + line.style.width = "2px"; + line.style.background = "black"; + line.className = "vertical-marker"; + + const label = document.createElement("div"); + label.textContent = name; + label.style.position = "absolute"; + label.style.left = `${xPos}px`; + label.style.top = `${yPos - 16}px`; + label.style.transform = "translateX(-50%)"; + label.style.color = "black"; + label.style.fontSize = "12px"; + label.style.padding = "0 2px"; + label.style.whiteSpace = "nowrap"; + label.className = "marker-label"; + + return { line, label }; + } + + React.useEffect(() => { + if (!graph1Ref.current || !sineWaveSum.current) return; + const graphEl = graph1Ref.current; + const graphWidth = graphEl.clientWidth; + const graphHeight = graphEl.clientHeight; + + // Remove previous markers and labels + graphEl.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); + + const endTime = sineWaveSum.current.timestamp; + const startTime = endTime - (singleGraphConfig.defaultTimeWindow as number); + const graphMin = -1; // TODO: do it in general case not hardcord + const graphMax = 1; // TODO: do it in general case not hardcord + + markers.forEach(({ timestamp, name }) => { + const closest = sineWaveSum.long.values + .filter((v): v is TimeSeriesValue => v !== null) + .reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev + ); + const valueY = ((closest.value - graphMin) / (graphMax - graphMin)) * graphHeight; + const { line, label } = createMarkerElement( + timestamp, + valueY, + name, + startTime, + endTime, + graphWidth, + graphHeight, + ); + + graphEl.appendChild(line); + graphEl.appendChild(label); + }); + }, [markers, sineWaveSum.current]); const config: GraphConfig = { title: "Sine Wave", @@ -98,14 +189,29 @@ export function Mock1GraphPage() { return (
- value.toFixed(3)} - graphId="single-graph1" - /> +
+ value.toFixed(3)} + graphId="single-graph1" + /> +
+
+ Add data marker + + +

{marker ?? "No data"}

+
Date: Mon, 8 Dec 2025 16:01:45 +0100 Subject: [PATCH 02/12] success to adjust with the time --- .../src/machines/mock/mock1/Mock1Graph.tsx | 97 ++++++++++++------- 1 file changed, 61 insertions(+), 36 deletions(-) diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index a9e473a2f..c2fc3ca31 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -69,42 +69,6 @@ export function Mock1GraphPage() { return { line, label }; } - React.useEffect(() => { - if (!graph1Ref.current || !sineWaveSum.current) return; - const graphEl = graph1Ref.current; - const graphWidth = graphEl.clientWidth; - const graphHeight = graphEl.clientHeight; - - // Remove previous markers and labels - graphEl.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); - - const endTime = sineWaveSum.current.timestamp; - const startTime = endTime - (singleGraphConfig.defaultTimeWindow as number); - const graphMin = -1; // TODO: do it in general case not hardcord - const graphMax = 1; // TODO: do it in general case not hardcord - - markers.forEach(({ timestamp, name }) => { - const closest = sineWaveSum.long.values - .filter((v): v is TimeSeriesValue => v !== null) - .reduce((prev, curr) => - Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev - ); - const valueY = ((closest.value - graphMin) / (graphMax - graphMin)) * graphHeight; - const { line, label } = createMarkerElement( - timestamp, - valueY, - name, - startTime, - endTime, - graphWidth, - graphHeight, - ); - - graphEl.appendChild(line); - graphEl.appendChild(label); - }); - }, [markers, sineWaveSum.current]); - const config: GraphConfig = { title: "Sine Wave", defaultTimeWindow: 30 * 60 * 1000, @@ -186,6 +150,67 @@ export function Mock1GraphPage() { title: "Sine Wave", }; + const [timeTick, setTimeTick] = useState(0); + + // Component be re-rendered by updating 'timeTick'. + React.useEffect(() => { + if (!sineWaveSum.current) return; + + const intervalId = setInterval(() => { + setTimeTick(prev => prev + 1); + }, 50); + + return () => clearInterval(intervalId); + }, [sineWaveSum.current]); + + // Draw the marker + React.useEffect(() => { + if (!graph1Ref.current || !sineWaveSum.current) return; + const graphEl = graph1Ref.current; + const graphWidth = graphEl.clientWidth; + const graphHeight = graphEl.clientHeight; + + // Remove previous markers and labels + graphEl.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); + + const currentTimeWindow = syncHook.controlProps.timeWindow; + const defaultDuration = singleGraphConfig.defaultTimeWindow as number; + const validTimeWindowMs = + (typeof currentTimeWindow === 'number' && currentTimeWindow) || + defaultDuration; + const endTime = sineWaveSum.current.timestamp; + const startTime = endTime - validTimeWindowMs; + + const graphMin = -1; + const graphMax = 1; + + markers.forEach(({ timestamp, name }) => { + if (timestamp >= startTime && timestamp <= endTime) { + const closest = sineWaveSum.long.values + .filter((v): v is TimeSeriesValue => v !== null) + .reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev + ); + if (!closest) return; + + const valueY = ((closest.value - graphMin) / (graphMax - graphMin)) * graphHeight; + + const { line, label } = createMarkerElement( + timestamp, + valueY, + name, + startTime, + endTime, + graphWidth, + graphHeight, + ); + + graphEl.appendChild(line); + graphEl.appendChild(label); + } + }); + }, [markers, sineWaveSum.current, timeTick, singleGraphConfig.defaultTimeWindow, syncHook.controlProps.timeWindow]); + return (
From 7469db2a596e780edd857d2894c8fc69ef3b1e79 Mon Sep 17 00:00:00 2001 From: qitech Date: Tue, 9 Dec 2025 11:51:45 +0100 Subject: [PATCH 03/12] timestamp for mock graphs --- .../graph/GraphWithMarkerControls.tsx | 211 ++++++++++++++++++ .../src/machines/mock/mock1/Mock1Graph.tsx | 175 +++------------ 2 files changed, 240 insertions(+), 146 deletions(-) create mode 100644 electron/src/components/graph/GraphWithMarkerControls.tsx diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx new file mode 100644 index 000000000..176301c53 --- /dev/null +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -0,0 +1,211 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { + AutoSyncedBigGraph, + useGraphSync, + type GraphConfig, +} from "@/components/graph"; +import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries"; +import { Unit } from "@/control/units"; + +type TimeSeriesData = { + newData: TimeSeries | null; + title?: string; + color?: string; + lines?: any[]; +}; + +type GraphWithMarkerControlsProps = { + syncHook: ReturnType; + newData: TimeSeriesData | TimeSeriesData[]; + config: GraphConfig; + unit?: Unit; + renderValue?: (value: number) => string; + graphId: string; + // The raw TimeSeries object for capturing timestamps. + currentTimeSeries: TimeSeries | null; + yAxisScale?: { // TODO: why is it neccessary? + min: number; + max: number + }; +}; + +function createMarkerElement( + timestamp: number, + value: number, + name: string, + startTime: number, + endTime: number, + graphWidth: number, + graphHeight: number, +) { + // Calculate the position of the timestamp + const ratio = (timestamp - startTime) / (endTime - startTime); + const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth; + const yPos = graphHeight - value; + + const line = document.createElement("div"); + line.style.position = "absolute"; + line.style.left = `${xPos}px`; + line.style.top = `${yPos}px`; + line.style.height = `${value}px`; + line.style.width = "2px"; + line.style.background = "rgba(0, 0, 0, 0.5)"; + line.className = "vertical-marker"; + + const label = document.createElement("div"); + label.textContent = name; + label.style.position = "absolute"; + label.style.left = `${xPos}px`; + label.style.top = `${yPos - 20}px`; + label.style.transform = "translateX(-50%)"; + label.style.color = "black"; + label.style.padding = "2px 4px"; + label.style.fontSize = "12px"; + label.style.whiteSpace = "nowrap"; + label.className = "marker-label"; + + return { line, label }; +} + +export function GraphWithMarkerControls({ + syncHook, + newData, + config, + unit, + renderValue, + graphId, + currentTimeSeries, +}: GraphWithMarkerControlsProps) { + const graphWrapperRef = useRef(null); + const [markerName, setMarkerName] = useState(""); + const [markers, setMarkers] = useState<{ timestamp: number; name: string }[]>([]); + const [statusMessage, setStatusMessage] = useState(null); + + // Time Tick for forcing marker redraw + const [timeTick, setTimeTick] = useState(0); + + // Set interval to force redraw the marker effect frequently (e.g., every 50ms) + useEffect(() => { + if (!currentTimeSeries?.current) return; + const intervalId = setInterval(() => { + setTimeTick(prev => prev + 1); + }, 50); + return () => clearInterval(intervalId); + }, [currentTimeSeries?.current]); + + const handleAddMarker = useCallback(() => { + if (currentTimeSeries?.current && markerName.trim()) { + const ts = currentTimeSeries.current.timestamp; + const name = markerName.trim(); + setMarkers((prev) => [...prev, { timestamp: ts, name }]); + const tsStr = new Date(ts).toLocaleTimeString("en-GB", { hour12: false }); + setStatusMessage(`Marker '${name}' added @ ${tsStr}`); + setMarkerName(""); // Clear input after adding + } else { + setStatusMessage("No data or marker name is empty."); + } + }, [currentTimeSeries, markerName]); + + + // Marker Drawing Effect + useEffect(() => { + if (!graphWrapperRef.current || !currentTimeSeries?.current) return; + + const graphEl = graphWrapperRef.current; + // The BigGraph component is the first child (the one with the actual chart) + // TODO: Find a better way to do this + const chartContainer = graphEl.querySelector(".h-\\[50vh\\] > div > div.flex-1 > div"); + if (!chartContainer) return; + + const graphWidth = chartContainer.clientWidth; + const graphHeight = chartContainer.clientHeight; + + const overlayContainer = chartContainer.parentElement; + if (!overlayContainer) return; + + // Remove previous markers and labels from the overlay container + overlayContainer.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); + + // Get the visible time window + const currentTimeWindow = syncHook.controlProps.timeWindow; + const defaultDuration = config.defaultTimeWindow as number; + const validTimeWindowMs = + (typeof currentTimeWindow === 'number' && currentTimeWindow) || + defaultDuration || // Fallback to config default + (30 * 60 * 1000); // Final fallback (30 minutes) + + const endTime = currentTimeSeries.current.timestamp; + const startTime = endTime - validTimeWindowMs; + + // Assuming the graph's fixed Y-scale is from -1 to 1 based on your sine wave example + const graphMin = -1; + const graphMax = 1; + // TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale + // from the uPlot instance or define a safe range if the data is unconstrained. + + markers.forEach(({ timestamp, name }) => { + if (timestamp >= startTime && timestamp <= endTime) { + // Find the data point closest to the marker timestamp to get the correct Y-value + const closest = currentTimeSeries.long.values + .filter((v): v is TimeSeriesValue => v !== null) + .reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev + ); + if (!closest) return; + + // Calculate the Y-position in pixels from the bottom of the chart area + const normalizedValue = (closest.value - graphMin) / (graphMax - graphMin); + const valueY = normalizedValue * graphHeight; // Height from bottom + + const { line, label } = createMarkerElement( + timestamp, + valueY, + name, + startTime, + endTime, + graphWidth, + graphHeight, + ); + + overlayContainer.appendChild(line); + overlayContainer.appendChild(label); + } + }); + }, [markers, currentTimeSeries, timeTick, config.defaultTimeWindow, syncHook.controlProps.timeWindow]); + + return ( +
+
+ {/* Render the core chart component */} + +
+ + {/* Marker Input and Button */} +
+ Add Marker: + setMarkerName(e.target.value)} + className="border px-2 py-1 rounded" + /> + +

{statusMessage ?? ""}

+
+
+ ); +} \ No newline at end of file diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index c2fc3ca31..bb154bfdf 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -6,68 +6,19 @@ import { type GraphConfig, } from "@/components/graph"; import React from "react"; -import { useState } from "react"; import { useMock1 } from "./useMock"; import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries"; +import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; +import { Unit } from "@/control/units"; + +// Define the assumed Y-axis scale for the mock data (-1 to 1) +// TODO: why is it neccessary here? +const MOCK_Y_AXIS_SCALE = { min: -1, max: 1 }; export function Mock1GraphPage() { const { sineWaveSum } = useMock1(); const syncHook = useGraphSync("mock-graphs"); - const graph1Ref = React.useRef(null); - const [marker, setMarker] = useState(null); - const [markers, setMarkers] = useState<{ timestamp: number; name: string }[]>([]); - - const handleAddMarker = () => { - const inputEl = document.getElementById("marker-input") as HTMLInputElement; - if (inputEl && sineWaveSum.current) { - const ts = sineWaveSum.current.timestamp; - const name = inputEl.value; - setMarkers((prev) => [...prev, { timestamp: ts, name }]); - const tsStr = new Date(ts).toLocaleTimeString("en-GB", { hour12: false }); - setMarker(`${name} @ ${tsStr}`); - } else { - setMarker("No data"); - } - }; - - function createMarkerElement( - timestamp: number, - value: number, - name: string, - startTime: number, - endTime: number, - graphWidth: number, - graphHeight: number, - ) { - const ratio = (timestamp - startTime) / (endTime - startTime); - const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth; - const yPos = graphHeight - value; - - - const line = document.createElement("div"); - line.style.position = "absolute"; - line.style.left = `${xPos}px`; - line.style.top = `${yPos}px`; - line.style.height = `${value}px`; - line.style.width = "2px"; - line.style.background = "black"; - line.className = "vertical-marker"; - - const label = document.createElement("div"); - label.textContent = name; - label.style.position = "absolute"; - label.style.left = `${xPos}px`; - label.style.top = `${yPos - 16}px`; - label.style.transform = "translateX(-50%)"; - label.style.color = "black"; - label.style.fontSize = "12px"; - label.style.padding = "0 2px"; - label.style.whiteSpace = "nowrap"; - label.className = "marker-label"; - - return { line, label }; - } const config: GraphConfig = { title: "Sine Wave", @@ -150,123 +101,55 @@ export function Mock1GraphPage() { title: "Sine Wave", }; - const [timeTick, setTimeTick] = useState(0); - - // Component be re-rendered by updating 'timeTick'. - React.useEffect(() => { - if (!sineWaveSum.current) return; - - const intervalId = setInterval(() => { - setTimeTick(prev => prev + 1); - }, 50); - - return () => clearInterval(intervalId); - }, [sineWaveSum.current]); - - // Draw the marker - React.useEffect(() => { - if (!graph1Ref.current || !sineWaveSum.current) return; - const graphEl = graph1Ref.current; - const graphWidth = graphEl.clientWidth; - const graphHeight = graphEl.clientHeight; - - // Remove previous markers and labels - graphEl.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); - - const currentTimeWindow = syncHook.controlProps.timeWindow; - const defaultDuration = singleGraphConfig.defaultTimeWindow as number; - const validTimeWindowMs = - (typeof currentTimeWindow === 'number' && currentTimeWindow) || - defaultDuration; - const endTime = sineWaveSum.current.timestamp; - const startTime = endTime - validTimeWindowMs; - - const graphMin = -1; - const graphMax = 1; - - markers.forEach(({ timestamp, name }) => { - if (timestamp >= startTime && timestamp <= endTime) { - const closest = sineWaveSum.long.values - .filter((v): v is TimeSeriesValue => v !== null) - .reduce((prev, curr) => - Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev - ); - if (!closest) return; - - const valueY = ((closest.value - graphMin) / (graphMax - graphMin)) * graphHeight; - - const { line, label } = createMarkerElement( - timestamp, - valueY, - name, - startTime, - endTime, - graphWidth, - graphHeight, - ); - - graphEl.appendChild(line); - graphEl.appendChild(label); - } - }); - }, [markers, sineWaveSum.current, timeTick, singleGraphConfig.defaultTimeWindow, syncHook.controlProps.timeWindow]); - return (
-
- value.toFixed(3)} - graphId="single-graph1" - /> -
-
- Add data marker - - -

{marker ?? "No data"}

-
- value.toFixed(3)} + graphId="single-graph1" + currentTimeSeries={sineWaveSum} + yAxisScale={MOCK_Y_AXIS_SCALE} + /> + value.toFixed(3)} graphId="combined-graph" + currentTimeSeries={sineWaveSum} + yAxisScale={MOCK_Y_AXIS_SCALE} /> - value.toFixed(3)} graphId="single-graph2" + currentTimeSeries={sineWaveSum} + yAxisScale={MOCK_Y_AXIS_SCALE} /> - value.toFixed(3)} graphId="single-graph" + currentTimeSeries={sineWaveSum} + yAxisScale={MOCK_Y_AXIS_SCALE} />
); -} +} \ No newline at end of file From a294945522f8ad5d272466dd80e5761edcbce942 Mon Sep 17 00:00:00 2001 From: qitech Date: Tue, 9 Dec 2025 12:08:18 +0100 Subject: [PATCH 04/12] time stamp for winder --- .../machines/winder/winder2/Winder2Graphs.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/electron/src/machines/winder/winder2/Winder2Graphs.tsx b/electron/src/machines/winder/winder2/Winder2Graphs.tsx index ab3adb066..3429d6768 100644 --- a/electron/src/machines/winder/winder2/Winder2Graphs.tsx +++ b/electron/src/machines/winder/winder2/Winder2Graphs.tsx @@ -11,6 +11,15 @@ import { useWinder2 } from "./useWinder"; import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal"; import { TimeSeries } from "@/lib/timeseries"; import { Unit } from "@/control/units"; +import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; + +// Define placeholder Y-Axis scales (MUST BE UPDATED WITH ACTUAL MACHINE LIMITS) +// TODO: why is it necessary here? +const WINDER_RPM_SCALE = { min: 0, max: 1000 }; +const WINDER_ANGLE_SCALE = { min: 0, max: 90 }; +const WINDER_POS_SCALE = { min: 0, max: 500 }; +const WINDER_SPEED_SCALE = { min: 0, max: 100 }; +const WINDER_PROGRESS_SCALE = { min: 0, max: 50000 }; export function Winder2GraphsPage() { const { @@ -96,7 +105,7 @@ export function SpoolRpmGraph({ }; return ( - ); } @@ -155,7 +166,7 @@ export function TraversePositionGraph({ }; return ( - ); } @@ -191,7 +204,7 @@ export function TensionArmAngleGraph({ }; return ( - ); } @@ -225,8 +240,9 @@ export function SpoolProgressGraph({ exportFilename: "spool_progress", }; + // NOTE: Assuming this graph starts at 0, and the max is the total capacity. return ( - ); } @@ -274,7 +292,7 @@ export function PullerSpeedGraph({ }; return ( - ); } From 9ce105d3e604dc2c3b911bd1868f47af40da70d1 Mon Sep 17 00:00:00 2001 From: qitech Date: Tue, 9 Dec 2025 14:10:38 +0100 Subject: [PATCH 05/12] time stamp for extruder --- .../extruder/extruder2/Extruder2Graph.tsx | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx b/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx index b8bc86e26..c57329073 100644 --- a/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx +++ b/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx @@ -7,6 +7,7 @@ import { } from "@/components/graph"; import React from "react"; import { useExtruder2 } from "./useExtruder"; +import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; export function Extruder2GraphsPage() { const { @@ -28,6 +29,13 @@ export function Extruder2GraphsPage() { const syncHook = useGraphSync("extruder-graphs"); + // ESTIMATED SCALES (REPLACE WITH REAL LIMITS) + const SCALE_TEMP = { min: 0, max: 450 }; // Typical max temp for extruders (in °C) + const SCALE_POWER = { min: 0, max: 10000 }; // Estimated max power (in W) + const SCALE_CURRENT = { min: 0, max: 50 }; // Estimated max motor current (in A) + const SCALE_PRESSURE = { min: 0, max: 250 }; // Estimated max pressure (in bar) + const SCALE_RPM = { min: 0, max: 100 }; // Estimated max screw RPM (in rpm) + // Base config const baseConfig: GraphConfig = { defaultTimeWindow: 30 * 60 * 1000, // 30 minutes @@ -242,7 +250,7 @@ export function Extruder2GraphsPage() { return (
- value.toFixed(2)} graphId="pressure-graph" + currentTimeSeries={pressure} + yAxisScale={SCALE_PRESSURE} /> - value.toFixed(1)} graphId="combined-temperatures" + currentTimeSeries={nozzleTemperature} + yAxisScale={SCALE_TEMP} /> - value.toFixed(1)} graphId="combined-power" + currentTimeSeries={combinedPower} + yAxisScale={SCALE_POWER} /> - value.toFixed(2)} graphId="motor-current" + currentTimeSeries={motorCurrent} + yAxisScale={SCALE_CURRENT} /> - value.toFixed(0)} graphId="rpm-graph" + currentTimeSeries={motorScrewRpm} + yAxisScale={SCALE_RPM} />
From 8e4cc676252a4fcb5c16c32a6d02c646e1f6db02 Mon Sep 17 00:00:00 2001 From: qitech Date: Tue, 9 Dec 2025 17:04:25 +0100 Subject: [PATCH 06/12] first approach for time stamp in excel --- .../graph/GraphWithMarkerControls.tsx | 43 ++++-- electron/src/components/graph/excelExport.ts | 133 ++++++++++++++++++ electron/src/components/graph/types.ts | 3 +- 3 files changed, 167 insertions(+), 12 deletions(-) diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx index 176301c53..a4631b30c 100644 --- a/electron/src/components/graph/GraphWithMarkerControls.tsx +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -78,9 +78,23 @@ export function GraphWithMarkerControls({ }: GraphWithMarkerControlsProps) { const graphWrapperRef = useRef(null); const [markerName, setMarkerName] = useState(""); - const [markers, setMarkers] = useState<{ timestamp: number; name: string }[]>([]); + const [markers, setMarkers] = useState<{ timestamp: number; name: string; value: number }[]>([]) const [statusMessage, setStatusMessage] = useState(null); + // Convert local markers state into GraphLine format + // TODO: do I really need this? + const dynamicMarkerLines = markers.map((marker, index) => ({ + type: "user_marker" as const, // Use a unique type identifier + value: marker.value, // We need to store the value as well! + label: marker.name, + color: "#ff0000", // e.g., Red for user-added markers + width: 2, + show: true, + + // *** FIX: Store the actual time here *** + markerTimestamp: marker.timestamp, + })); + // Time Tick for forcing marker redraw const [timeTick, setTimeTick] = useState(0); @@ -95,15 +109,15 @@ export function GraphWithMarkerControls({ const handleAddMarker = useCallback(() => { if (currentTimeSeries?.current && markerName.trim()) { - const ts = currentTimeSeries.current.timestamp; - const name = markerName.trim(); - setMarkers((prev) => [...prev, { timestamp: ts, name }]); - const tsStr = new Date(ts).toLocaleTimeString("en-GB", { hour12: false }); - setStatusMessage(`Marker '${name}' added @ ${tsStr}`); - setMarkerName(""); // Clear input after adding - } else { - setStatusMessage("No data or marker name is empty."); - } + const ts = currentTimeSeries.current.timestamp; + const val = currentTimeSeries.current.value; // <--- Value extracted + const name = markerName.trim(); + + // Ensure 'value' is stored here + setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); // <--- Value stored + + // ... (rest of the function) + } // ... }, [currentTimeSeries, markerName]); @@ -173,6 +187,13 @@ export function GraphWithMarkerControls({ }); }, [markers, currentTimeSeries, timeTick, config.defaultTimeWindow, syncHook.controlProps.timeWindow]); + // Combine the base config lines with the dynamic lines + // TODO: do I really need this? if not change back to config + const finalConfig = { + ...config, + lines: [...(config.lines || []), ...dynamicMarkerLines], + }; + return (
@@ -180,7 +201,7 @@ export function GraphWithMarkerControls({ 0) { + const markerReportData = createGraphLineMarkerReportSheet(graphLineData); + const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData); + // Set column widths here (e.g., Column A = 15, Column B = 25) + markerReportWorksheet["!cols"] = [ + { wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.) + { wch: 30 }, // Column B (Values, where the Date object resides) + ]; + const markerReportSheetName = generateUniqueSheetName( + `${seriesTitle} Marker Report`, + usedSheetNames, + ); + XLSX.utils.book_append_sheet( + workbook, + markerReportWorksheet, + markerReportSheetName, + ); + } + // TODO: clean and refactor + processedCount++; }); @@ -290,6 +311,118 @@ function createGraphLineDataSheet(graphLine: { }); } +/** + * TODO: clean and refactor + */ +function createGraphLineMarkerReportSheet(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) || ""; + // Initialize Report Data and Header + const reportData: any[][] = [ + [`Marker Report: ${graphLine.lineTitle}`], + ["Graph", graphLine.graphTitle], + ["Line Name", graphLine.lineTitle], + ["", ""], + ["--- Data Point Marker Status ---", ""], + ["", ""], + ]; + + if (timestamps.length === 0) { + reportData.push(["No data points to report"]); + return reportData; + } + + // 2. Filter User Markers + const allTargetLines = graphLine.targetLines.filter(line => line.show !== false); + const userMarkers = allTargetLines.filter(line => line.type === 'user_marker' && line.label); + + // 3. Map Markers to Closest Data Point Index + const markerIndexMap = new Map(); + + userMarkers.forEach(line => { + const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp + let closestDataPointIndex = -1; + let minTimeDifference = Infinity; + + // Find the data point with the closest timestamp + timestamps.forEach((ts, index) => { + const difference = Math.abs(ts - markerTime); + if (difference < minTimeDifference) { + minTimeDifference = difference; + closestDataPointIndex = index; + } + }); + + // Store the marker data at the index of the closest data point + if (closestDataPointIndex !== -1) { + markerIndexMap.set(closestDataPointIndex, { + label: line.label || 'User Marker', + originalTimestamp: markerTime, + }); + } + }); + + // Add the final header before the detailed report starts + reportData.push( + ["--- BEGIN DETAILED REPORT ---", ""], + ["", ""], + ); + + // Handle case where no user markers were created + if (userMarkers.length === 0) { + reportData.push(["No user-created markers found.", ""]); + } + + // 4. Detailed Report Generation Loop + timestamps.forEach((dataPointTimestamp, index) => { + const value = values[index]; + const markerData = markerIndexMap.get(index); + + let finalMarkerLabel = "N/A"; + let timeToDisplay = dataPointTimestamp; // Default to data sample time + + if (markerData) { + finalMarkerLabel = `Marker: ${markerData.label}`; + // CRITICAL FIX: Use the marker's high-precision time for display + timeToDisplay = markerData.originalTimestamp; + } + + // Format the time (using timeToDisplay) + const formattedTime = new Date(timeToDisplay).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(/ /g, ''); + + // Row 1: Timestamp + reportData.push(["Timestamp", formattedTime]); + + // Row 2: Value + const formattedValue = graphLine.renderValue + ? graphLine.renderValue(value) + : value?.toFixed(3) || ""; + reportData.push([`Value (${unitSymbol})`, formattedValue]); + + // Row 3: Marker Event + reportData.push(["Marker Event", finalMarkerLabel]); + + // Separator + reportData.push(["", ""]); + }); + + return reportData; +} + // Ensure sheet names are unique and valid for Excel function generateUniqueSheetName( name: string, diff --git a/electron/src/components/graph/types.ts b/electron/src/components/graph/types.ts index bc8001aec..ccbae607e 100644 --- a/electron/src/components/graph/types.ts +++ b/electron/src/components/graph/types.ts @@ -25,13 +25,14 @@ export type PropGraphSync = { // Configuration types for additional lines export type GraphLine = { - type: "threshold" | "target"; + type: "threshold" | "target" | "user_marker"; // TODO: redundant or not? value: number; color: string; label?: string; width?: number; dash?: number[]; show?: boolean; + markerTimestamp?: number; }; export type GraphConfig = { From 6a2cda5bb97635b76204124550b3a3d10a175b3e Mon Sep 17 00:00:00 2001 From: qitech Date: Thu, 11 Dec 2025 11:11:32 +0100 Subject: [PATCH 07/12] refactor code --- electron/src/components/graph/excelExport.ts | 22 +++++++------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 150f4b85b..b51ddaa77 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -84,7 +84,7 @@ export function exportGraphsToExcel( XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName); } - // TODO: clean and refactor + // Excel worksheet for timestamps and timestamp markers if (graphLineData.targetLines.length > 0) { const markerReportData = createGraphLineMarkerReportSheet(graphLineData); const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData); @@ -103,7 +103,6 @@ export function exportGraphsToExcel( markerReportSheetName, ); } - // TODO: clean and refactor processedCount++; }); @@ -311,9 +310,6 @@ function createGraphLineDataSheet(graphLine: { }); } -/** - * TODO: clean and refactor - */ function createGraphLineMarkerReportSheet(graphLine: { graphTitle: string; lineTitle: string; @@ -341,11 +337,11 @@ function createGraphLineMarkerReportSheet(graphLine: { return reportData; } - // 2. Filter User Markers + // Filter User Markers const allTargetLines = graphLine.targetLines.filter(line => line.show !== false); const userMarkers = allTargetLines.filter(line => line.type === 'user_marker' && line.label); - // 3. Map Markers to Closest Data Point Index + // Map Markers to Closest Data Point Index const markerIndexMap = new Map(); userMarkers.forEach(line => { @@ -371,7 +367,7 @@ function createGraphLineMarkerReportSheet(graphLine: { } }); - // Add the final header before the detailed report starts + // Add the final header before the timestamp report starts reportData.push( ["--- BEGIN DETAILED REPORT ---", ""], ["", ""], @@ -382,17 +378,15 @@ function createGraphLineMarkerReportSheet(graphLine: { reportData.push(["No user-created markers found.", ""]); } - // 4. Detailed Report Generation Loop timestamps.forEach((dataPointTimestamp, index) => { const value = values[index]; const markerData = markerIndexMap.get(index); - let finalMarkerLabel = "N/A"; + let finalMarkerLabel = ""; let timeToDisplay = dataPointTimestamp; // Default to data sample time if (markerData) { - finalMarkerLabel = `Marker: ${markerData.label}`; - // CRITICAL FIX: Use the marker's high-precision time for display + finalMarkerLabel = `${markerData.label}`; timeToDisplay = markerData.originalTimestamp; } @@ -413,8 +407,8 @@ function createGraphLineMarkerReportSheet(graphLine: { : value?.toFixed(3) || ""; reportData.push([`Value (${unitSymbol})`, formattedValue]); - // Row 3: Marker Event - reportData.push(["Marker Event", finalMarkerLabel]); + // Row 3: Marker Name + reportData.push(["Marker", finalMarkerLabel]); // Separator reportData.push(["", ""]); From 61a247088a67c1c4f5869a3a6adecd9760b38eed Mon Sep 17 00:00:00 2001 From: qitech Date: Thu, 11 Dec 2025 12:02:16 +0100 Subject: [PATCH 08/12] clean up redundant code and comments --- .../graph/GraphWithMarkerControls.tsx | 30 +++++-------------- .../extruder/extruder2/Extruder2Graph.tsx | 12 -------- .../src/machines/mock/mock1/Mock1Graph.tsx | 8 ----- .../machines/winder/winder2/Winder2Graphs.tsx | 13 -------- 4 files changed, 8 insertions(+), 55 deletions(-) diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx index a4631b30c..3da483f38 100644 --- a/electron/src/components/graph/GraphWithMarkerControls.tsx +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -21,12 +21,7 @@ type GraphWithMarkerControlsProps = { unit?: Unit; renderValue?: (value: number) => string; graphId: string; - // The raw TimeSeries object for capturing timestamps. currentTimeSeries: TimeSeries | null; - yAxisScale?: { // TODO: why is it neccessary? - min: number; - max: number - }; }; function createMarkerElement( @@ -81,17 +76,13 @@ export function GraphWithMarkerControls({ const [markers, setMarkers] = useState<{ timestamp: number; name: string; value: number }[]>([]) const [statusMessage, setStatusMessage] = useState(null); - // Convert local markers state into GraphLine format - // TODO: do I really need this? const dynamicMarkerLines = markers.map((marker, index) => ({ - type: "user_marker" as const, // Use a unique type identifier - value: marker.value, // We need to store the value as well! + type: "user_marker" as const, + value: marker.value, label: marker.name, - color: "#ff0000", // e.g., Red for user-added markers + color: "#ff0000", width: 2, show: true, - - // *** FIX: Store the actual time here *** markerTimestamp: marker.timestamp, })); @@ -110,14 +101,11 @@ export function GraphWithMarkerControls({ const handleAddMarker = useCallback(() => { if (currentTimeSeries?.current && markerName.trim()) { const ts = currentTimeSeries.current.timestamp; - const val = currentTimeSeries.current.value; // <--- Value extracted + const val = currentTimeSeries.current.value; const name = markerName.trim(); - // Ensure 'value' is stored here - setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); // <--- Value stored - - // ... (rest of the function) - } // ... + setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); + } }, [currentTimeSeries, markerName]); @@ -151,7 +139,7 @@ export function GraphWithMarkerControls({ const endTime = currentTimeSeries.current.timestamp; const startTime = endTime - validTimeWindowMs; - // Assuming the graph's fixed Y-scale is from -1 to 1 based on your sine wave example + // Assuming the graph's fixed Y-scale is from -1 to 1 based on the sine wave example const graphMin = -1; const graphMax = 1; // TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale @@ -169,7 +157,7 @@ export function GraphWithMarkerControls({ // Calculate the Y-position in pixels from the bottom of the chart area const normalizedValue = (closest.value - graphMin) / (graphMax - graphMin); - const valueY = normalizedValue * graphHeight; // Height from bottom + const valueY = normalizedValue * graphHeight; const { line, label } = createMarkerElement( timestamp, @@ -187,8 +175,6 @@ export function GraphWithMarkerControls({ }); }, [markers, currentTimeSeries, timeTick, config.defaultTimeWindow, syncHook.controlProps.timeWindow]); - // Combine the base config lines with the dynamic lines - // TODO: do I really need this? if not change back to config const finalConfig = { ...config, lines: [...(config.lines || []), ...dynamicMarkerLines], diff --git a/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx b/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx index c57329073..69d118afa 100644 --- a/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx +++ b/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx @@ -29,13 +29,6 @@ export function Extruder2GraphsPage() { const syncHook = useGraphSync("extruder-graphs"); - // ESTIMATED SCALES (REPLACE WITH REAL LIMITS) - const SCALE_TEMP = { min: 0, max: 450 }; // Typical max temp for extruders (in °C) - const SCALE_POWER = { min: 0, max: 10000 }; // Estimated max power (in W) - const SCALE_CURRENT = { min: 0, max: 50 }; // Estimated max motor current (in A) - const SCALE_PRESSURE = { min: 0, max: 250 }; // Estimated max pressure (in bar) - const SCALE_RPM = { min: 0, max: 100 }; // Estimated max screw RPM (in rpm) - // Base config const baseConfig: GraphConfig = { defaultTimeWindow: 30 * 60 * 1000, // 30 minutes @@ -272,7 +265,6 @@ export function Extruder2GraphsPage() { renderValue={(value) => value.toFixed(2)} graphId="pressure-graph" currentTimeSeries={pressure} - yAxisScale={SCALE_PRESSURE} /> value.toFixed(1)} graphId="combined-temperatures" currentTimeSeries={nozzleTemperature} - yAxisScale={SCALE_TEMP} /> value.toFixed(1)} graphId="combined-power" currentTimeSeries={combinedPower} - yAxisScale={SCALE_POWER} /> value.toFixed(2)} graphId="motor-current" currentTimeSeries={motorCurrent} - yAxisScale={SCALE_CURRENT} /> value.toFixed(0)} graphId="rpm-graph" currentTimeSeries={motorScrewRpm} - yAxisScale={SCALE_RPM} />
diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index bb154bfdf..ca6a59823 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -11,10 +11,6 @@ import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries"; import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; import { Unit } from "@/control/units"; -// Define the assumed Y-axis scale for the mock data (-1 to 1) -// TODO: why is it neccessary here? -const MOCK_Y_AXIS_SCALE = { min: -1, max: 1 }; - export function Mock1GraphPage() { const { sineWaveSum } = useMock1(); @@ -112,7 +108,6 @@ export function Mock1GraphPage() { renderValue={(value) => value.toFixed(3)} graphId="single-graph1" currentTimeSeries={sineWaveSum} - yAxisScale={MOCK_Y_AXIS_SCALE} /> value.toFixed(3)} graphId="combined-graph" currentTimeSeries={sineWaveSum} - yAxisScale={MOCK_Y_AXIS_SCALE} /> value.toFixed(3)} graphId="single-graph2" currentTimeSeries={sineWaveSum} - yAxisScale={MOCK_Y_AXIS_SCALE} /> value.toFixed(3)} graphId="single-graph" currentTimeSeries={sineWaveSum} - yAxisScale={MOCK_Y_AXIS_SCALE} />
diff --git a/electron/src/machines/winder/winder2/Winder2Graphs.tsx b/electron/src/machines/winder/winder2/Winder2Graphs.tsx index 3429d6768..450ceea40 100644 --- a/electron/src/machines/winder/winder2/Winder2Graphs.tsx +++ b/electron/src/machines/winder/winder2/Winder2Graphs.tsx @@ -13,14 +13,6 @@ import { TimeSeries } from "@/lib/timeseries"; import { Unit } from "@/control/units"; import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; -// Define placeholder Y-Axis scales (MUST BE UPDATED WITH ACTUAL MACHINE LIMITS) -// TODO: why is it necessary here? -const WINDER_RPM_SCALE = { min: 0, max: 1000 }; -const WINDER_ANGLE_SCALE = { min: 0, max: 90 }; -const WINDER_POS_SCALE = { min: 0, max: 500 }; -const WINDER_SPEED_SCALE = { min: 0, max: 100 }; -const WINDER_PROGRESS_SCALE = { min: 0, max: 50000 }; - export function Winder2GraphsPage() { const { state, @@ -116,7 +108,6 @@ export function SpoolRpmGraph({ config={config} graphId="spool-rpm" currentTimeSeries={newData} - yAxisScale={WINDER_RPM_SCALE} // TODO: is it necessary? /> ); } @@ -178,7 +169,6 @@ export function TraversePositionGraph({ config={config} graphId="traverse-position" currentTimeSeries={newData} - yAxisScale={WINDER_POS_SCALE} // TODO: is it necessary? /> ); } @@ -215,7 +205,6 @@ export function TensionArmAngleGraph({ config={config} graphId="tension-arm-angle" currentTimeSeries={newData} - yAxisScale={WINDER_ANGLE_SCALE} // TODO: is it necessary? /> ); } @@ -253,7 +242,6 @@ export function SpoolProgressGraph({ config={config} graphId="spool-progress" currentTimeSeries={newData} - yAxisScale={WINDER_PROGRESS_SCALE} // TODO: is it necessary? /> ); } @@ -304,7 +292,6 @@ export function PullerSpeedGraph({ config={config} graphId="puller-speed" currentTimeSeries={newData} - yAxisScale={WINDER_SPEED_SCALE} // TODO: is it necessary? /> ); } From 8a59d69dbe6380599ba36c69c95db300d067c31e Mon Sep 17 00:00:00 2001 From: qitech Date: Thu, 11 Dec 2025 12:09:07 +0100 Subject: [PATCH 09/12] Fix: Applied Prettier formatting fixes --- .../graph/GraphWithMarkerControls.tsx | 133 ++++++++++-------- electron/src/components/graph/excelExport.ts | 83 ++++++----- .../src/machines/mock/mock1/Mock1Graph.tsx | 2 +- 3 files changed, 120 insertions(+), 98 deletions(-) diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx index 3da483f38..498d9b4fe 100644 --- a/electron/src/components/graph/GraphWithMarkerControls.tsx +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -21,7 +21,7 @@ type GraphWithMarkerControlsProps = { unit?: Unit; renderValue?: (value: number) => string; graphId: string; - currentTimeSeries: TimeSeries | null; + currentTimeSeries: TimeSeries | null; }; function createMarkerElement( @@ -36,7 +36,7 @@ function createMarkerElement( // Calculate the position of the timestamp const ratio = (timestamp - startTime) / (endTime - startTime); const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth; - const yPos = graphHeight - value; + const yPos = graphHeight - value; const line = document.createElement("div"); line.style.position = "absolute"; @@ -73,7 +73,9 @@ export function GraphWithMarkerControls({ }: GraphWithMarkerControlsProps) { const graphWrapperRef = useRef(null); const [markerName, setMarkerName] = useState(""); - const [markers, setMarkers] = useState<{ timestamp: number; name: string; value: number }[]>([]) + const [markers, setMarkers] = useState< + { timestamp: number; name: string; value: number }[] + >([]); const [statusMessage, setStatusMessage] = useState(null); const dynamicMarkerLines = markers.map((marker, index) => ({ @@ -93,22 +95,21 @@ export function GraphWithMarkerControls({ useEffect(() => { if (!currentTimeSeries?.current) return; const intervalId = setInterval(() => { - setTimeTick(prev => prev + 1); - }, 50); + setTimeTick((prev) => prev + 1); + }, 50); return () => clearInterval(intervalId); - }, [currentTimeSeries?.current]); + }, [currentTimeSeries?.current]); const handleAddMarker = useCallback(() => { if (currentTimeSeries?.current && markerName.trim()) { - const ts = currentTimeSeries.current.timestamp; - const val = currentTimeSeries.current.value; - const name = markerName.trim(); - - setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); + const ts = currentTimeSeries.current.timestamp; + const val = currentTimeSeries.current.value; + const name = markerName.trim(); + + setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); } }, [currentTimeSeries, markerName]); - // Marker Drawing Effect useEffect(() => { if (!graphWrapperRef.current || !currentTimeSeries?.current) return; @@ -116,64 +117,78 @@ export function GraphWithMarkerControls({ const graphEl = graphWrapperRef.current; // The BigGraph component is the first child (the one with the actual chart) // TODO: Find a better way to do this - const chartContainer = graphEl.querySelector(".h-\\[50vh\\] > div > div.flex-1 > div"); - if (!chartContainer) return; + const chartContainer = graphEl.querySelector( + ".h-\\[50vh\\] > div > div.flex-1 > div", + ); + if (!chartContainer) return; const graphWidth = chartContainer.clientWidth; const graphHeight = chartContainer.clientHeight; - + const overlayContainer = chartContainer.parentElement; if (!overlayContainer) return; // Remove previous markers and labels from the overlay container - overlayContainer.querySelectorAll(".vertical-marker, .marker-label").forEach((el) => el.remove()); + overlayContainer + .querySelectorAll(".vertical-marker, .marker-label") + .forEach((el) => el.remove()); // Get the visible time window - const currentTimeWindow = syncHook.controlProps.timeWindow; + const currentTimeWindow = syncHook.controlProps.timeWindow; const defaultDuration = config.defaultTimeWindow as number; - const validTimeWindowMs = - (typeof currentTimeWindow === 'number' && currentTimeWindow) || - defaultDuration || // Fallback to config default - (30 * 60 * 1000); // Final fallback (30 minutes) - - const endTime = currentTimeSeries.current.timestamp; - const startTime = endTime - validTimeWindowMs; + const validTimeWindowMs = + (typeof currentTimeWindow === "number" && currentTimeWindow) || + defaultDuration || // Fallback to config default + 30 * 60 * 1000; // Final fallback (30 minutes) + + const endTime = currentTimeSeries.current.timestamp; + const startTime = endTime - validTimeWindowMs; // Assuming the graph's fixed Y-scale is from -1 to 1 based on the sine wave example - const graphMin = -1; - const graphMax = 1; - // TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale + const graphMin = -1; + const graphMax = 1; + // TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale // from the uPlot instance or define a safe range if the data is unconstrained. markers.forEach(({ timestamp, name }) => { if (timestamp >= startTime && timestamp <= endTime) { - // Find the data point closest to the marker timestamp to get the correct Y-value - const closest = currentTimeSeries.long.values - .filter((v): v is TimeSeriesValue => v !== null) - .reduce((prev, curr) => - Math.abs(curr.timestamp - timestamp) < Math.abs(prev.timestamp - timestamp) ? curr : prev - ); - if (!closest) return; - - // Calculate the Y-position in pixels from the bottom of the chart area - const normalizedValue = (closest.value - graphMin) / (graphMax - graphMin); - const valueY = normalizedValue * graphHeight; - - const { line, label } = createMarkerElement( - timestamp, - valueY, - name, - startTime, - endTime, - graphWidth, - graphHeight, + // Find the data point closest to the marker timestamp to get the correct Y-value + const closest = currentTimeSeries.long.values + .filter((v): v is TimeSeriesValue => v !== null) + .reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < + Math.abs(prev.timestamp - timestamp) + ? curr + : prev, ); - - overlayContainer.appendChild(line); - overlayContainer.appendChild(label); + if (!closest) return; + + // Calculate the Y-position in pixels from the bottom of the chart area + const normalizedValue = + (closest.value - graphMin) / (graphMax - graphMin); + const valueY = normalizedValue * graphHeight; + + const { line, label } = createMarkerElement( + timestamp, + valueY, + name, + startTime, + endTime, + graphWidth, + graphHeight, + ); + + overlayContainer.appendChild(line); + overlayContainer.appendChild(label); } }); - }, [markers, currentTimeSeries, timeTick, config.defaultTimeWindow, syncHook.controlProps.timeWindow]); + }, [ + markers, + currentTimeSeries, + timeTick, + config.defaultTimeWindow, + syncHook.controlProps.timeWindow, + ]); const finalConfig = { ...config, @@ -193,26 +208,26 @@ export function GraphWithMarkerControls({ graphId={graphId} />
- + {/* Marker Input and Button */} -
+
Add Marker: setMarkerName(e.target.value)} - className="border px-2 py-1 rounded" + className="rounded border px-2 py-1" /> - -

{statusMessage ?? ""}

+

{statusMessage ?? ""}

); -} \ No newline at end of file +} diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index b51ddaa77..8b86fcab9 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -86,12 +86,13 @@ export function exportGraphsToExcel( // Excel worksheet for timestamps and timestamp markers if (graphLineData.targetLines.length > 0) { - const markerReportData = createGraphLineMarkerReportSheet(graphLineData); + const markerReportData = + createGraphLineMarkerReportSheet(graphLineData); const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData); // Set column widths here (e.g., Column A = 15, Column B = 25) markerReportWorksheet["!cols"] = [ - { wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.) - { wch: 30 }, // Column B (Values, where the Date object resides) + { wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.) + { wch: 30 }, // Column B (Values, where the Date object resides) ]; const markerReportSheetName = generateUniqueSheetName( `${seriesTitle} Marker Report`, @@ -338,46 +339,50 @@ function createGraphLineMarkerReportSheet(graphLine: { } // Filter User Markers - const allTargetLines = graphLine.targetLines.filter(line => line.show !== false); - const userMarkers = allTargetLines.filter(line => line.type === 'user_marker' && line.label); + const allTargetLines = graphLine.targetLines.filter( + (line) => line.show !== false, + ); + const userMarkers = allTargetLines.filter( + (line) => line.type === "user_marker" && line.label, + ); // Map Markers to Closest Data Point Index - const markerIndexMap = new Map(); + const markerIndexMap = new Map< + number, + { label: string; originalTimestamp: number } + >(); - userMarkers.forEach(line => { + userMarkers.forEach((line) => { const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp let closestDataPointIndex = -1; let minTimeDifference = Infinity; // Find the data point with the closest timestamp timestamps.forEach((ts, index) => { - const difference = Math.abs(ts - markerTime); - if (difference < minTimeDifference) { - minTimeDifference = difference; - closestDataPointIndex = index; - } + const difference = Math.abs(ts - markerTime); + if (difference < minTimeDifference) { + minTimeDifference = difference; + closestDataPointIndex = index; + } }); - + // Store the marker data at the index of the closest data point - if (closestDataPointIndex !== -1) { - markerIndexMap.set(closestDataPointIndex, { - label: line.label || 'User Marker', - originalTimestamp: markerTime, - }); + if (closestDataPointIndex !== -1) { + markerIndexMap.set(closestDataPointIndex, { + label: line.label || "User Marker", + originalTimestamp: markerTime, + }); } }); // Add the final header before the timestamp report starts - reportData.push( - ["--- BEGIN DETAILED REPORT ---", ""], - ["", ""], - ); + reportData.push(["--- BEGIN DETAILED REPORT ---", ""], ["", ""]); // Handle case where no user markers were created if (userMarkers.length === 0) { - reportData.push(["No user-created markers found.", ""]); + reportData.push(["No user-created markers found.", ""]); } - + timestamps.forEach((dataPointTimestamp, index) => { const value = values[index]; const markerData = markerIndexMap.get(index); @@ -386,34 +391,36 @@ function createGraphLineMarkerReportSheet(graphLine: { let timeToDisplay = dataPointTimestamp; // Default to data sample time if (markerData) { - finalMarkerLabel = `${markerData.label}`; - timeToDisplay = markerData.originalTimestamp; + finalMarkerLabel = `${markerData.label}`; + timeToDisplay = markerData.originalTimestamp; } // Format the time (using timeToDisplay) - const formattedTime = new Date(timeToDisplay).toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false - }).replace(/ /g, ''); + const formattedTime = new Date(timeToDisplay) + .toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + .replace(/ /g, ""); // Row 1: Timestamp reportData.push(["Timestamp", formattedTime]); - + // Row 2: Value const formattedValue = graphLine.renderValue - ? graphLine.renderValue(value) - : value?.toFixed(3) || ""; + ? graphLine.renderValue(value) + : value?.toFixed(3) || ""; reportData.push([`Value (${unitSymbol})`, formattedValue]); - + // Row 3: Marker Name reportData.push(["Marker", finalMarkerLabel]); - + // Separator reportData.push(["", ""]); }); - + return reportData; } diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index ca6a59823..066d623b1 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -144,4 +144,4 @@ export function Mock1GraphPage() {
); -} \ No newline at end of file +} From 3341f16097f9bcfda6027df2980c5ba2b660b7b8 Mon Sep 17 00:00:00 2001 From: EinsPommes Date: Sat, 17 Jan 2026 12:19:08 +0100 Subject: [PATCH 10/12] Fix chart y-axis scaling for visible data; improve container selection --- .../graph/GraphWithMarkerControls.tsx | 66 +++++++++++++------ 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx index 498d9b4fe..609850096 100644 --- a/electron/src/components/graph/GraphWithMarkerControls.tsx +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -115,11 +115,10 @@ export function GraphWithMarkerControls({ if (!graphWrapperRef.current || !currentTimeSeries?.current) return; const graphEl = graphWrapperRef.current; - // The BigGraph component is the first child (the one with the actual chart) - // TODO: Find a better way to do this - const chartContainer = graphEl.querySelector( - ".h-\\[50vh\\] > div > div.flex-1 > div", - ); + // Find chart container via uPlot canvas (canvas is always a direct child) + const canvas = graphEl.querySelector("canvas"); + if (!canvas) return; + const chartContainer = canvas.parentElement; if (!chartContainer) return; const graphWidth = chartContainer.clientWidth; @@ -144,24 +143,53 @@ export function GraphWithMarkerControls({ const endTime = currentTimeSeries.current.timestamp; const startTime = endTime - validTimeWindowMs; - // Assuming the graph's fixed Y-scale is from -1 to 1 based on the sine wave example - const graphMin = -1; - const graphMax = 1; - // TODO: For real-world graphs (like Winder), you might need to read the actual min/max scale - // from the uPlot instance or define a safe range if the data is unconstrained. + // Calculate Y-axis scale from visible data (similar to createChart.ts) + const visibleValues: number[] = []; + + // Collect values from the time series in the visible time window + currentTimeSeries.long.values + .filter((v): v is TimeSeriesValue => v !== null) + .forEach((v) => { + if (v.timestamp >= startTime && v.timestamp <= endTime) { + visibleValues.push(v.value); + } + }); + + // Include config lines in the scale calculation + config.lines?.forEach((line) => { + if (line.show !== false) { + visibleValues.push(line.value); + } + }); + + // Calculate min/max with 10% padding (matching createChart.ts behavior) + let graphMin: number, graphMax: number; + if (visibleValues.length > 0) { + const minY = Math.min(...visibleValues); + const maxY = Math.max(...visibleValues); + const range = maxY - minY || Math.abs(maxY) * 0.1 || 1; + graphMin = minY - range * 0.1; + graphMax = maxY + range * 0.1; + } else { + // Fallback if no data is available + graphMin = -1; + graphMax = 1; + } markers.forEach(({ timestamp, name }) => { if (timestamp >= startTime && timestamp <= endTime) { // Find the data point closest to the marker timestamp to get the correct Y-value - const closest = currentTimeSeries.long.values - .filter((v): v is TimeSeriesValue => v !== null) - .reduce((prev, curr) => - Math.abs(curr.timestamp - timestamp) < - Math.abs(prev.timestamp - timestamp) - ? curr - : prev, - ); - if (!closest) return; + const validValues = currentTimeSeries.long.values.filter( + (v): v is TimeSeriesValue => v !== null, + ); + if (validValues.length === 0) return; + + const closest = validValues.reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < + Math.abs(prev.timestamp - timestamp) + ? curr + : prev, + ); // Calculate the Y-position in pixels from the bottom of the chart area const normalizedValue = From 7ef2f5552fe39cebad73e86a4e5f8dadfc792533 Mon Sep 17 00:00:00 2001 From: EinsPommes Date: Sun, 25 Jan 2026 10:21:41 +0100 Subject: [PATCH 11/12] feat(graph): persist markers and render marker points --- .../graph/GraphWithMarkerControls.tsx | 142 ++++++++++++------ 1 file changed, 98 insertions(+), 44 deletions(-) diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx index 609850096..d71e73af9 100644 --- a/electron/src/components/graph/GraphWithMarkerControls.tsx +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -26,32 +26,51 @@ type GraphWithMarkerControlsProps = { function createMarkerElement( timestamp: number, - value: number, name: string, + value: number, + graphMin: number, + graphMax: number, startTime: number, endTime: number, graphWidth: number, graphHeight: number, ) { - // Calculate the position of the timestamp + // Calculate the X position of the timestamp const ratio = (timestamp - startTime) / (endTime - startTime); const xPos = Math.min(Math.max(ratio, 0), 1) * graphWidth; - const yPos = graphHeight - value; + // Calculate the Y position of the value (from bottom of graph) + const normalizedValue = (value - graphMin) / (graphMax - graphMin); + const valueY = graphHeight - normalizedValue * graphHeight; + + // Create vertical line that spans full height (shows time position) const line = document.createElement("div"); line.style.position = "absolute"; line.style.left = `${xPos}px`; - line.style.top = `${yPos}px`; - line.style.height = `${value}px`; + line.style.top = "0px"; + line.style.height = `${graphHeight}px`; line.style.width = "2px"; line.style.background = "rgba(0, 0, 0, 0.5)"; line.className = "vertical-marker"; + // Create a point at the actual data value position + const point = document.createElement("div"); + point.style.position = "absolute"; + point.style.left = `${xPos}px`; + point.style.top = `${valueY}px`; + point.style.width = "8px"; + point.style.height = "8px"; + point.style.borderRadius = "50%"; + point.style.background = "rgba(0, 0, 0, 0.8)"; + point.style.transform = "translate(-50%, -50%)"; + point.style.border = "2px solid white"; + point.className = "marker-point"; + const label = document.createElement("div"); label.textContent = name; label.style.position = "absolute"; label.style.left = `${xPos}px`; - label.style.top = `${yPos - 20}px`; + label.style.top = `${graphHeight + 5}px`; label.style.transform = "translateX(-50%)"; label.style.color = "black"; label.style.padding = "2px 4px"; @@ -59,7 +78,7 @@ function createMarkerElement( label.style.whiteSpace = "nowrap"; label.className = "marker-label"; - return { line, label }; + return { line, point, label }; } export function GraphWithMarkerControls({ @@ -73,20 +92,71 @@ export function GraphWithMarkerControls({ }: GraphWithMarkerControlsProps) { const graphWrapperRef = useRef(null); const [markerName, setMarkerName] = useState(""); + + // Load markers from localStorage on mount and clean up old ones + const loadMarkersFromStorage = useCallback((): { + timestamp: number; + name: string; + value: number; + }[] => { + try { + const storageKey = `graph-markers-${graphId}`; + const stored = localStorage.getItem(storageKey); + if (stored) { + const allMarkers: { + timestamp: number; + name: string; + value: number; + }[] = JSON.parse(stored); + + // Remove markers older than 7 days to save storage space + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const recentMarkers = allMarkers.filter( + (marker) => marker.timestamp >= sevenDaysAgo, + ); + + // Limit to max 100 markers per graph to prevent storage bloat + const maxMarkers = 100; + const limitedMarkers = + recentMarkers.length > maxMarkers + ? recentMarkers.slice(-maxMarkers) + : recentMarkers; + + // Save cleaned markers back if we removed any + if (limitedMarkers.length !== allMarkers.length) { + localStorage.setItem(storageKey, JSON.stringify(limitedMarkers)); + } + + return limitedMarkers; + } + } catch (error) { + console.warn("Failed to load markers from localStorage:", error); + } + return []; + }, [graphId]); + const [markers, setMarkers] = useState< { timestamp: number; name: string; value: number }[] - >([]); + >(loadMarkersFromStorage); const [statusMessage, setStatusMessage] = useState(null); - const dynamicMarkerLines = markers.map((marker, index) => ({ - type: "user_marker" as const, - value: marker.value, - label: marker.name, - color: "#ff0000", - width: 2, - show: true, - markerTimestamp: marker.timestamp, - })); + // Save markers to localStorage whenever they change, with limits + useEffect(() => { + try { + const storageKey = `graph-markers-${graphId}`; + + // Limit to max 100 markers per graph + const maxMarkers = 100; + const markersToSave = + markers.length > maxMarkers ? markers.slice(-maxMarkers) : markers; + + localStorage.setItem(storageKey, JSON.stringify(markersToSave)); + } catch (error) { + console.warn("Failed to save markers to localStorage:", error); + } + }, [markers, graphId]); + + // Markers are rendered as overlay elements, not as graph lines // Time Tick for forcing marker redraw const [timeTick, setTimeTick] = useState(0); @@ -127,9 +197,9 @@ export function GraphWithMarkerControls({ const overlayContainer = chartContainer.parentElement; if (!overlayContainer) return; - // Remove previous markers and labels from the overlay container + // Remove previous markers, points and labels from the overlay container overlayContainer - .querySelectorAll(".vertical-marker, .marker-label") + .querySelectorAll(".vertical-marker, .marker-point, .marker-label") .forEach((el) => el.remove()); // Get the visible time window @@ -176,30 +246,15 @@ export function GraphWithMarkerControls({ graphMax = 1; } - markers.forEach(({ timestamp, name }) => { + markers.forEach(({ timestamp, name, value }) => { if (timestamp >= startTime && timestamp <= endTime) { - // Find the data point closest to the marker timestamp to get the correct Y-value - const validValues = currentTimeSeries.long.values.filter( - (v): v is TimeSeriesValue => v !== null, - ); - if (validValues.length === 0) return; - - const closest = validValues.reduce((prev, curr) => - Math.abs(curr.timestamp - timestamp) < - Math.abs(prev.timestamp - timestamp) - ? curr - : prev, - ); - - // Calculate the Y-position in pixels from the bottom of the chart area - const normalizedValue = - (closest.value - graphMin) / (graphMax - graphMin); - const valueY = normalizedValue * graphHeight; - - const { line, label } = createMarkerElement( + // Create marker element (full height line + point at data value) + const { line, point, label } = createMarkerElement( timestamp, - valueY, name, + value, + graphMin, + graphMax, startTime, endTime, graphWidth, @@ -207,6 +262,7 @@ export function GraphWithMarkerControls({ ); overlayContainer.appendChild(line); + overlayContainer.appendChild(point); overlayContainer.appendChild(label); } }); @@ -218,10 +274,8 @@ export function GraphWithMarkerControls({ syncHook.controlProps.timeWindow, ]); - const finalConfig = { - ...config, - lines: [...(config.lines || []), ...dynamicMarkerLines], - }; + // Use original config without adding marker lines (markers are overlay elements) + const finalConfig = config; return (
From e1b55179cd94e7c0d0e6cd6bcd5801579b1ffc3c Mon Sep 17 00:00:00 2001 From: EinsPommes Date: Sun, 25 Jan 2026 17:14:56 +0100 Subject: [PATCH 12/12] feat(graph): add marker functionality and improve marker rendering --- .../src/components/graph/AddMarkerDialog.tsx | 154 +++++++++++++++ .../src/components/graph/GraphControls.tsx | 40 +++- .../graph/GraphWithMarkerControls.tsx | 176 ++++++++---------- .../src/components/graph/MarkerContext.tsx | 42 +++++ .../src/components/graph/SyncedComponents.tsx | 60 +++++- electron/src/components/graph/index.ts | 3 + electron/src/components/graph/types.ts | 1 + .../src/components/graph/useMarkerManager.ts | 139 ++++++++++++++ 8 files changed, 500 insertions(+), 115 deletions(-) create mode 100644 electron/src/components/graph/AddMarkerDialog.tsx create mode 100644 electron/src/components/graph/MarkerContext.tsx create mode 100644 electron/src/components/graph/useMarkerManager.ts diff --git a/electron/src/components/graph/AddMarkerDialog.tsx b/electron/src/components/graph/AddMarkerDialog.tsx new file mode 100644 index 000000000..11e352480 --- /dev/null +++ b/electron/src/components/graph/AddMarkerDialog.tsx @@ -0,0 +1,154 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { TouchButton } from "@/components/touch/TouchButton"; +import { TimeInput } from "@/components/time/TimeInput"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Icon } from "@/components/Icon"; + +type AddMarkerDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onAddMarker: (name: string, timestamp: number, color?: string) => void; + currentTimestamp: number | null; + defaultName?: string; +}; + +export function AddMarkerDialog({ + open, + onOpenChange, + onAddMarker, + currentTimestamp, + defaultName = "", +}: AddMarkerDialogProps) { + const [name, setName] = useState(defaultName); + const [selectedTimestamp, setSelectedTimestamp] = useState( + null, + ); + const [color, setColor] = useState("#000000"); + + // Reset form when dialog opens/closes + useEffect(() => { + if (open) { + setName(defaultName); + // Always use current time when dialog opens + setSelectedTimestamp(currentTimestamp); + } else { + setName(""); + setSelectedTimestamp(null); + } + }, [open, currentTimestamp, defaultName]); + + const handleAdd = () => { + if (!name.trim()) return; + + // Always use current timestamp when dialog is opened (as per requirement) + // The time input is optional and only for historical markers + const timestamp = currentTimestamp || Date.now(); + if (!timestamp) return; + + onAddMarker(name.trim(), timestamp, color); + onOpenChange(false); + }; + + const handleCancel = () => { + onOpenChange(false); + }; + + return ( + + + + + + Add Marker + + + Create a marker for all graphs of this machine at the current time. + + + + +
+ {/* Name Input */} +
+ + setName(e.target.value)} + placeholder="Enter marker name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAdd(); + } + }} + /> +
+ + {/* Time Input (optional) */} +
+ + setSelectedTimestamp(currentTimestamp)} + /> +

+ Leave empty to use current time +

+
+ + {/* Color Input */} +
+ +
+ setColor(e.target.value)} + className="border-input h-9 w-20 cursor-pointer rounded-md border" + /> + setColor(e.target.value)} + placeholder="#000000" + /> +
+
+
+ + +
+ + Abort + + + Add Marker + +
+
+
+ ); +} diff --git a/electron/src/components/graph/GraphControls.tsx b/electron/src/components/graph/GraphControls.tsx index a5daa43d4..53a9d2601 100644 --- a/electron/src/components/graph/GraphControls.tsx +++ b/electron/src/components/graph/GraphControls.tsx @@ -21,6 +21,7 @@ export function GraphControls({ onSwitchToLive, onSwitchToHistorical, onExport, + onAddMarker, timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS, showFromTimestamp, onShowFromChange, @@ -102,16 +103,27 @@ export function GraphControls({ Live - {onExport && ( + {(onExport || onAddMarker) && ( <>
- - Export - + {onAddMarker && ( + + Add Marker + + )} + {onExport && ( + + Export + + )} )}
@@ -127,6 +139,7 @@ export function FloatingControlPanel({ onSwitchToLive, onSwitchToHistorical, onExport, + onAddMarker, timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS, showFromTimestamp, onShowFromChange, @@ -218,9 +231,18 @@ export function FloatingControlPanel({ > Live - {isExpanded && onExport && ( + {isExpanded && (onExport || onAddMarker) && (
)} + {onAddMarker && ( + + Add Marker + + )} {onExport && ( string; graphId: string; currentTimeSeries: TimeSeries | null; + machineId?: string; + markers?: Array<{ + timestamp: number; + name: string; + value?: number; + color?: string; + }>; }; function createMarkerElement( @@ -34,6 +43,7 @@ function createMarkerElement( endTime: number, graphWidth: number, graphHeight: number, + color?: string, ) { // Calculate the X position of the timestamp const ratio = (timestamp - startTime) / (endTime - startTime); @@ -50,7 +60,9 @@ function createMarkerElement( line.style.top = "0px"; line.style.height = `${graphHeight}px`; line.style.width = "2px"; - line.style.background = "rgba(0, 0, 0, 0.5)"; + // Use custom color if provided, otherwise default gray + const lineColor = color || "rgba(0, 0, 0, 0.5)"; + line.style.background = lineColor; line.className = "vertical-marker"; // Create a point at the actual data value position @@ -61,7 +73,9 @@ function createMarkerElement( point.style.width = "8px"; point.style.height = "8px"; point.style.borderRadius = "50%"; - point.style.background = "rgba(0, 0, 0, 0.8)"; + // Use custom color if provided, otherwise default black + const pointColor = color || "rgba(0, 0, 0, 0.8)"; + point.style.background = pointColor; point.style.transform = "translate(-50%, -50%)"; point.style.border = "2px solid white"; point.className = "marker-point"; @@ -81,7 +95,7 @@ function createMarkerElement( return { line, point, label }; } -export function GraphWithMarkerControls({ +function GraphWithMarkerControlsContent({ syncHook, newData, config, @@ -89,74 +103,32 @@ export function GraphWithMarkerControls({ renderValue, graphId, currentTimeSeries, -}: GraphWithMarkerControlsProps) { - const graphWrapperRef = useRef(null); - const [markerName, setMarkerName] = useState(""); - - // Load markers from localStorage on mount and clean up old ones - const loadMarkersFromStorage = useCallback((): { - timestamp: number; - name: string; - value: number; - }[] => { - try { - const storageKey = `graph-markers-${graphId}`; - const stored = localStorage.getItem(storageKey); - if (stored) { - const allMarkers: { - timestamp: number; - name: string; - value: number; - }[] = JSON.parse(stored); - - // Remove markers older than 7 days to save storage space - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - const recentMarkers = allMarkers.filter( - (marker) => marker.timestamp >= sevenDaysAgo, - ); - - // Limit to max 100 markers per graph to prevent storage bloat - const maxMarkers = 100; - const limitedMarkers = - recentMarkers.length > maxMarkers - ? recentMarkers.slice(-maxMarkers) - : recentMarkers; - - // Save cleaned markers back if we removed any - if (limitedMarkers.length !== allMarkers.length) { - localStorage.setItem(storageKey, JSON.stringify(limitedMarkers)); - } - - return limitedMarkers; - } - } catch (error) { - console.warn("Failed to load markers from localStorage:", error); - } - return []; - }, [graphId]); - - const [markers, setMarkers] = useState< - { timestamp: number; name: string; value: number }[] - >(loadMarkersFromStorage); - const [statusMessage, setStatusMessage] = useState(null); + machineId: providedMachineId, + markers: providedMarkers, + graphWrapperRef, +}: GraphWithMarkerControlsProps & { + graphWrapperRef: React.RefObject; +}) { + const { setMachineId, setCurrentTimestamp } = useMarkerContext(); + + // Auto-detect machineId from graphId if not provided (extract base name) + // e.g., "pressure-graph" -> "pressure", "extruder-graphs" -> "extruder-graphs" + const machineId = providedMachineId || graphId.split("-")[0] || "default"; + + // Update context with machineId and current timestamp + useEffect(() => { + setMachineId(machineId); + }, [machineId, setMachineId]); - // Save markers to localStorage whenever they change, with limits useEffect(() => { - try { - const storageKey = `graph-markers-${graphId}`; - - // Limit to max 100 markers per graph - const maxMarkers = 100; - const markersToSave = - markers.length > maxMarkers ? markers.slice(-maxMarkers) : markers; - - localStorage.setItem(storageKey, JSON.stringify(markersToSave)); - } catch (error) { - console.warn("Failed to save markers to localStorage:", error); + if (currentTimeSeries?.current?.timestamp) { + setCurrentTimestamp(currentTimeSeries.current.timestamp); } - }, [markers, graphId]); + }, [currentTimeSeries?.current?.timestamp, setCurrentTimestamp]); - // Markers are rendered as overlay elements, not as graph lines + // Use provided markers or load from marker manager + const markerManager = useMarkerManager(machineId); + const markers = providedMarkers || markerManager.markers; // Time Tick for forcing marker redraw const [timeTick, setTimeTick] = useState(0); @@ -170,16 +142,6 @@ export function GraphWithMarkerControls({ return () => clearInterval(intervalId); }, [currentTimeSeries?.current]); - const handleAddMarker = useCallback(() => { - if (currentTimeSeries?.current && markerName.trim()) { - const ts = currentTimeSeries.current.timestamp; - const val = currentTimeSeries.current.value; - const name = markerName.trim(); - - setMarkers((prev) => [...prev, { timestamp: ts, name, value: val }]); - } - }, [currentTimeSeries, markerName]); - // Marker Drawing Effect useEffect(() => { if (!graphWrapperRef.current || !currentTimeSeries?.current) return; @@ -246,19 +208,43 @@ export function GraphWithMarkerControls({ graphMax = 1; } - markers.forEach(({ timestamp, name, value }) => { + markers.forEach(({ timestamp, name, value, color }) => { if (timestamp >= startTime && timestamp <= endTime) { + // Find the data point closest to the marker timestamp to get the correct Y-value + // If value is not provided, use the closest data point + let markerValue = value; + if (markerValue === undefined && currentTimeSeries) { + const validValues = currentTimeSeries.long.values.filter( + (v): v is TimeSeriesValue => v !== null, + ); + if (validValues.length > 0) { + const closest = validValues.reduce((prev, curr) => + Math.abs(curr.timestamp - timestamp) < + Math.abs(prev.timestamp - timestamp) + ? curr + : prev, + ); + markerValue = closest.value; + } + } + + // Use a default value if still undefined + if (markerValue === undefined) { + markerValue = (graphMin + graphMax) / 2; + } + // Create marker element (full height line + point at data value) const { line, point, label } = createMarkerElement( timestamp, name, - value, + markerValue, graphMin, graphMax, startTime, endTime, graphWidth, graphHeight, + color, ); overlayContainer.appendChild(line); @@ -290,26 +276,18 @@ export function GraphWithMarkerControls({ graphId={graphId} />
- - {/* Marker Input and Button */} -
- Add Marker: - setMarkerName(e.target.value)} - className="rounded border px-2 py-1" - /> - -

{statusMessage ?? ""}

-
); } + +export function GraphWithMarkerControls(props: GraphWithMarkerControlsProps) { + const graphWrapperRef = useRef(null); + + // Use context if available (from SyncedFloatingControlPanel), otherwise work without it + return ( + + ); +} diff --git a/electron/src/components/graph/MarkerContext.tsx b/electron/src/components/graph/MarkerContext.tsx new file mode 100644 index 000000000..111899d3d --- /dev/null +++ b/electron/src/components/graph/MarkerContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, useState } from "react"; + +type MarkerContextType = { + machineId: string | null; + setMachineId: (id: string) => void; + currentTimestamp: number | null; + setCurrentTimestamp: (timestamp: number) => void; +}; + +const MarkerContext = createContext(null); + +export function MarkerProvider({ children }: { children: React.ReactNode }) { + const [machineId, setMachineId] = useState(null); + const [currentTimestamp, setCurrentTimestamp] = useState(null); + + return ( + + {children} + + ); +} + +export function useMarkerContext() { + const context = useContext(MarkerContext); + // Return a default context if not within a provider (for non-graph pages) + if (!context) { + return { + machineId: null, + setMachineId: () => {}, + currentTimestamp: null, + setCurrentTimestamp: () => {}, + }; + } + return context; +} diff --git a/electron/src/components/graph/SyncedComponents.tsx b/electron/src/components/graph/SyncedComponents.tsx index 7c182d5a3..5cfd3ed65 100644 --- a/electron/src/components/graph/SyncedComponents.tsx +++ b/electron/src/components/graph/SyncedComponents.tsx @@ -1,9 +1,12 @@ -import React from "react"; +import React, { useState } from "react"; import { BigGraph } from "./BigGraph"; import { GraphControls, FloatingControlPanel } from "./GraphControls"; import { useGraphSync } from "./useGraphSync"; import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types"; import { GraphExportData } from "./excelExport"; +import { useMarkerManager } from "./useMarkerManager"; +import { AddMarkerDialog } from "./AddMarkerDialog"; +import { MarkerProvider, useMarkerContext } from "./MarkerContext"; export function SyncedBigGraph({ syncGraph: externalSyncGraph, @@ -50,7 +53,7 @@ export function SyncedGraphControls({ ); } -export function SyncedFloatingControlPanel({ +function SyncedFloatingControlPanelInner({ controlProps, timeWindowOptions, ...props @@ -60,13 +63,56 @@ export function SyncedFloatingControlPanel({ }) { const defaultSync = useGraphSync(); const finalProps = controlProps || defaultSync.controlProps; + const { machineId, currentTimestamp } = useMarkerContext(); + + // Use machineId from context (set by GraphWithMarkerControls) or fallback to "default" + const detectedMachineId = machineId || "default"; + const { addMarker } = useMarkerManager(detectedMachineId); + const [isMarkerDialogOpen, setIsMarkerDialogOpen] = useState(false); + + // Always use current timestamp from context (live time from graphs) or current time + // As per requirement: "always use the current time" + const markerTimestamp = currentTimestamp || Date.now(); + + const handleAddMarker = (name: string, timestamp: number, color?: string) => { + addMarker(name, timestamp, color); + }; return ( - + <> + setIsMarkerDialogOpen(true)} + {...props} + /> + + + ); +} + +export function SyncedFloatingControlPanel({ + controlProps, + timeWindowOptions, + ...props +}: { + controlProps?: ReturnType["controlProps"]; + timeWindowOptions?: TimeWindowOption[]; +}) { + // Wrap in MarkerProvider so it's only available for graph pages + return ( + + + ); } diff --git a/electron/src/components/graph/index.ts b/electron/src/components/graph/index.ts index bfa18fb38..6ef87b1e0 100644 --- a/electron/src/components/graph/index.ts +++ b/electron/src/components/graph/index.ts @@ -10,6 +10,9 @@ export { AutoSyncedBigGraph, } from "./SyncedComponents"; +// Context +export { MarkerProvider } from "./MarkerContext"; + // Hooks export { useGraphSync } from "./useGraphSync"; diff --git a/electron/src/components/graph/types.ts b/electron/src/components/graph/types.ts index bbec327fe..a529cd480 100644 --- a/electron/src/components/graph/types.ts +++ b/electron/src/components/graph/types.ts @@ -86,6 +86,7 @@ export type ControlProps = { onSwitchToLive: () => void; onSwitchToHistorical: () => void; onExport?: () => void; + onAddMarker?: () => void; timeWindowOptions?: TimeWindowOption[]; showFromTimestamp?: number | null; onShowFromChange?: (timestamp: number | null) => void; diff --git a/electron/src/components/graph/useMarkerManager.ts b/electron/src/components/graph/useMarkerManager.ts new file mode 100644 index 000000000..45d82976b --- /dev/null +++ b/electron/src/components/graph/useMarkerManager.ts @@ -0,0 +1,139 @@ +import { useState, useCallback, useEffect } from "react"; + +export type Marker = { + timestamp: number; + name: string; + value?: number; // Optional: value at that timestamp + color?: string; // Optional: color for the marker +}; + +// Custom event for marker updates to ensure immediate propagation +const MARKER_UPDATE_EVENT = "marker-update"; + +/** + * Centralized marker management for all graphs of a machine + * Markers are stored per machine (machineId) and appear on all graphs + */ +export function useMarkerManager(machineId: string) { + const storageKey = `machine-markers-${machineId}`; + + // Load markers from localStorage + const loadMarkers = useCallback((): Marker[] => { + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const allMarkers: Marker[] = JSON.parse(stored); + + // Remove markers older than 7 days to save storage space + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const recentMarkers = allMarkers.filter( + (marker) => marker.timestamp >= sevenDaysAgo, + ); + + // Limit to max 200 markers per machine to prevent storage bloat + const maxMarkers = 200; + const limitedMarkers = + recentMarkers.length > maxMarkers + ? recentMarkers.slice(-maxMarkers) + : recentMarkers; + + // Save cleaned markers back if we removed any + if (limitedMarkers.length !== allMarkers.length) { + localStorage.setItem(storageKey, JSON.stringify(limitedMarkers)); + } + + return limitedMarkers; + } + } catch (error) { + console.warn("Failed to load markers from localStorage:", error); + } + return []; + }, [storageKey]); + + const [markers, setMarkers] = useState(loadMarkers); + + // Listen for marker updates from other components + useEffect(() => { + const handleMarkerUpdate = (event: CustomEvent) => { + if (event.detail?.machineId === machineId) { + // Reload markers immediately when updated + setMarkers(loadMarkers()); + } + }; + + window.addEventListener( + MARKER_UPDATE_EVENT, + handleMarkerUpdate as EventListener, + ); + + return () => { + window.removeEventListener( + MARKER_UPDATE_EVENT, + handleMarkerUpdate as EventListener, + ); + }; + }, [machineId, loadMarkers]); + + // Save markers to localStorage whenever they change + useEffect(() => { + try { + // Limit to max 200 markers per machine + const maxMarkers = 200; + const markersToSave = + markers.length > maxMarkers ? markers.slice(-maxMarkers) : markers; + + localStorage.setItem(storageKey, JSON.stringify(markersToSave)); + } catch (error) { + console.warn("Failed to save markers to localStorage:", error); + } + }, [markers, storageKey]); + + const addMarker = useCallback( + (name: string, timestamp: number, color?: string, value?: number) => { + const newMarker: Marker = { + timestamp, + name, + color, + value, + }; + setMarkers((prev) => { + const updated = [...prev, newMarker]; + // Save immediately to localStorage + try { + const maxMarkers = 200; + const markersToSave = + updated.length > maxMarkers ? updated.slice(-maxMarkers) : updated; + localStorage.setItem(storageKey, JSON.stringify(markersToSave)); + } catch (error) { + console.warn("Failed to save marker to localStorage:", error); + } + // Dispatch event to notify other components immediately + window.dispatchEvent( + new CustomEvent(MARKER_UPDATE_EVENT, { + detail: { machineId, markers: updated }, + }), + ); + return updated; + }); + return newMarker; + }, + [storageKey, machineId], + ); + + const removeMarker = useCallback((timestamp: number) => { + setMarkers((prev) => + prev.filter((marker) => marker.timestamp !== timestamp), + ); + }, []); + + const clearMarkers = useCallback(() => { + setMarkers([]); + }, []); + + return { + markers, + addMarker, + removeMarker, + clearMarkers, + }; +}