Compare commits

...
20 Commits
Author SHA1 Message Date
EinsPommes ea4e0f78c6 Refactor GlobalHeatingFaultToastManager to improve code readability by consolidating Icon component formatting, enhancing maintainability and consistency in the UI component structure. 2026-02-28 19:03:27 +01:00
EinsPommes c85a4d3ebe Refactor GraphWithMarkerControls to improve readability by formatting interpolation logic and clean up unnecessary whitespace in useMarkerManager and markerStore. This enhances code maintainability and consistency across components. 2026-02-28 19:00:06 +01:00
EinsPommes 635c08cfc5 Refactor useMarkerManager to utilize a stable empty array for default markers, preventing infinite re-renders in useSyncExternalStore. This change enhances marker management consistency across graph instances. 2026-02-28 18:58:58 +01:00
EinsPommes 1ecdf33d29 Enhance marker management by adding current value tracking and improving marker interpolation logic. Update context to include current value and adjust marker addition to utilize the new value parameter. 2026-02-28 18:58:58 +01:00
EinsPommes d88a87c8a2 fix(graph): ensure proper null handling for chart destruction and correct indentation in GraphControls 2026-02-28 18:58:58 +01:00
EinsPommes 94950c3c38 refactor(graph): streamline marker handling and enhance component structure 2026-02-28 18:58:58 +01:00
EinsPommes ebc6cb9c40 feat(graph): add duplicate marker validation and long-press delete 2026-02-28 18:58:58 +01:00
EinsPommes 921f0fa445 Refactor graph components to improve marker handling and UI consistency 2026-02-28 18:58:30 +01:00
EinsPommes 2ab668b2f1 feat(graph): add marker functionality and improve marker rendering 2026-02-28 18:58:06 +01:00
EinsPommes 3a9801b97c feat(graph): persist markers and render marker points 2026-02-28 18:58:06 +01:00
EinsPommes 612b6ecb25 Fix chart y-axis scaling for visible data; improve container selection 2026-02-28 18:58:06 +01:00
qitech 677f51ae33 Fix: Applied Prettier formatting fixes 2026-02-28 18:58:06 +01:00
qitech bf28b41afd clean up redundant code and comments 2026-02-28 18:58:06 +01:00
qitech 723072c5bc refactor code 2026-02-28 18:58:06 +01:00
qitech 3813ce70bf first approach for time stamp in excel 2026-02-28 18:58:06 +01:00
qitech b523b3de21 time stamp for extruder 2026-02-28 18:58:06 +01:00
qitech 09b1eb9433 time stamp for winder 2026-02-28 18:58:06 +01:00
qitech c7feef6bcd timestamp for mock graphs 2026-02-28 18:58:06 +01:00
qitech 84d2766ca9 success to adjust with the time 2026-02-28 18:58:06 +01:00
qitech b4d2974f45 button to add new marker 2026-02-28 18:58:06 +01:00
19 changed files with 1511 additions and 258 deletions

No files matched your search

