Compare commits
20
Commits
master
...
add-timestamp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4e0f78c6 | ||
|
|
c85a4d3ebe | ||
|
|
635c08cfc5 | ||
|
|
1ecdf33d29 | ||
|
|
d88a87c8a2 | ||
|
|
94950c3c38 | ||
|
|
ebc6cb9c40 | ||
|
|
921f0fa445 | ||
|
|
2ab668b2f1 | ||
|
|
3a9801b97c | ||
|
|
612b6ecb25 | ||
|
|
677f51ae33 | ||
|
|
bf28b41afd | ||
|
|
723072c5bc | ||
|
|
3813ce70bf | ||
|
|
b523b3de21 | ||
|
|
09b1eb9433 | ||
|
|
c7feef6bcd | ||
|
|
84d2766ca9 | ||
|
|
b4d2974f45 |
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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,9 @@ export {
|
||||
AutoSyncedBigGraph,
|
||||
} from "./SyncedComponents";
|
||||
|
||||
// Context
|
||||
export { MarkerProvider } from "./MarkerContext";
|
||||
|
||||
// Hooks
|
||||
export { useGraphSync } from "./useGraphSync";
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
Reference in new issue
Block a user