diff --git a/electron/src/components/graph/AddMarkerDialog.tsx b/electron/src/components/graph/AddMarkerDialog.tsx new file mode 100644 index 000000000..231f05ab7 --- /dev/null +++ b/electron/src/components/graph/AddMarkerDialog.tsx @@ -0,0 +1,182 @@ +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; + // Existing marker names to prevent duplicates (case-insensitive) + existingNames?: string[]; +}; + +export function AddMarkerDialog({ + open, + onOpenChange, + onAddMarker, + currentTimestamp, + defaultName = "", + existingNames = [], +}: AddMarkerDialogProps) { + const [name, setName] = useState(defaultName); + const [selectedTimestamp, setSelectedTimestamp] = useState( + null, + ); + const [color, setColor] = useState("#000000"); + // Shown when user tries to add a marker whose name already exists + const [duplicateNameError, setDuplicateNameError] = useState(false); + + // Reset form only when dialog opens or closes; do not reset when currentTimestamp + // updates while open (e.g. from graph) or we overwrite the user's time input + useEffect(() => { + if (open) { + setName(defaultName); + setSelectedTimestamp(currentTimestamp ?? Date.now()); + setDuplicateNameError(false); + } else { + setName(""); + setSelectedTimestamp(null); + setDuplicateNameError(false); + } + }, [open]); + + const handleAdd = () => { + if (!name.trim()) return; + + // Reject duplicate names (compare trimmed, case-insensitive) + const trimmedName = name.trim(); + const isDuplicate = existingNames.some( + (existing) => existing.trim().toLowerCase() === trimmedName.toLowerCase(), + ); + if (isDuplicate) { + setDuplicateNameError(true); + return; + } + + setDuplicateNameError(false); + + // Use selected time if set, else context/graph time, else now + const timestamp = selectedTimestamp ?? currentTimestamp ?? Date.now(); + if (!timestamp) return; + + onAddMarker(trimmedName, 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); + setDuplicateNameError(false); + }} + placeholder="Enter marker name" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAdd(); + } + }} + /> + {duplicateNameError && ( +

+ A marker with this name already exists. +

+ )} +
+ + {/* Time Input (optional) */} +
+ + + setSelectedTimestamp(currentTimestamp ?? Date.now()) + } + /> +

