diff --git a/electron/src/control/MinMaxValue.tsx b/electron/src/control/MinMaxValue.tsx new file mode 100644 index 00000000..bb543945 --- /dev/null +++ b/electron/src/control/MinMaxValue.tsx @@ -0,0 +1,173 @@ +import { Icon, IconName } from "@/components/Icon"; +import React, { useState, useMemo } from "react"; +import { + getUnitIcon, + renderValueToReactNode, + renderUnitSymbol, + Unit, +} from "./units"; +import { Label } from "./Label"; +import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries"; + +type TimeframeOption = { + label: string; + value: number; // in milliseconds +}; + +export const TIMEFRAME_OPTIONS: TimeframeOption[] = [ + { label: "1 min", value: 1 * 60 * 1000 }, + { label: "5 min", value: 5 * 60 * 1000 }, + { label: "10 min", value: 10 * 60 * 1000 }, + { label: "30 min", value: 30 * 60 * 1000 }, + { label: "1 hour", value: 60 * 60 * 1000 }, +]; + +type Props = { + label: string; + unit?: Unit; + timeseries: TimeSeries; + icon?: IconName; + renderValue?: (value: number) => string; + defaultTimeframe?: number; // in milliseconds, defaults to 5 minutes + /** Optional externally-controlled timeframe (ms). When provided, component becomes controlled. */ + timeframe?: number; + /** Optional callback when timeframe is changed via this component's selector */ + onTimeframeChange?: (t: number) => void; + /** When true, hide the built-in timeframe selector (useful when sharing a selector outside) */ + hideSelector?: boolean; +}; + +export function MinMaxValue({ + unit, + timeseries, + icon, + label, + renderValue, + defaultTimeframe = 5 * 60 * 1000, // 5 minutes default + timeframe, + onTimeframeChange, + hideSelector = false, +}: Props) { + const [internalTimeframe, setInternalTimeframe] = useState(defaultTimeframe); + const selectedTimeframe = timeframe ?? internalTimeframe; + + // Calculate min/max from the timeseries using the selected timeframe + const { min, max, hasData } = useMemo(() => { + // Use short series for timeframes <= 5 seconds, otherwise use long series + // Short series typically has ~5 second retention with high resolution + // Long series typically has ~1 hour retention with lower resolution + const series = + selectedTimeframe <= 5000 ? timeseries.short : timeseries.long; + + // Manually calculate min/max to exclude zero values (which might be invalid readings) + const cutoffTime = series.lastTimestamp - selectedTimeframe; + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + let hasValidData = false; + let dataCount = 0; + + const { values: raw, index, size, validCount } = series; + + const startIdx = validCount < size ? 0 : index; + + for (let i = 0; i < validCount; i++) { + const idx = (startIdx + i) % size; + const val = raw[idx]; + + if ( + val && + val.timestamp > 0 && + val.timestamp >= cutoffTime && + val.value > 0 + ) { + hasValidData = true; + dataCount++; + if (val.value < min) min = val.value; + if (val.value > max) max = val.value; + } + } + + if (!hasValidData) { + return { min: 0, max: 0, hasData: false }; + } + + return { min, max, hasData: true }; + }, [ + timeseries.short, + timeseries.long, + timeseries.current, + selectedTimeframe, + ]); + + return ( +
+ + + {/* Timeframe Selector (hidden when sharing selector externally) */} + {!hideSelector && ( +
+ {TIMEFRAME_OPTIONS.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/electron/src/machines/laser/laser1/Laser1ControlPage.tsx b/electron/src/machines/laser/laser1/Laser1ControlPage.tsx index f9a8a319..e38e08a4 100644 --- a/electron/src/machines/laser/laser1/Laser1ControlPage.tsx +++ b/electron/src/machines/laser/laser1/Laser1ControlPage.tsx @@ -3,6 +3,7 @@ import { Page } from "@/components/Page"; import React from "react"; import { ControlGrid } from "@/control/ControlGrid"; import { TimeSeriesValueNumeric } from "@/control/TimeSeriesValue"; +import { MinMaxValue, TIMEFRAME_OPTIONS } from "@/control/MinMaxValue"; import { EditValue } from "@/control/EditValue"; import { Label } from "@/control/Label"; @@ -28,6 +29,12 @@ export function Laser1ControlPage() { const targetDiameter = state?.laser_state?.target_diameter ?? 0; const lowerTolerance = state?.laser_state?.lower_tolerance ?? 0; const higherTolerance = state?.laser_state?.higher_tolerance ?? 0; + + // Detect if this is a 2-axis laser + const isTwoAxis = !!x_diameter?.current || !!y_diameter?.current; + // Shared timeframe state (default 5 minutes) + const [timeframe, setTimeframe] = React.useState(5 * 60 * 1000); + return ( @@ -77,6 +84,57 @@ export function Laser1ControlPage() { /> )} +
+ {isTwoAxis ? ( + // For 2-axis lasers: show diameter and roundness min/max side by side +
+ value.toFixed(3)} + timeframe={timeframe} + hideSelector + /> + {roundness?.current && ( + value.toFixed(3)} + timeframe={timeframe} + hideSelector + /> + )} +
+ ) : ( + // For single-axis lasers: show only diameter min/max with shared selector + value.toFixed(3)} + timeframe={timeframe} + hideSelector + /> + )} + + {/* Shared timeframe selector for diameter/roundness */} +
+ {TIMEFRAME_OPTIONS.map((option) => ( + + ))} +
+