@@ -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<number | null>(
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex flex-row items-center gap-2">
<Icon name="lu:Bookmark" />
Add Marker
</DialogTitle>
<DialogDescription>
Create a marker for all graphs of this machine at the current time.
</DialogDescription>
</DialogHeader>
<Separator />
<div className="flex flex-col gap-4">
{/* Name Input */}
<div className="flex flex-col gap-2">
<Label htmlFor="marker-name">Marker Name</Label>
<Input
id="marker-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setDuplicateNameError(false);
}}
placeholder="Enter marker name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAdd();
}
}}
/>
{duplicateNameError && (
<p className="text-destructive text-sm">
A marker with this name already exists.
</p>
)}
</div>
{/* Time Input (optional) */}
<div className="flex flex-col gap-2">
<Label>Time (optional)</Label>
<TimeInput
timestamp={selectedTimestamp}
onTimeChange={setSelectedTimestamp}
onClear={() =>
setSelectedTimestamp(currentTimestamp ?? Date.now())
}
/>
<p className="text-muted-foreground text-xs">
Leave empty to use current time
</p>
</div>
{/* Color Input */}
<div className="flex flex-col gap-2">
<Label htmlFor="marker-color">Color</Label>
<div className="flex items-center gap-3">
<input
id="marker-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="border-input h-9 w-20 cursor-pointer rounded-md border"
/>
<Input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#000000"
/>
</div>
</div>
</div>
<Separator />
<div className="flex flex-row gap-4">
<TouchButton
variant="outline"
icon="lu:X"
className="h-21 flex-1"
onClick={handleCancel}
>
Abort
</TouchButton>
<TouchButton
className="h-21 flex-1 flex-shrink-0"
onClick={handleAdd}
icon="lu:Bookmark"
disabled={!name.trim()}
>
Add Marker
</TouchButton>
</div>
</DialogContent>
</Dialog>
);
}
+6 -4
View File
@@ -93,6 +93,7 @@ export function BigGraph({
config,
graphId,
syncGraph,
uplotRefOut,
onRegisterForExport,
onUnregisterFromExport,
}: BigGraphProps & {
@@ -457,6 +458,7 @@ export function BigGraph({
useBigGraphEffects({
containerRef,
uplotRef,
uplotRefOut,
startTimeRef,
manualScaleRef,
lastProcessedCountRef,
@@ -495,8 +497,8 @@ export function BigGraph({
return (
<div className="h-[50vh] w-full">
<div className="flex h-full w-full flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow">
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
<div className="mt-1 flex items-center gap-4">
<div className="flex shrink-0 items-center justify-between gap-4 pt-4 pr-5 pb-6 pl-6">
<div className="mt-1 flex min-w-0 flex-1 items-center gap-4">
<Icon
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
className="size-6 text-gray-600"
@@ -507,9 +509,9 @@ export function BigGraph({
</h2>
</div>
<div className="flex items-center gap-4">
<div className="flex shrink-0 items-center gap-4">
{normalizedSeries.length === 1 && (
<ControlCard className="rounded-md px-4 py-3">
<ControlCard className="rounded-lg border-gray-200/80 bg-gray-50/80 px-4 py-3 shadow-sm">
<div className="flex items-center gap-2 text-base text-gray-600">
<span className="font-mono leading-none font-bold text-gray-900">
{formatDisplayValue(displayValue, renderValue)}
+32 -10
View File
@@ -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
</TouchButton>
{onExport && (
{(onExport || onAddMarker) && (
<>
<div className="mx-2 h-8 w-px bg-gray-200"></div>
<TouchButton
onClick={onExport}
variant="outline"
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
>
Export
</TouchButton>
{onAddMarker && (
<TouchButton
onClick={onAddMarker}
variant="outline"
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
>
Add Marker
</TouchButton>
)}
{onExport && (
<TouchButton
onClick={onExport}
variant="outline"
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
>
Export
</TouchButton>
)}
</>
)}
</div>
@@ -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
</TouchButton>
{isExpanded && onExport && (
{isExpanded && (onExport || onAddMarker) && (
<div className="h-8 w-px bg-gray-200"></div>
)}
{onExport && (
{isExpanded && onAddMarker && (
<TouchButton
onClick={onAddMarker}
variant="outline"
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
>
Add Marker
</TouchButton>
)}
{isExpanded && onExport && (
<TouchButton
onClick={onExport}
variant="outline"
@@ -0,0 +1,393 @@
import React, { useState, useRef, useEffect } from "react";
import type uPlot from "uplot";
import {
AutoSyncedBigGraph,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
import { Unit } from "@/control/units";
import { useMarkerManager } from "./useMarkerManager";
import { useMarkerContext } from "./MarkerContext";
type TimeSeriesData = {
newData: TimeSeries | null;
title?: string;
color?: string;
lines?: any[];
};
type GraphWithMarkerControlsProps = {
syncHook: ReturnType<typeof useGraphSync>;
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<HTMLDivElement>(".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<typeof setTimeout> | 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<HTMLDivElement | null>;
uplotRefOut: React.MutableRefObject<uPlot | null>;
}) {
const { setMachineId, setCurrentTimestamp, setCurrentValue } =
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, current timestamp and value for marker creation
useEffect(() => {
setMachineId(machineId);
}, [machineId, setMachineId]);
useEffect(() => {
const curr = currentTimeSeries?.current;
if (curr?.timestamp != null) {
setCurrentTimestamp(curr.timestamp);
setCurrentValue(curr.value);
} else {
setCurrentValue(null);
}
}, [
currentTimeSeries?.current?.timestamp,
currentTimeSeries?.current?.value,
setCurrentTimestamp,
setCurrentValue,
]);
// 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();
// Only show markers within the visible time window (saved markers stay in store for export)
const xMin = u.scales.x?.min ?? -Infinity;
const xMax = u.scales.x?.max ?? Infinity;
const visibleMarkers = markers.filter(
(m) => m.timestamp >= xMin && m.timestamp <= xMax,
);
const wrappers: HTMLDivElement[] = visibleMarkers.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) {
// Use linear interpolation between surrounding points for stable positioning
// (avoids "closest" flipping between adjacent points which causes jumping)
const sorted = [...validValues].sort(
(a, b) => a.timestamp - b.timestamp,
);
const after = sorted.find((p) => p.timestamp >= timestamp);
const before = [...sorted]
.reverse()
.find((p) => p.timestamp <= timestamp);
if (after && before) {
if (after.timestamp === before.timestamp) {
markerValue = after.value;
} else {
const t =
(timestamp - before.timestamp) /
(after.timestamp - before.timestamp);
markerValue = before.value + t * (after.value - before.value);
}
} else if (after) {
markerValue = after.value;
} else if (before) {
markerValue = before.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 (
<div className="flex flex-col gap-2">
<div ref={graphWrapperRef} className="relative">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={newData}
config={finalConfig}
unit={unit}
renderValue={renderValue}
graphId={graphId}
uplotRefOut={uplotRefOut}
/>
</div>
</div>
);
}
export function GraphWithMarkerControls(props: GraphWithMarkerControlsProps) {
const graphWrapperRef = useRef<HTMLDivElement | null>(null);
const uplotRefOut = useRef<uPlot | null>(null);
return (
<GraphWithMarkerControlsContent
{...props}
graphWrapperRef={graphWrapperRef}
uplotRefOut={uplotRefOut}
/>
);
}
@@ -0,0 +1,49 @@
import React, { createContext, useContext, useState } from "react";
type MarkerContextType = {
machineId: string | null;
setMachineId: (id: string) => void;
currentTimestamp: number | null;
setCurrentTimestamp: (timestamp: number) => void;
currentValue: number | null;
setCurrentValue: (value: number | null) => void;
};
const MarkerContext = createContext<MarkerContextType | null>(null);
export function MarkerProvider({ children }: { children: React.ReactNode }) {
const [machineId, setMachineId] = useState<string | null>(null);
const [currentTimestamp, setCurrentTimestamp] = useState<number | null>(null);
const [currentValue, setCurrentValue] = useState<number | null>(null);
return (
<MarkerContext.Provider
value={{
machineId,
setMachineId,
currentTimestamp,
setCurrentTimestamp,
currentValue,
setCurrentValue,
}}
>
{children}
</MarkerContext.Provider>
);
}
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: () => {},
currentValue: null,
setCurrentValue: () => {},
};
}
return context;
}
@@ -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,71 @@ export function SyncedGraphControls({
);
}
export function SyncedFloatingControlPanel({
function SyncedFloatingControlPanelInner({
controlProps,
timeWindowOptions,
machineId: machineIdProp,
...props
}: {
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
timeWindowOptions?: TimeWindowOption[];
machineId?: string;
}) {
const defaultSync = useGraphSync();
const finalProps = controlProps || defaultSync.controlProps;
const {
machineId: machineIdContext,
currentTimestamp,
currentValue,
} = 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, currentValue ?? undefined);
};
return (
<FloatingControlPanel
{...finalProps}
<>
<FloatingControlPanel
{...finalProps}
timeWindowOptions={timeWindowOptions}
onAddMarker={() => setIsMarkerDialogOpen(true)}
{...props}
/>
<AddMarkerDialog
open={isMarkerDialogOpen}
onOpenChange={setIsMarkerDialogOpen}
onAddMarker={handleAddMarker}
currentTimestamp={markerTimestamp}
existingNames={markers.map((m) => m.name)}
/>
</>
);
}
export function SyncedFloatingControlPanel({
controlProps,
timeWindowOptions,
machineId,
...props
}: {
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
timeWindowOptions?: TimeWindowOption[];
machineId?: string;
}) {
return (
<SyncedFloatingControlPanelInner
controlProps={controlProps}
timeWindowOptions={timeWindowOptions}
machineId={machineId}
{...props}
/>
);
@@ -15,6 +15,7 @@ import { normalizeDataSeries } from "./animation";
export function createChart({
containerRef,
uplotRef,
uplotRefOut,
newData,
config,
colors,
@@ -197,6 +198,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;
}
@@ -498,6 +502,10 @@ export function createChart({
containerRef.current,
);
if (uplotRefOut) {
uplotRefOut.current = uplotRef.current;
}
// Create handler callbacks
const handlerCallbacks: HandlerCallbacks = {
updateYAxisScale,
@@ -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,
+3
View File
@@ -10,6 +10,9 @@ export {
AutoSyncedBigGraph,
} from "./SyncedComponents";
// Context
export { MarkerProvider } from "./MarkerContext";
// Hooks
export { useGraphSync } from "./useGraphSync";
+8 -1
View File
@@ -1,3 +1,4 @@
import type uPlot from "uplot";
import { IconName } from "@/components/Icon";
import { Unit } from "@/control/units";
import { TimeSeries } from "@/lib/timeseries";
@@ -28,7 +29,7 @@ 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;
targetSeries?: TimeSeries;
color: string;
@@ -36,6 +37,7 @@ export type GraphLine = {
width?: number;
dash?: number[];
show?: boolean;
markerTimestamp?: number;
};
export type GraphConfig = {
@@ -75,6 +77,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<uPlot | null>;
};
export type TimeWindowOption = {
@@ -89,6 +93,7 @@ export type ControlProps = {
onSwitchToLive: () => void;
onSwitchToHistorical: (origin: SwitchOrigin) => void;
onExport?: () => void;
onAddMarker?: () => void;
timeWindowOptions?: TimeWindowOption[];
showFromTimestamp?: number | null;
onShowFromChange?: (timestamp: number | null) => void;
@@ -111,6 +116,8 @@ export interface HistoricalModeHandlers {
export interface CreateChartParams {
containerRef: React.RefObject<HTMLDivElement | null>;
uplotRef: React.RefObject<uPlot | null>;
/** Optional ref to expose uPlot instance (e.g. for marker overlay positioning) */
uplotRefOut?: React.MutableRefObject<uPlot | null>;
newData: BigGraphProps["newData"];
config: BigGraphProps["config"];
colors: {
@@ -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<HTMLDivElement | null>;
uplotRef: RefObject<uPlot | null>;
uplotRefOut?: MutableRefObject<uPlot | null>;
startTimeRef: RefObject<number | null>;
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;
@@ -0,0 +1,41 @@
import { useCallback } from "react";
import { useMarkerStore, type Marker } from "@/stores/markerStore";
// Stable empty array to avoid "getSnapshot should be cached" infinite loop
const EMPTY_MARKERS: Marker[] = [];
export function useMarkerManager(machineId: string) {
const markers = useMarkerStore((state) => {
const list = state.markersByMachine[machineId];
return list ?? EMPTY_MARKERS;
});
const addMarkerAction = useMarkerStore((state) => state.addMarker);
const removeMarkerAction = useMarkerStore((state) => state.removeMarker);
const clearMarkersAction = useMarkerStore((state) => state.clearMarkers);
const addMarker = useCallback(
(name: string, timestamp: number, color?: string, value?: number) => {
addMarkerAction(machineId, { name, timestamp, color, value });
return { name, timestamp, color, value } as Marker;
},
[machineId, addMarkerAction],
);
const removeMarker = useCallback(
(timestamp: number) => {
removeMarkerAction(machineId, timestamp);
},
[machineId, removeMarkerAction],
);
const clearMarkers = useCallback(() => {
clearMarkersAction(machineId);
}, [machineId, clearMarkersAction]);
return {
markers,
addMarker,
removeMarker,
clearMarkers,
};
}
+2 -2
View File
@@ -36,7 +36,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/80",
className,
)}
{...props}
@@ -55,7 +55,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
@@ -1,12 +1,13 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
MarkerProvider,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import React from "react";
import { useExtruder2 } from "./useExtruder";
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
export function Extruder2GraphsPage() {
const {
@@ -252,87 +253,102 @@ export function Extruder2GraphsPage() {
return (
<Page className="pb-25">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: pressure,
color: "#3b82f6",
lines:
state?.pressure_state.target_bar !== undefined
? [
{
type: "target" as const,
value: state.pressure_state.target_bar,
targetSeries: targetPressure,
color: "#3b82f6",
show: true,
},
]
: [],
}}
config={pressureConfig}
unit="bar"
renderValue={(value) => value.toFixed(2)}
graphId="pressure-graph"
/>
<MarkerProvider>
<div className="flex flex-col gap-4">
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: pressure,
color: "#3b82f6",
lines:
state?.pressure_state.target_bar !== undefined
? [
{
type: "target" as const,
value: state.pressure_state.target_bar,
targetSeries: targetPressure,
color: "#3b82f6",
show: true,
},
]
: [],
}}
config={pressureConfig}
unit="bar"
renderValue={(value) => value.toFixed(2)}
graphId="pressure-graph"
currentTimeSeries={pressure}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
currentTimeSeries={nozzleTemperature}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
currentTimeSeries={combinedPower}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
currentTimeSeries={motorCurrent}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorScrewRpm,
color: "#8b5cf6",
lines:
state?.screw_state.target_rpm !== undefined
? [
{
type: "target" as const,
value: state.screw_state.target_rpm,
targetSeries: targetScrewRpm,
color: "#8b5cf6",
show: true,
},
]
: [],
}}
config={rpmConfig}
unit="rpm"
renderValue={(value) => value.toFixed(0)}
graphId="rpm-graph"
/>
</div>
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: motorScrewRpm,
color: "#8b5cf6",
lines:
state?.screw_state.target_rpm !== undefined
? [
{
type: "target" as const,
value: state.screw_state.target_rpm,
targetSeries: targetScrewRpm,
color: "#8b5cf6",
show: true,
},
]
: [],
}}
config={rpmConfig}
unit="rpm"
renderValue={(value) => value.toFixed(0)}
graphId="rpm-graph"
currentTimeSeries={motorScrewRpm}
machineId="extruder-graphs"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
<SyncedFloatingControlPanel
controlProps={syncHook.controlProps}
machineId="extruder-graphs"
/>
</MarkerProvider>
</Page>
);
}
@@ -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";
@@ -252,87 +253,102 @@ export function Extruder3GraphsPage() {
return (
<Page className="pb-25">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: pressure,
color: "#3b82f6",
lines:
state?.pressure_state.target_bar !== undefined
? [
{
type: "target" as const,
value: state.pressure_state.target_bar,
targetSeries: targetPressure,
color: "#3b82f6",
show: true,
},
]
: [],
}}
config={pressureConfig}
unit="bar"
renderValue={(value) => value.toFixed(2)}
graphId="pressure-graph"
/>
<MarkerProvider>
<div className="flex flex-col gap-4">
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: pressure,
color: "#3b82f6",
lines:
state?.pressure_state.target_bar !== undefined
? [
{
type: "target" as const,
value: state.pressure_state.target_bar,
targetSeries: targetPressure,
color: "#3b82f6",
show: true,
},
]
: [],
}}
config={pressureConfig}
unit="bar"
renderValue={(value) => value.toFixed(2)}
graphId="pressure-graph"
currentTimeSeries={pressure}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
currentTimeSeries={nozzleTemperature ?? null}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
currentTimeSeries={combinedPower ?? null}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
currentTimeSeries={motorCurrent}
machineId="extruder-graphs"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorScrewRpm,
color: "#8b5cf6",
lines:
state?.screw_state.target_rpm !== undefined
? [
{
type: "target" as const,
value: state.screw_state.target_rpm,
targetSeries: targetScrewRpm,
color: "#8b5cf6",
show: true,
},
]
: [],
}}
config={rpmConfig}
unit="rpm"
renderValue={(value) => value.toFixed(0)}
graphId="rpm-graph"
/>
</div>
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData: motorScrewRpm,
color: "#8b5cf6",
lines:
state?.screw_state.target_rpm !== undefined
? [
{
type: "target" as const,
value: state.screw_state.target_rpm,
targetSeries: targetScrewRpm,
color: "#8b5cf6",
show: true,
},
]
: [],
}}
config={rpmConfig}
unit="rpm"
renderValue={(value) => value.toFixed(0)}
graphId="rpm-graph"
currentTimeSeries={motorScrewRpm}
machineId="extruder-graphs"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
<SyncedFloatingControlPanel
controlProps={syncHook.controlProps}
machineId="extruder-graphs"
/>
</MarkerProvider>
</Page>
);
}
@@ -0,0 +1,153 @@
import React, { useEffect, useRef } from "react";
import { toast } from "sonner";
import { Icon } from "@/components/Icon";
type HeatingFaultState = {
heating_fault_state?: {
fault_zone?: string | null;
fault_acknowledged?: boolean;
};
is_default_state?: boolean;
};
type HeatingFaultToastProps = {
zoneName: string;
onAcknowledge: () => void;
children?: React.ReactNode;
};
function HeatingFaultToast({
zoneName,
onAcknowledge,
children,
}: HeatingFaultToastProps) {
return (
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon name="lu:TriangleAlert" className="h-5 w-5 text-yellow-200" />
<strong className="text-lg font-semibold tracking-wide">
Heating Fault
</strong>
</div>
<button
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
onClick={onAcknowledge}
aria-label="Close"
>
×
</button>
</div>
{children ?? (
<p className="text-base leading-snug text-red-50">
The <strong>{zoneName}</strong> heating zone did not increase in
temperature by at least 5°C within 60 seconds. The extruder has been
automatically set to standby mode.
</p>
)}
</div>
);
}
function toZoneName(faultZone: string | null): string {
switch (faultZone) {
case "front":
return "Front";
case "middle":
return "Middle";
case "back":
return "Back";
case "nozzle":
return "Nozzle";
default:
return "Unknown";
}
}
function useHeatingFaultToastEffect({
state,
onAcknowledgeHeatingFault,
}: {
state: HeatingFaultState | null | undefined;
onAcknowledgeHeatingFault: () => void;
}) {
// Deduplicate by active fault zone
const activeFaultZone = useRef<string | null>(null);
const currentToastId = useRef<string | null>(null);
useEffect(() => {
if (!state) return;
const faultZone = state.heating_fault_state?.fault_zone ?? null;
const faultAcknowledged =
state.heating_fault_state?.fault_acknowledged ?? false;
const isDefault = !!state.is_default_state;
if (isDefault) return;
const faultZoneStr = faultZone ?? "unknown";
const zoneName = toZoneName(faultZone);
if (
faultZone &&
!faultAcknowledged &&
activeFaultZone.current !== faultZone
) {
activeFaultZone.current = faultZone;
const toastId = `heating-fault-${faultZoneStr}`;
currentToastId.current = toastId;
toast(
<HeatingFaultToast
zoneName={zoneName}
onAcknowledge={() => {
onAcknowledgeHeatingFault();
toast.dismiss(toastId);
activeFaultZone.current = null;
currentToastId.current = null;
}}
/>,
{
id: toastId,
duration: Infinity,
position: "top-center",
style: {
background: "transparent",
padding: 0,
boxShadow: "none",
border: "none",
},
},
);
}
if (faultAcknowledged || !faultZone) {
if (currentToastId.current) {
toast.dismiss(currentToastId.current);
currentToastId.current = null;
}
activeFaultZone.current = null;
}
}, [state, onAcknowledgeHeatingFault]);
}
export function GlobalHeatingFaultToastManager({
state,
onAcknowledgeHeatingFault,
}: {
state:
| {
heating_fault_state?: {
fault_zone?: string | null;
fault_acknowledged?: boolean;
};
is_default_state?: boolean;
}
| null
| undefined;
onAcknowledgeHeatingFault: () => void;
}) {
useHeatingFaultToastEffect({ state, onAcknowledgeHeatingFault });
return null;
}
+54 -38
View File
@@ -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 (
<Page className="pb-27">
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm"}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph1"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={combinedData}
config={{
...config,
title: "Combined Sine Waves",
}}
unit={"mm"}
renderValue={(value) => value.toFixed(3)}
graphId="combined-graph"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm"}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph2"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm"}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph"
/>
</div>
<MarkerProvider>
<div className="flex flex-col gap-4">
<GraphWithMarkerControls
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm" as Unit}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph1"
currentTimeSeries={sineWaveSum}
machineId="mock-graphs"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={combinedData}
config={{
...config,
title: "Combined Sine Waves",
}}
unit={"mm" as Unit}
renderValue={(value) => value.toFixed(3)}
graphId="combined-graph"
currentTimeSeries={sineWaveSum}
machineId="mock-graphs"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm" as Unit}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph2"
currentTimeSeries={sineWaveSum}
machineId="mock-graphs"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm" as Unit}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph"
currentTimeSeries={sineWaveSum}
machineId="mock-graphs"
/>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
<SyncedFloatingControlPanel
controlProps={syncHook.controlProps}
machineId="mock-graphs"
/>
</MarkerProvider>
</Page>
);
}
@@ -1,6 +1,6 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
MarkerProvider,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
@@ -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 {
@@ -27,50 +28,55 @@ export function Winder2GraphsPage() {
return (
<Page className="pb-27">
<div className="flex flex-col gap-4">
<div className="grid gap-4">
<PullerSpeedGraph
syncHook={syncHook}
newData={pullerSpeed}
targetSeries={targetPullerSpeed}
targetSpeed={state?.puller_state?.target_speed}
unit="m/min"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<MarkerProvider>
<div className="flex flex-col gap-4">
<div className="grid gap-4">
<PullerSpeedGraph
syncHook={syncHook}
newData={pullerSpeed}
targetSeries={targetPullerSpeed}
targetSpeed={state?.puller_state?.target_speed}
unit="m/min"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<TensionArmAngleGraph
syncHook={syncHook}
newData={tensionArmAngle}
unit="deg"
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<TensionArmAngleGraph
syncHook={syncHook}
newData={tensionArmAngle}
unit="deg"
renderValue={(value) => roundDegreesToDecimals(value, 0)}
/>
<SpoolRpmGraph
syncHook={syncHook}
newData={spoolRpm}
unit="rpm"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<SpoolRpmGraph
syncHook={syncHook}
newData={spoolRpm}
unit="rpm"
renderValue={(value) => roundToDecimals(value, 0)}
/>
<TraversePositionGraph
syncHook={syncHook}
newData={traversePosition}
limitInner={state?.traverse_state?.limit_inner}
limitOuter={state?.traverse_state?.limit_outer}
unit="mm"
renderValue={(value) => roundToDecimals(value, 1)}
/>
<TraversePositionGraph
syncHook={syncHook}
newData={traversePosition}
limitInner={state?.traverse_state?.limit_inner}
limitOuter={state?.traverse_state?.limit_outer}
unit="mm"
renderValue={(value) => roundToDecimals(value, 1)}
/>
<SpoolProgressGraph
syncHook={syncHook}
newData={spoolProgress}
unit="m"
renderValue={(value) => roundToDecimals(value, 2)}
/>
<SpoolProgressGraph
syncHook={syncHook}
newData={spoolProgress}
unit="m"
renderValue={(value) => roundToDecimals(value, 2)}
/>
</div>
</div>
</div>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
<SyncedFloatingControlPanel
controlProps={syncHook.controlProps}
machineId="winder2"
/>
</MarkerProvider>
</Page>
);
}
@@ -98,7 +104,7 @@ export function SpoolRpmGraph({
};
return (
<AutoSyncedBigGraph
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData,
@@ -108,6 +114,8 @@ export function SpoolRpmGraph({
renderValue={renderValue}
config={config}
graphId="spool-rpm"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -157,7 +165,7 @@ export function TraversePositionGraph({
};
return (
<AutoSyncedBigGraph
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData,
@@ -168,6 +176,8 @@ export function TraversePositionGraph({
renderValue={renderValue}
config={config}
graphId="traverse-position"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -193,7 +203,7 @@ export function TensionArmAngleGraph({
};
return (
<AutoSyncedBigGraph
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData,
@@ -203,6 +213,8 @@ export function TensionArmAngleGraph({
renderValue={renderValue}
config={config}
graphId="tension-arm-angle"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -227,8 +239,9 @@ export function SpoolProgressGraph({
exportFilename: "spool_progress",
};
// NOTE: Assuming this graph starts at 0, and the max is the total capacity.
return (
<AutoSyncedBigGraph
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData,
@@ -238,6 +251,8 @@ export function SpoolProgressGraph({
renderValue={renderValue}
config={config}
graphId="spool-progress"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -279,7 +294,7 @@ export function PullerSpeedGraph({
};
return (
<AutoSyncedBigGraph
<GraphWithMarkerControls
syncHook={syncHook}
newData={{
newData,
@@ -290,6 +305,8 @@ export function PullerSpeedGraph({
renderValue={renderValue}
config={config}
graphId="puller-speed"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type Marker = {
timestamp: number;
name: string;
value?: number;
color?: string;
};
const MAX_MARKERS_PER_MACHINE = 200;
const STORAGE_NAME = "machine-markers-storage";
const OLD_STORAGE_PREFIX = "machine-markers-";
export type MarkerStoreState = {
markersByMachine: Record<string, Marker[]>;
};
export type MarkerStoreActions = {
getMarkers: (machineId: string) => Marker[];
addMarker: (machineId: string, marker: Marker) => void;
removeMarker: (machineId: string, timestamp: number) => void;
clearMarkers: (machineId: string) => void;
};
export type MarkerStore = MarkerStoreState & MarkerStoreActions;
function trimToMax(markers: Marker[]): Marker[] {
return markers.length > MAX_MARKERS_PER_MACHINE
? markers.slice(-MAX_MARKERS_PER_MACHINE)
: markers;
}
/** Migrate from legacy per-machine keys (machine-markers-{id}) to unified store */
function migrateFromLegacyStorage(): Partial<MarkerStoreState> | null {
try {
const markersByMachine: Record<string, Marker[]> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(OLD_STORAGE_PREFIX) && key !== STORAGE_NAME) {
const machineId = key.slice(OLD_STORAGE_PREFIX.length);
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored) as Marker[];
if (Array.isArray(parsed) && parsed.length > 0) {
markersByMachine[machineId] = parsed;
}
}
} catch {
// Skip malformed entries
}
}
}
if (Object.keys(markersByMachine).length > 0) {
return { markersByMachine };
}
} catch {
// Ignore migration errors
}
return null;
}
const markerStorage = createJSONStorage<MarkerStoreState>(() => ({
getItem: (name: string): string | null => {
const next = localStorage.getItem(name);
if (next) return next;
const migrated = migrateFromLegacyStorage();
if (migrated) {
return JSON.stringify({ state: migrated, version: 0 });
}
return null;
},
setItem: (name: string, value: string): void => {
localStorage.setItem(name, value);
},
removeItem: (name: string): void => {
localStorage.removeItem(name);
},
}));
export const useMarkerStore = create<MarkerStore>()(
persist(
(set, get) => ({
markersByMachine: {},
getMarkers: (machineId: string) => {
return get().markersByMachine[machineId] ?? [];
},
addMarker: (machineId: string, marker: Marker) => {
set((state) => {
const current = state.markersByMachine[machineId] ?? [];
const updated = trimToMax([...current, marker]);
return {
markersByMachine: {
...state.markersByMachine,
[machineId]: updated,
},
};
});
},
removeMarker: (machineId: string, timestamp: number) => {
set((state) => {
const current = state.markersByMachine[machineId] ?? [];
const updated = current.filter((m) => m.timestamp !== timestamp);
return {
markersByMachine: {
...state.markersByMachine,
[machineId]: updated,
},
};
});
},
clearMarkers: (machineId: string) => {
set((state) => ({
markersByMachine: {
...state.markersByMachine,
[machineId]: [],
},
}));
},
}),
{
name: STORAGE_NAME,
storage: markerStorage,
},
),
);