+ 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/BigGraph.tsx b/electron/src/components/graph/BigGraph.tsx index 8a8796ff7..8f06d0d2f 100644 --- a/electron/src/components/graph/BigGraph.tsx +++ b/electron/src/components/graph/BigGraph.tsx @@ -92,6 +92,7 @@ export function BigGraph({ config, graphId, syncGraph, + uplotRefOut, onRegisterForExport, onUnregisterFromExport, }: BigGraphProps & { @@ -456,6 +457,7 @@ export function BigGraph({ useBigGraphEffects({ containerRef, uplotRef, + uplotRefOut, startTimeRef, manualScaleRef, lastProcessedCountRef, @@ -494,8 +496,8 @@ export function BigGraph({ return (
-
-
+
+
-
+
{normalizedSeries.length === 1 && ( - +
{formatDisplayValue(displayValue, renderValue)} diff --git a/electron/src/components/graph/GraphControls.tsx b/electron/src/components/graph/GraphControls.tsx index a5daa43d4..6dd88e86e 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,10 +231,19 @@ export function FloatingControlPanel({ > Live - {isExpanded && onExport && ( + {isExpanded && (onExport || onAddMarker) && (
)} - {onExport && ( + {isExpanded && onAddMarker && ( + + Add Marker + + )} + {isExpanded && onExport && ( ; + newData: TimeSeriesData | TimeSeriesData[]; + config: GraphConfig; + unit?: Unit; + renderValue?: (value: number) => string; + graphId: string; + currentTimeSeries: TimeSeries | null; + machineId?: string; + markers?: Array<{ + timestamp: number; + name: string; + value?: number; + color?: string; + }>; +}; + +const MARKER_HIT_WIDTH = 28; + +const LABEL_TOP_OFFSET = 30; + +function applyLabelHighlight(label: HTMLDivElement, highlighted: boolean) { + if (highlighted) { + label.style.zIndex = "20"; + label.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)"; + label.style.borderColor = "rgb(59 130 246)"; + } else { + label.style.zIndex = ""; + label.style.boxShadow = "0 2px 6px rgba(0,0,0,0.15)"; + label.style.borderColor = "rgba(0,0,0,0.12)"; + } +} + +function unhighlightAllLabels(container: HTMLElement) { + container.querySelectorAll(".marker-label").forEach((el) => { + applyLabelHighlight(el, false); + }); +} + +// Apply or restore overlay styles (avoids React Compiler "mutates immutable" on inline DOM writes) +function setOverlayOverflow( + container: HTMLElement, + parent: HTMLElement | null, + visible: boolean, + previous: { overflow: string; position: string; parentOverflow: string }, +) { + if (visible) { + container.style.overflow = "visible"; + container.style.position = "relative"; + if (parent) parent.style.overflow = "visible"; + } else { + container.style.overflow = previous.overflow; + container.style.position = previous.position; + if (parent) parent.style.overflow = previous.parentOverflow; + } +} + +// Duration (ms) to hold pointer on marker before it is deleted +const LONG_PRESS_DELETE_MS = 5000; + +// Build marker DOM: one wrapper (hit area) for hover/tap, contains line, point, label. +// Optional onLongPress: called after LONG_PRESS_DELETE_MS hold (e.g. to delete marker). +function createMarkerElement( + u: uPlot, + overlayRect: DOMRect, + timestamp: number, + name: string, + value: number, + color?: string, + onLongPress?: () => void, +): HTMLDivElement { + const plotRect = u.rect; + const plotLeftInOverlay = plotRect.left - overlayRect.left; + const plotTopInOverlay = plotRect.top - overlayRect.top; + const plotHeight = plotRect.height; + const plotWidth = plotRect.width; + + let xInPlot = u.valToPos(timestamp, "x", false); + xInPlot = Math.max(0, Math.min(plotWidth, xInPlot)); + const xPos = plotLeftInOverlay + xInPlot; + let yInPlot = u.valToPos(value, "y", false); + yInPlot = Math.max(0, Math.min(plotHeight, yInPlot)); + + const lineColor = color || "rgba(0, 0, 0, 0.5)"; + const pointColor = color || "rgba(0, 0, 0, 0.8)"; + const half = MARKER_HIT_WIDTH / 2; + + const wrapperTop = plotTopInOverlay - LABEL_TOP_OFFSET; + const plotStartInWrapper = LABEL_TOP_OFFSET; + + const wrapper = document.createElement("div"); + wrapper.style.position = "absolute"; + wrapper.style.left = `${xPos - half}px`; + wrapper.style.top = `${wrapperTop}px`; + wrapper.style.width = `${MARKER_HIT_WIDTH}px`; + wrapper.style.height = `${plotHeight + 50 + plotStartInWrapper}px`; + wrapper.style.cursor = "pointer"; + wrapper.style.touchAction = "manipulation"; + wrapper.style.zIndex = "10"; + wrapper.className = "marker-wrapper"; + + const line = document.createElement("div"); + line.style.position = "absolute"; + line.style.left = `${half - 1}px`; + line.style.top = `${plotStartInWrapper}px`; + line.style.height = `${plotHeight}px`; + line.style.width = "2px"; + line.style.background = lineColor; + line.style.pointerEvents = "none"; + line.title = name; + line.className = "vertical-marker"; + + const point = document.createElement("div"); + point.style.position = "absolute"; + point.style.left = `${half}px`; + point.style.top = `${plotStartInWrapper + yInPlot}px`; + point.style.width = "8px"; + point.style.height = "8px"; + point.style.borderRadius = "50%"; + point.style.background = pointColor; + point.style.transform = "translate(-50%, -50%)"; + point.style.border = "2px solid white"; + point.style.pointerEvents = "none"; + point.title = name; + point.className = "marker-point"; + + const label = document.createElement("div"); + label.textContent = name; + label.title = name; + label.style.position = "absolute"; + label.style.left = `${half}px`; + label.style.top = "6px"; + label.style.transform = "translateX(-50%)"; + label.style.color = "rgb(30 41 59)"; + label.style.padding = "4px 8px"; + label.style.fontSize = "12px"; + label.style.fontWeight = "600"; + label.style.whiteSpace = "nowrap"; + label.style.maxWidth = "160px"; + label.style.overflow = "hidden"; + label.style.textOverflow = "ellipsis"; + label.style.background = "rgba(255, 255, 255, 1)"; + label.style.borderRadius = "6px"; + label.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)"; + label.style.border = "1px solid rgba(0,0,0,0.2)"; + label.style.pointerEvents = "none"; + label.style.transition = "box-shadow 0.15s ease, border-color 0.15s ease"; + label.style.zIndex = "1"; + label.className = "marker-label"; + + const highlight = () => applyLabelHighlight(label, true); + const unhighlight = () => applyLabelHighlight(label, false); + + wrapper.addEventListener("mouseenter", highlight); + wrapper.addEventListener("mouseleave", unhighlight); + wrapper.addEventListener("click", () => highlight()); + + // Long-press: hold 5s on marker to delete it (timer on pointerdown, clear on up/leave/cancel) + let longPressTimer: ReturnType | null = null; + const clearLongPressTimer = () => { + if (longPressTimer !== null) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + }; + wrapper.addEventListener("pointerdown", () => { + if (!onLongPress) return; + clearLongPressTimer(); + longPressTimer = setTimeout(() => { + longPressTimer = null; + onLongPress(); // removes this marker + }, LONG_PRESS_DELETE_MS); + }); + wrapper.addEventListener("pointerup", clearLongPressTimer); + wrapper.addEventListener("pointerleave", clearLongPressTimer); + wrapper.addEventListener("pointercancel", clearLongPressTimer); + + wrapper.appendChild(line); + wrapper.appendChild(point); + wrapper.appendChild(label); + return wrapper; +} + +function GraphWithMarkerControlsContent({ + syncHook, + newData, + config, + unit, + renderValue, + graphId, + currentTimeSeries, + machineId: providedMachineId, + markers: providedMarkers, + graphWrapperRef, + uplotRefOut, +}: GraphWithMarkerControlsProps & { + graphWrapperRef: React.RefObject; + uplotRefOut: React.MutableRefObject; +}) { + 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]); + + useEffect(() => { + if (currentTimeSeries?.current?.timestamp) { + setCurrentTimestamp(currentTimeSeries.current.timestamp); + } + }, [currentTimeSeries?.current?.timestamp, setCurrentTimestamp]); + + // 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); + + // 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]); + + // Marker Drawing Effect: use uPlot instance for exact valToPos so point sits on the curve + useEffect(() => { + const u = uplotRefOut.current; + if (!u?.root?.parentElement) return; + + u.syncRect(); + + const overlayContainer = u.root.parentElement; + const overlayParent = overlayContainer.parentElement; + + const previous = { + overflow: overlayContainer.style.overflow, + position: overlayContainer.style.position, + parentOverflow: overlayParent?.style.overflow ?? "", + }; + setOverlayOverflow(overlayContainer, overlayParent, true, previous); + + const overlayRect = overlayContainer.getBoundingClientRect(); + + const wrappers: HTMLDivElement[] = markers.map( + ({ timestamp, name, value, color }) => { + 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; + } + } + if (markerValue === undefined) { + const yScale = u.scales.y; + markerValue = + yScale?.min != null && yScale?.max != null + ? (yScale.min + yScale.max) / 2 + : 0; + } + + // Pass remove callback so long-press (5s) on this marker deletes it + return createMarkerElement( + u, + overlayRect, + timestamp, + name, + markerValue, + color, + () => markerManager.removeMarker(timestamp), + ); + }, + ); + + overlayContainer + .querySelectorAll(".marker-wrapper") + .forEach((el) => el.remove()); + + wrappers.forEach((wrapper) => overlayContainer.appendChild(wrapper)); + + const onOverlayClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest(".marker-wrapper")) { + unhighlightAllLabels(overlayContainer); + } + }; + overlayContainer.addEventListener("click", onOverlayClick, true); + + return () => { + overlayContainer.removeEventListener("click", onOverlayClick, true); + setOverlayOverflow(overlayContainer, overlayParent, false, previous); + }; + }, [markers, currentTimeSeries, timeTick, uplotRefOut, graphWrapperRef]); + + // Use original config without adding marker lines (markers are overlay elements) + const finalConfig = config; + + return ( +
+
+ +
+
+ ); +} + +export function GraphWithMarkerControls(props: GraphWithMarkerControlsProps) { + const graphWrapperRef = useRef(null); + const uplotRefOut = useRef(null); + + 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..a35f10152 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 { useMarkerContext } from "./MarkerContext"; export function SyncedBigGraph({ syncGraph: externalSyncGraph, @@ -50,21 +53,67 @@ export function SyncedGraphControls({ ); } -export function SyncedFloatingControlPanel({ +function SyncedFloatingControlPanelInner({ controlProps, timeWindowOptions, + machineId: machineIdProp, ...props }: { controlProps?: ReturnType["controlProps"]; timeWindowOptions?: TimeWindowOption[]; + machineId?: string; }) { const defaultSync = useGraphSync(); const finalProps = controlProps || defaultSync.controlProps; + const { machineId: machineIdContext, currentTimestamp } = useMarkerContext(); + + // Prefer explicit prop so panel and graphs always use the same store + const detectedMachineId = machineIdProp ?? machineIdContext ?? "default"; + const { addMarker, markers } = 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} + /> + m.name)} + /> + + ); +} + +export function SyncedFloatingControlPanel({ + controlProps, + timeWindowOptions, + machineId, + ...props +}: { + controlProps?: ReturnType["controlProps"]; + timeWindowOptions?: TimeWindowOption[]; + machineId?: string; +}) { + return ( + ); diff --git a/electron/src/components/graph/createChart.ts b/electron/src/components/graph/createChart.ts index c13df4043..5711787ff 100644 --- a/electron/src/components/graph/createChart.ts +++ b/electron/src/components/graph/createChart.ts @@ -11,6 +11,7 @@ import { normalizeDataSeries } from "./animation"; export function createChart({ containerRef, uplotRef, + uplotRefOut, newData, config, colors, @@ -182,6 +183,9 @@ export function createChart({ // Always destroy existing chart before creating new one if (uplotRef.current) { + if (uplotRefOut?.current != null) { + uplotRefOut.current = null; + } uplotRef.current.destroy(); uplotRef.current = null; } @@ -483,6 +487,10 @@ export function createChart({ containerRef.current, ); + if (uplotRefOut) { + uplotRefOut.current = uplotRef.current; + } + // Create handler callbacks const handlerCallbacks: HandlerCallbacks = { updateYAxisScale, 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/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 beff72807..c4412d4ad 100644 --- a/electron/src/components/graph/types.ts +++ b/electron/src/components/graph/types.ts @@ -1,3 +1,4 @@ +import type uPlot from "uplot"; import { IconName } from "@/components/Icon"; import { Unit } from "@/control/units"; import { TimeSeries } from "@/lib/timeseries"; @@ -25,13 +26,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 = { @@ -71,6 +73,8 @@ export type BigGraphProps = { config: GraphConfig; graphId: string; syncGraph?: PropGraphSync; + /** Optional ref to receive the uPlot instance when chart is created (e.g. for marker positioning) */ + uplotRefOut?: React.MutableRefObject; }; export type TimeWindowOption = { @@ -85,6 +89,7 @@ export type ControlProps = { onSwitchToLive: () => void; onSwitchToHistorical: () => void; onExport?: () => void; + onAddMarker?: () => void; timeWindowOptions?: TimeWindowOption[]; showFromTimestamp?: number | null; onShowFromChange?: (timestamp: number | null) => void; @@ -107,6 +112,8 @@ export interface HistoricalModeHandlers { export interface CreateChartParams { containerRef: React.RefObject; uplotRef: React.RefObject; + /** Optional ref to expose uPlot instance (e.g. for marker overlay positioning) */ + uplotRefOut?: React.MutableRefObject; newData: BigGraphProps["newData"]; config: BigGraphProps["config"]; colors: { diff --git a/electron/src/components/graph/useBigGraphEffects.ts b/electron/src/components/graph/useBigGraphEffects.ts index 730bf1539..a7e7fa8cd 100644 --- a/electron/src/components/graph/useBigGraphEffects.ts +++ b/electron/src/components/graph/useBigGraphEffects.ts @@ -1,5 +1,11 @@ /* eslint-disable react-compiler/react-compiler */ -import { useEffect, RefObject, useRef, useState } from "react"; +import { + useEffect, + MutableRefObject, + RefObject, + useRef, + useState, +} from "react"; import uPlot from "uplot"; import { seriesToUPlotData } from "@/lib/timeseries"; import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types"; @@ -13,6 +19,7 @@ interface UseBigGraphEffectsProps { // Refs containerRef: RefObject; uplotRef: RefObject; + uplotRefOut?: MutableRefObject; startTimeRef: RefObject; manualScaleRef: RefObject<{ x: { min: number; max: number }; @@ -71,6 +78,7 @@ interface UseBigGraphEffectsProps { export function useBigGraphEffects({ containerRef, uplotRef, + uplotRefOut, startTimeRef, manualScaleRef, lastProcessedCountRef, @@ -257,6 +265,7 @@ export function useBigGraphEffects({ const cleanup = createChart({ containerRef, uplotRef, + uplotRefOut, newData, config, colors, @@ -291,6 +300,7 @@ export function useBigGraphEffects({ cleanup?.(); uplotRef.current?.destroy(); uplotRef.current = null; + if (uplotRefOut) uplotRefOut.current = null; stopAnimations(animationRefs); setIsChartCreated(false); chartCreatedRef.current = false; diff --git a/electron/src/components/graph/useMarkerManager.ts b/electron/src/components/graph/useMarkerManager.ts new file mode 100644 index 000000000..b2444d495 --- /dev/null +++ b/electron/src/components/graph/useMarkerManager.ts @@ -0,0 +1,142 @@ +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 (no time-based deletion; keep last 200 only) + const loadMarkers = useCallback((): Marker[] => { + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const allMarkers: Marker[] = JSON.parse(stored); + const maxMarkers = 200; + const limited = + allMarkers.length > maxMarkers + ? allMarkers.slice(-maxMarkers) + : allMarkers; + if (limited.length !== allMarkers.length) { + localStorage.setItem(storageKey, JSON.stringify(limited)); + } + return limited; + } + } 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 { + 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) => { + const updated = prev.filter((marker) => marker.timestamp !== timestamp); + // Persist and notify other components (e.g. dialog, other graphs) + try { + localStorage.setItem(storageKey, JSON.stringify(updated)); + window.dispatchEvent( + new CustomEvent(MARKER_UPDATE_EVENT, { + detail: { machineId, markers: updated }, + }), + ); + } catch (error) { + console.warn("Failed to save markers after remove:", error); + } + return updated; + }); + }, + [storageKey, machineId], + ); + + const clearMarkers = useCallback(() => { + setMarkers([]); + }, []); + + return { + markers, + addMarker, + removeMarker, + clearMarkers, + }; +} diff --git a/electron/src/components/ui/dialog.tsx b/electron/src/components/ui/dialog.tsx index fb20110f8..c10f24b93 100644 --- a/electron/src/components/ui/dialog.tsx +++ b/electron/src/components/ui/dialog.tsx @@ -36,7 +36,7 @@ function DialogOverlay({ -
- value.toFixed(2)} - graphId="pressure-graph" - /> + +
+ value.toFixed(2)} + graphId="pressure-graph" + currentTimeSeries={pressure} + machineId="extruder-graphs" + /> - value.toFixed(1)} - graphId="combined-temperatures" - /> + value.toFixed(1)} + graphId="combined-temperatures" + currentTimeSeries={nozzleTemperature} + machineId="extruder-graphs" + /> - value.toFixed(1)} - graphId="combined-power" - /> + value.toFixed(1)} + graphId="combined-power" + currentTimeSeries={combinedPower} + machineId="extruder-graphs" + /> - value.toFixed(2)} - graphId="motor-current" - /> + value.toFixed(2)} + graphId="motor-current" + currentTimeSeries={motorCurrent} + machineId="extruder-graphs" + /> - value.toFixed(0)} - graphId="rpm-graph" - /> -
+ value.toFixed(0)} + graphId="rpm-graph" + currentTimeSeries={motorScrewRpm} + machineId="extruder-graphs" + /> +
- + + ); } diff --git a/electron/src/machines/extruder/extruder3/Extruder3Graph.tsx b/electron/src/machines/extruder/extruder3/Extruder3Graph.tsx index 6ffa8cad5..01247b3b8 100644 --- a/electron/src/machines/extruder/extruder3/Extruder3Graph.tsx +++ b/electron/src/machines/extruder/extruder3/Extruder3Graph.tsx @@ -1,10 +1,11 @@ import { Page } from "@/components/Page"; import { - AutoSyncedBigGraph, + MarkerProvider, SyncedFloatingControlPanel, useGraphSync, type GraphConfig, } from "@/components/graph"; +import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls"; import React from "react"; import { useExtruder3 } from "./useExtruder"; @@ -241,85 +242,100 @@ export function Extruder3GraphsPage() { return ( -
- value.toFixed(2)} - graphId="pressure-graph" - /> + +
+ value.toFixed(2)} + graphId="pressure-graph" + currentTimeSeries={pressure} + machineId="extruder-graphs" + /> - value.toFixed(1)} - graphId="combined-temperatures" - /> + value.toFixed(1)} + graphId="combined-temperatures" + currentTimeSeries={nozzleTemperature ?? null} + machineId="extruder-graphs" + /> - value.toFixed(1)} - graphId="combined-power" - /> + value.toFixed(1)} + graphId="combined-power" + currentTimeSeries={combinedPower ?? null} + machineId="extruder-graphs" + /> - value.toFixed(2)} - graphId="motor-current" - /> + value.toFixed(2)} + graphId="motor-current" + currentTimeSeries={motorCurrent} + machineId="extruder-graphs" + /> - value.toFixed(0)} - graphId="rpm-graph" - /> -
+ value.toFixed(0)} + graphId="rpm-graph" + currentTimeSeries={motorScrewRpm} + machineId="extruder-graphs" + /> +
- + +
); } diff --git a/electron/src/machines/mock/mock1/Mock1Graph.tsx b/electron/src/machines/mock/mock1/Mock1Graph.tsx index 5f0805d96..e0e5852ad 100644 --- a/electron/src/machines/mock/mock1/Mock1Graph.tsx +++ b/electron/src/machines/mock/mock1/Mock1Graph.tsx @@ -1,6 +1,7 @@ import { Page } from "@/components/Page"; import { AutoSyncedBigGraph, + MarkerProvider, SyncedFloatingControlPanel, useGraphSync, type GraphConfig, @@ -8,6 +9,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(); @@ -97,45 +100,58 @@ export function Mock1GraphPage() { return ( -
- value.toFixed(3)} - graphId="single-graph1" - /> - value.toFixed(3)} - graphId="combined-graph" - /> - value.toFixed(3)} - graphId="single-graph2" - /> - value.toFixed(3)} - graphId="single-graph" - /> -
+ +
+ value.toFixed(3)} + graphId="single-graph1" + currentTimeSeries={sineWaveSum} + machineId="mock-graphs" + /> + value.toFixed(3)} + graphId="combined-graph" + currentTimeSeries={sineWaveSum} + machineId="mock-graphs" + /> + value.toFixed(3)} + graphId="single-graph2" + currentTimeSeries={sineWaveSum} + machineId="mock-graphs" + /> + value.toFixed(3)} + graphId="single-graph" + currentTimeSeries={sineWaveSum} + machineId="mock-graphs" + /> +
- + +
); } diff --git a/electron/src/machines/winder/winder2/Winder2Graphs.tsx b/electron/src/machines/winder/winder2/Winder2Graphs.tsx index ab3adb066..33296c5bf 100644 --- a/electron/src/machines/winder/winder2/Winder2Graphs.tsx +++ b/electron/src/machines/winder/winder2/Winder2Graphs.tsx @@ -1,6 +1,7 @@ import { Page } from "@/components/Page"; import { AutoSyncedBigGraph, + MarkerProvider, SyncedFloatingControlPanel, useGraphSync, type GraphConfig, @@ -11,6 +12,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 { @@ -26,49 +28,54 @@ export function Winder2GraphsPage() { return ( -
-
- roundToDecimals(value, 0)} - /> + +
+
+ roundToDecimals(value, 0)} + /> - roundDegreesToDecimals(value, 0)} - /> + roundDegreesToDecimals(value, 0)} + /> - roundToDecimals(value, 0)} - /> + roundToDecimals(value, 0)} + /> - roundToDecimals(value, 1)} - /> + roundToDecimals(value, 1)} + /> - roundToDecimals(value, 2)} - /> + roundToDecimals(value, 2)} + /> +
-
- + + ); } @@ -96,7 +103,7 @@ export function SpoolRpmGraph({ }; return ( - ); } @@ -155,7 +164,7 @@ export function TraversePositionGraph({ }; return ( - ); } @@ -191,7 +202,7 @@ export function TensionArmAngleGraph({ }; return ( - ); } @@ -225,8 +238,9 @@ export function SpoolProgressGraph({ exportFilename: "spool_progress", }; + // NOTE: Assuming this graph starts at 0, and the max is the total capacity. return ( - ); } @@ -274,7 +290,7 @@ export function PullerSpeedGraph({ }; return ( - ); }