diff --git a/electron/src/components/graph/GraphWithMarkerControls.tsx b/electron/src/components/graph/GraphWithMarkerControls.tsx new file mode 100644 index 000000000..498d9b4fe --- /dev/null +++ b/electron/src/components/graph/GraphWithMarkerControls.tsx @@ -0,0 +1,233 @@ +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; + currentTimeSeries: TimeSeries | null; +}; + +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; value: number }[] + >([]); + 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, + })); + + // 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 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; + + 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 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. + + 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, + ); + + overlayContainer.appendChild(line); + overlayContainer.appendChild(label); + } + }); + }, [ + markers, + currentTimeSeries, + timeTick, + config.defaultTimeWindow, + syncHook.controlProps.timeWindow, + ]); + + const finalConfig = { + ...config, + lines: [...(config.lines || []), ...dynamicMarkerLines], + }; + + return ( +
+
+ {/* Render the core chart component */} + +
+ + {/* Marker Input and Button */} +
+ Add Marker: + setMarkerName(e.target.value)} + className="rounded border px-2 py-1" + /> + +

{statusMessage ?? ""}

+
+
+ ); +} diff --git a/electron/src/components/graph/excelExport.ts b/electron/src/components/graph/excelExport.ts index 493975862..8b86fcab9 100644 --- a/electron/src/components/graph/excelExport.ts +++ b/electron/src/components/graph/excelExport.ts @@ -84,6 +84,27 @@ export function exportGraphsToExcel( XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName); } + // Excel worksheet for timestamps and timestamp markers + if (graphLineData.targetLines.length > 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, + ); + } + processedCount++; }); @@ -290,6 +311,119 @@ function createGraphLineDataSheet(graphLine: { }); } +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; + } + + // Filter User Markers + 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< + number, + { label: string; originalTimestamp: number } + >(); + + 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 timestamp 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.", ""]); + } + + timestamps.forEach((dataPointTimestamp, index) => { + const value = values[index]; + const markerData = markerIndexMap.get(index); + + let finalMarkerLabel = ""; + let timeToDisplay = dataPointTimestamp; // Default to data sample time + + if (markerData) { + 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, ""); + + // 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 Name + reportData.push(["Marker", 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 beff72807..bbec327fe 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 = { diff --git a/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx b/electron/src/machines/extruder/extruder2/Extruder2Graph.tsx index b8bc86e26..69d118afa 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 { @@ -242,7 +243,7 @@ export function Extruder2GraphsPage() { return (
- value.toFixed(2)} graphId="pressure-graph" + currentTimeSeries={pressure} /> - value.toFixed(1)} graphId="combined-temperatures" + currentTimeSeries={nozzleTemperature} /> - value.toFixed(1)} graphId="combined-power" + currentTimeSeries={combinedPower} /> - value.toFixed(2)} graphId="motor-current" + currentTimeSeries={motorCurrent} /> - value.toFixed(0)} graphId="rpm-graph" + currentTimeSeries={motorScrewRpm} />
diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index 5f0805d96..066d623b1 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -8,6 +8,8 @@ import { import React 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"; export function Mock1GraphPage() { const { sineWaveSum } = useMock1(); @@ -98,40 +100,44 @@ export function Mock1GraphPage() { return (
- value.toFixed(3)} graphId="single-graph1" + currentTimeSeries={sineWaveSum} /> - value.toFixed(3)} graphId="combined-graph" + currentTimeSeries={sineWaveSum} /> - value.toFixed(3)} graphId="single-graph2" + currentTimeSeries={sineWaveSum} /> - value.toFixed(3)} graphId="single-graph" + currentTimeSeries={sineWaveSum} />
diff --git a/electron/src/machines/winder/winder2/Winder2Graphs.tsx b/electron/src/machines/winder/winder2/Winder2Graphs.tsx index ab3adb066..450ceea40 100644 --- a/electron/src/machines/winder/winder2/Winder2Graphs.tsx +++ b/electron/src/machines/winder/winder2/Winder2Graphs.tsx @@ -11,6 +11,7 @@ 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"; export function Winder2GraphsPage() { const { @@ -96,7 +97,7 @@ export function SpoolRpmGraph({ }; return ( - ); } @@ -155,7 +157,7 @@ export function TraversePositionGraph({ }; return ( - ); } @@ -191,7 +194,7 @@ export function TensionArmAngleGraph({ }; return ( - ); } @@ -225,8 +229,9 @@ export function SpoolProgressGraph({ exportFilename: "spool_progress", }; + // NOTE: Assuming this graph starts at 0, and the max is the total capacity. return ( - ); } @@ -274,7 +280,7 @@ export function PullerSpeedGraph({ }; return ( - ); }