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,
|
config,
|
||||||
graphId,
|
graphId,
|
||||||
syncGraph,
|
syncGraph,
|
||||||
|
uplotRefOut,
|
||||||
onRegisterForExport,
|
onRegisterForExport,
|
||||||
onUnregisterFromExport,
|
onUnregisterFromExport,
|
||||||
}: BigGraphProps & {
|
}: BigGraphProps & {
|
||||||
@@ -457,6 +458,7 @@ export function BigGraph({
|
|||||||
useBigGraphEffects({
|
useBigGraphEffects({
|
||||||
containerRef,
|
containerRef,
|
||||||
uplotRef,
|
uplotRef,
|
||||||
|
uplotRefOut,
|
||||||
startTimeRef,
|
startTimeRef,
|
||||||
manualScaleRef,
|
manualScaleRef,
|
||||||
lastProcessedCountRef,
|
lastProcessedCountRef,
|
||||||
@@ -495,8 +497,8 @@ export function BigGraph({
|
|||||||
return (
|
return (
|
||||||
<div className="h-[50vh] w-full">
|
<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 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="flex shrink-0 items-center justify-between gap-4 pt-4 pr-5 pb-6 pl-6">
|
||||||
<div className="mt-1 flex items-center gap-4">
|
<div className="mt-1 flex min-w-0 flex-1 items-center gap-4">
|
||||||
<Icon
|
<Icon
|
||||||
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
||||||
className="size-6 text-gray-600"
|
className="size-6 text-gray-600"
|
||||||
@@ -507,9 +509,9 @@ export function BigGraph({
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex shrink-0 items-center gap-4">
|
||||||
{normalizedSeries.length === 1 && (
|
{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">
|
<div className="flex items-center gap-2 text-base text-gray-600">
|
||||||
<span className="font-mono leading-none font-bold text-gray-900">
|
<span className="font-mono leading-none font-bold text-gray-900">
|
||||||
{formatDisplayValue(displayValue, renderValue)}
|
{formatDisplayValue(displayValue, renderValue)}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function GraphControls({
|
|||||||
onSwitchToLive,
|
onSwitchToLive,
|
||||||
onSwitchToHistorical,
|
onSwitchToHistorical,
|
||||||
onExport,
|
onExport,
|
||||||
|
onAddMarker,
|
||||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||||
showFromTimestamp,
|
showFromTimestamp,
|
||||||
onShowFromChange,
|
onShowFromChange,
|
||||||
@@ -102,16 +103,27 @@ export function GraphControls({
|
|||||||
Live
|
Live
|
||||||
</TouchButton>
|
</TouchButton>
|
||||||
|
|
||||||
{onExport && (
|
{(onExport || onAddMarker) && (
|
||||||
<>
|
<>
|
||||||
<div className="mx-2 h-8 w-px bg-gray-200"></div>
|
<div className="mx-2 h-8 w-px bg-gray-200"></div>
|
||||||
<TouchButton
|
{onAddMarker && (
|
||||||
onClick={onExport}
|
<TouchButton
|
||||||
variant="outline"
|
onClick={onAddMarker}
|
||||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
variant="outline"
|
||||||
>
|
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
|
||||||
Export
|
>
|
||||||
</TouchButton>
|
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>
|
</div>
|
||||||
@@ -127,6 +139,7 @@ export function FloatingControlPanel({
|
|||||||
onSwitchToLive,
|
onSwitchToLive,
|
||||||
onSwitchToHistorical,
|
onSwitchToHistorical,
|
||||||
onExport,
|
onExport,
|
||||||
|
onAddMarker,
|
||||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||||
showFromTimestamp,
|
showFromTimestamp,
|
||||||
onShowFromChange,
|
onShowFromChange,
|
||||||
@@ -218,10 +231,19 @@ export function FloatingControlPanel({
|
|||||||
>
|
>
|
||||||
Live
|
Live
|
||||||
</TouchButton>
|
</TouchButton>
|
||||||
{isExpanded && onExport && (
|
{isExpanded && (onExport || onAddMarker) && (
|
||||||
<div className="h-8 w-px bg-gray-200"></div>
|
<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
|
<TouchButton
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
variant="outline"
|
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 { BigGraph } from "./BigGraph";
|
||||||
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||||
import { useGraphSync } from "./useGraphSync";
|
import { useGraphSync } from "./useGraphSync";
|
||||||
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
||||||
import { GraphExportData } from "./excelExport";
|
import { GraphExportData } from "./excelExport";
|
||||||
|
import { useMarkerManager } from "./useMarkerManager";
|
||||||
|
import { AddMarkerDialog } from "./AddMarkerDialog";
|
||||||
|
import { useMarkerContext } from "./MarkerContext";
|
||||||
|
|
||||||
export function SyncedBigGraph({
|
export function SyncedBigGraph({
|
||||||
syncGraph: externalSyncGraph,
|
syncGraph: externalSyncGraph,
|
||||||
@@ -50,21 +53,71 @@ export function SyncedGraphControls({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SyncedFloatingControlPanel({
|
function SyncedFloatingControlPanelInner({
|
||||||
controlProps,
|
controlProps,
|
||||||
timeWindowOptions,
|
timeWindowOptions,
|
||||||
|
machineId: machineIdProp,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||||
timeWindowOptions?: TimeWindowOption[];
|
timeWindowOptions?: TimeWindowOption[];
|
||||||
|
machineId?: string;
|
||||||
}) {
|
}) {
|
||||||
const defaultSync = useGraphSync();
|
const defaultSync = useGraphSync();
|
||||||
const finalProps = controlProps || defaultSync.controlProps;
|
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 (
|
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}
|
timeWindowOptions={timeWindowOptions}
|
||||||
|
machineId={machineId}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { normalizeDataSeries } from "./animation";
|
|||||||
export function createChart({
|
export function createChart({
|
||||||
containerRef,
|
containerRef,
|
||||||
uplotRef,
|
uplotRef,
|
||||||
|
uplotRefOut,
|
||||||
newData,
|
newData,
|
||||||
config,
|
config,
|
||||||
colors,
|
colors,
|
||||||
@@ -197,6 +198,9 @@ export function createChart({
|
|||||||
|
|
||||||
// Always destroy existing chart before creating new one
|
// Always destroy existing chart before creating new one
|
||||||
if (uplotRef.current) {
|
if (uplotRef.current) {
|
||||||
|
if (uplotRefOut?.current != null) {
|
||||||
|
uplotRefOut.current = null;
|
||||||
|
}
|
||||||
uplotRef.current.destroy();
|
uplotRef.current.destroy();
|
||||||
uplotRef.current = null;
|
uplotRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -498,6 +502,10 @@ export function createChart({
|
|||||||
containerRef.current,
|
containerRef.current,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (uplotRefOut) {
|
||||||
|
uplotRefOut.current = uplotRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
// Create handler callbacks
|
// Create handler callbacks
|
||||||
const handlerCallbacks: HandlerCallbacks = {
|
const handlerCallbacks: HandlerCallbacks = {
|
||||||
updateYAxisScale,
|
updateYAxisScale,
|
||||||
|
|||||||
@@ -84,6 +84,27 @@ export function exportGraphsToExcel(
|
|||||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
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++;
|
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
|
// Ensure sheet names are unique and valid for Excel
|
||||||
function generateUniqueSheetName(
|
function generateUniqueSheetName(
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export {
|
|||||||
AutoSyncedBigGraph,
|
AutoSyncedBigGraph,
|
||||||
} from "./SyncedComponents";
|
} from "./SyncedComponents";
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export { MarkerProvider } from "./MarkerContext";
|
||||||
|
|
||||||
// Hooks
|
// Hooks
|
||||||
export { useGraphSync } from "./useGraphSync";
|
export { useGraphSync } from "./useGraphSync";
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type uPlot from "uplot";
|
||||||
import { IconName } from "@/components/Icon";
|
import { IconName } from "@/components/Icon";
|
||||||
import { Unit } from "@/control/units";
|
import { Unit } from "@/control/units";
|
||||||
import { TimeSeries } from "@/lib/timeseries";
|
import { TimeSeries } from "@/lib/timeseries";
|
||||||
@@ -28,7 +29,7 @@ export type PropGraphSync = {
|
|||||||
|
|
||||||
// Configuration types for additional lines
|
// Configuration types for additional lines
|
||||||
export type GraphLine = {
|
export type GraphLine = {
|
||||||
type: "threshold" | "target";
|
type: "threshold" | "target" | "user_marker"; // TODO: redundant or not?
|
||||||
value: number;
|
value: number;
|
||||||
targetSeries?: TimeSeries;
|
targetSeries?: TimeSeries;
|
||||||
color: string;
|
color: string;
|
||||||
@@ -36,6 +37,7 @@ export type GraphLine = {
|
|||||||
width?: number;
|
width?: number;
|
||||||
dash?: number[];
|
dash?: number[];
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
|
markerTimestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphConfig = {
|
export type GraphConfig = {
|
||||||
@@ -75,6 +77,8 @@ export type BigGraphProps = {
|
|||||||
config: GraphConfig;
|
config: GraphConfig;
|
||||||
graphId: string;
|
graphId: string;
|
||||||
syncGraph?: PropGraphSync;
|
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 = {
|
export type TimeWindowOption = {
|
||||||
@@ -89,6 +93,7 @@ export type ControlProps = {
|
|||||||
onSwitchToLive: () => void;
|
onSwitchToLive: () => void;
|
||||||
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
||||||
onExport?: () => void;
|
onExport?: () => void;
|
||||||
|
onAddMarker?: () => void;
|
||||||
timeWindowOptions?: TimeWindowOption[];
|
timeWindowOptions?: TimeWindowOption[];
|
||||||
showFromTimestamp?: number | null;
|
showFromTimestamp?: number | null;
|
||||||
onShowFromChange?: (timestamp: number | null) => void;
|
onShowFromChange?: (timestamp: number | null) => void;
|
||||||
@@ -111,6 +116,8 @@ export interface HistoricalModeHandlers {
|
|||||||
export interface CreateChartParams {
|
export interface CreateChartParams {
|
||||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
uplotRef: React.RefObject<uPlot | 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"];
|
newData: BigGraphProps["newData"];
|
||||||
config: BigGraphProps["config"];
|
config: BigGraphProps["config"];
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
/* eslint-disable react-compiler/react-compiler */
|
/* 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 uPlot from "uplot";
|
||||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||||
@@ -13,6 +19,7 @@ interface UseBigGraphEffectsProps {
|
|||||||
// Refs
|
// Refs
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
uplotRef: RefObject<uPlot | null>;
|
uplotRef: RefObject<uPlot | null>;
|
||||||
|
uplotRefOut?: MutableRefObject<uPlot | null>;
|
||||||
startTimeRef: RefObject<number | null>;
|
startTimeRef: RefObject<number | null>;
|
||||||
manualScaleRef: RefObject<{
|
manualScaleRef: RefObject<{
|
||||||
x: { min: number; max: number };
|
x: { min: number; max: number };
|
||||||
@@ -71,6 +78,7 @@ interface UseBigGraphEffectsProps {
|
|||||||
export function useBigGraphEffects({
|
export function useBigGraphEffects({
|
||||||
containerRef,
|
containerRef,
|
||||||
uplotRef,
|
uplotRef,
|
||||||
|
uplotRefOut,
|
||||||
startTimeRef,
|
startTimeRef,
|
||||||
manualScaleRef,
|
manualScaleRef,
|
||||||
lastProcessedCountRef,
|
lastProcessedCountRef,
|
||||||
@@ -257,6 +265,7 @@ export function useBigGraphEffects({
|
|||||||
const cleanup = createChart({
|
const cleanup = createChart({
|
||||||
containerRef,
|
containerRef,
|
||||||
uplotRef,
|
uplotRef,
|
||||||
|
uplotRefOut,
|
||||||
newData,
|
newData,
|
||||||
config,
|
config,
|
||||||
colors,
|
colors,
|
||||||
@@ -291,6 +300,7 @@ export function useBigGraphEffects({
|
|||||||
cleanup?.();
|
cleanup?.();
|
||||||
uplotRef.current?.destroy();
|
uplotRef.current?.destroy();
|
||||||
uplotRef.current = null;
|
uplotRef.current = null;
|
||||||
|
if (uplotRefOut) uplotRefOut.current = null;
|
||||||
stopAnimations(animationRefs);
|
stopAnimations(animationRefs);
|
||||||
setIsChartCreated(false);
|
setIsChartCreated(false);
|
||||||
chartCreatedRef.current = 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
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -55,7 +55,7 @@ function DialogContent({
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Page } from "@/components/Page";
|
import { Page } from "@/components/Page";
|
||||||
import {
|
import {
|
||||||
AutoSyncedBigGraph,
|
MarkerProvider,
|
||||||
SyncedFloatingControlPanel,
|
SyncedFloatingControlPanel,
|
||||||
useGraphSync,
|
useGraphSync,
|
||||||
type GraphConfig,
|
type GraphConfig,
|
||||||
} from "@/components/graph";
|
} from "@/components/graph";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useExtruder2 } from "./useExtruder";
|
import { useExtruder2 } from "./useExtruder";
|
||||||
|
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||||
|
|
||||||
export function Extruder2GraphsPage() {
|
export function Extruder2GraphsPage() {
|
||||||
const {
|
const {
|
||||||
@@ -252,87 +253,102 @@ export function Extruder2GraphsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="pb-25">
|
<Page className="pb-25">
|
||||||
<div className="flex flex-col gap-4">
|
<MarkerProvider>
|
||||||
<AutoSyncedBigGraph
|
<div className="flex flex-col gap-4">
|
||||||
syncHook={syncHook}
|
<GraphWithMarkerControls
|
||||||
newData={{
|
syncHook={syncHook}
|
||||||
newData: pressure,
|
newData={{
|
||||||
color: "#3b82f6",
|
newData: pressure,
|
||||||
lines:
|
color: "#3b82f6",
|
||||||
state?.pressure_state.target_bar !== undefined
|
lines:
|
||||||
? [
|
state?.pressure_state.target_bar !== undefined
|
||||||
{
|
? [
|
||||||
type: "target" as const,
|
{
|
||||||
value: state.pressure_state.target_bar,
|
type: "target" as const,
|
||||||
targetSeries: targetPressure,
|
value: state.pressure_state.target_bar,
|
||||||
color: "#3b82f6",
|
targetSeries: targetPressure,
|
||||||
show: true,
|
color: "#3b82f6",
|
||||||
},
|
show: true,
|
||||||
]
|
},
|
||||||
: [],
|
]
|
||||||
}}
|
: [],
|
||||||
config={pressureConfig}
|
}}
|
||||||
unit="bar"
|
config={pressureConfig}
|
||||||
renderValue={(value) => value.toFixed(2)}
|
unit="bar"
|
||||||
graphId="pressure-graph"
|
renderValue={(value) => value.toFixed(2)}
|
||||||
/>
|
graphId="pressure-graph"
|
||||||
|
currentTimeSeries={pressure}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={temperatureData}
|
newData={temperatureData}
|
||||||
config={temperatureConfig}
|
config={temperatureConfig}
|
||||||
unit="C"
|
unit="C"
|
||||||
renderValue={(value) => value.toFixed(1)}
|
renderValue={(value) => value.toFixed(1)}
|
||||||
graphId="combined-temperatures"
|
graphId="combined-temperatures"
|
||||||
/>
|
currentTimeSeries={nozzleTemperature}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={powerData}
|
newData={powerData}
|
||||||
config={powerConfig}
|
config={powerConfig}
|
||||||
unit="W"
|
unit="W"
|
||||||
renderValue={(value) => value.toFixed(1)}
|
renderValue={(value) => value.toFixed(1)}
|
||||||
graphId="combined-power"
|
graphId="combined-power"
|
||||||
/>
|
currentTimeSeries={combinedPower}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData: motorCurrent,
|
newData: motorCurrent,
|
||||||
color: "#3b82f6",
|
color: "#3b82f6",
|
||||||
}}
|
}}
|
||||||
config={currentConfig}
|
config={currentConfig}
|
||||||
unit="A"
|
unit="A"
|
||||||
renderValue={(value) => value.toFixed(2)}
|
renderValue={(value) => value.toFixed(2)}
|
||||||
graphId="motor-current"
|
graphId="motor-current"
|
||||||
/>
|
currentTimeSeries={motorCurrent}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData: motorScrewRpm,
|
newData: motorScrewRpm,
|
||||||
color: "#8b5cf6",
|
color: "#8b5cf6",
|
||||||
lines:
|
lines:
|
||||||
state?.screw_state.target_rpm !== undefined
|
state?.screw_state.target_rpm !== undefined
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "target" as const,
|
type: "target" as const,
|
||||||
value: state.screw_state.target_rpm,
|
value: state.screw_state.target_rpm,
|
||||||
targetSeries: targetScrewRpm,
|
targetSeries: targetScrewRpm,
|
||||||
color: "#8b5cf6",
|
color: "#8b5cf6",
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
}}
|
}}
|
||||||
config={rpmConfig}
|
config={rpmConfig}
|
||||||
unit="rpm"
|
unit="rpm"
|
||||||
renderValue={(value) => value.toFixed(0)}
|
renderValue={(value) => value.toFixed(0)}
|
||||||
graphId="rpm-graph"
|
graphId="rpm-graph"
|
||||||
/>
|
currentTimeSeries={motorScrewRpm}
|
||||||
</div>
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
<SyncedFloatingControlPanel
|
||||||
|
controlProps={syncHook.controlProps}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
</MarkerProvider>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Page } from "@/components/Page";
|
import { Page } from "@/components/Page";
|
||||||
import {
|
import {
|
||||||
AutoSyncedBigGraph,
|
MarkerProvider,
|
||||||
SyncedFloatingControlPanel,
|
SyncedFloatingControlPanel,
|
||||||
useGraphSync,
|
useGraphSync,
|
||||||
type GraphConfig,
|
type GraphConfig,
|
||||||
} from "@/components/graph";
|
} from "@/components/graph";
|
||||||
|
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useExtruder3 } from "./useExtruder";
|
import { useExtruder3 } from "./useExtruder";
|
||||||
|
|
||||||
@@ -252,87 +253,102 @@ export function Extruder3GraphsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="pb-25">
|
<Page className="pb-25">
|
||||||
<div className="flex flex-col gap-4">
|
<MarkerProvider>
|
||||||
<AutoSyncedBigGraph
|
<div className="flex flex-col gap-4">
|
||||||
syncHook={syncHook}
|
<GraphWithMarkerControls
|
||||||
newData={{
|
syncHook={syncHook}
|
||||||
newData: pressure,
|
newData={{
|
||||||
color: "#3b82f6",
|
newData: pressure,
|
||||||
lines:
|
color: "#3b82f6",
|
||||||
state?.pressure_state.target_bar !== undefined
|
lines:
|
||||||
? [
|
state?.pressure_state.target_bar !== undefined
|
||||||
{
|
? [
|
||||||
type: "target" as const,
|
{
|
||||||
value: state.pressure_state.target_bar,
|
type: "target" as const,
|
||||||
targetSeries: targetPressure,
|
value: state.pressure_state.target_bar,
|
||||||
color: "#3b82f6",
|
targetSeries: targetPressure,
|
||||||
show: true,
|
color: "#3b82f6",
|
||||||
},
|
show: true,
|
||||||
]
|
},
|
||||||
: [],
|
]
|
||||||
}}
|
: [],
|
||||||
config={pressureConfig}
|
}}
|
||||||
unit="bar"
|
config={pressureConfig}
|
||||||
renderValue={(value) => value.toFixed(2)}
|
unit="bar"
|
||||||
graphId="pressure-graph"
|
renderValue={(value) => value.toFixed(2)}
|
||||||
/>
|
graphId="pressure-graph"
|
||||||
|
currentTimeSeries={pressure}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={temperatureData}
|
newData={temperatureData}
|
||||||
config={temperatureConfig}
|
config={temperatureConfig}
|
||||||
unit="C"
|
unit="C"
|
||||||
renderValue={(value) => value.toFixed(1)}
|
renderValue={(value) => value.toFixed(1)}
|
||||||
graphId="combined-temperatures"
|
graphId="combined-temperatures"
|
||||||
/>
|
currentTimeSeries={nozzleTemperature ?? null}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={powerData}
|
newData={powerData}
|
||||||
config={powerConfig}
|
config={powerConfig}
|
||||||
unit="W"
|
unit="W"
|
||||||
renderValue={(value) => value.toFixed(1)}
|
renderValue={(value) => value.toFixed(1)}
|
||||||
graphId="combined-power"
|
graphId="combined-power"
|
||||||
/>
|
currentTimeSeries={combinedPower ?? null}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData: motorCurrent,
|
newData: motorCurrent,
|
||||||
color: "#3b82f6",
|
color: "#3b82f6",
|
||||||
}}
|
}}
|
||||||
config={currentConfig}
|
config={currentConfig}
|
||||||
unit="A"
|
unit="A"
|
||||||
renderValue={(value) => value.toFixed(2)}
|
renderValue={(value) => value.toFixed(2)}
|
||||||
graphId="motor-current"
|
graphId="motor-current"
|
||||||
/>
|
currentTimeSeries={motorCurrent}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData: motorScrewRpm,
|
newData: motorScrewRpm,
|
||||||
color: "#8b5cf6",
|
color: "#8b5cf6",
|
||||||
lines:
|
lines:
|
||||||
state?.screw_state.target_rpm !== undefined
|
state?.screw_state.target_rpm !== undefined
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
type: "target" as const,
|
type: "target" as const,
|
||||||
value: state.screw_state.target_rpm,
|
value: state.screw_state.target_rpm,
|
||||||
targetSeries: targetScrewRpm,
|
targetSeries: targetScrewRpm,
|
||||||
color: "#8b5cf6",
|
color: "#8b5cf6",
|
||||||
show: true,
|
show: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
}}
|
}}
|
||||||
config={rpmConfig}
|
config={rpmConfig}
|
||||||
unit="rpm"
|
unit="rpm"
|
||||||
renderValue={(value) => value.toFixed(0)}
|
renderValue={(value) => value.toFixed(0)}
|
||||||
graphId="rpm-graph"
|
graphId="rpm-graph"
|
||||||
/>
|
currentTimeSeries={motorScrewRpm}
|
||||||
</div>
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
<SyncedFloatingControlPanel
|
||||||
|
controlProps={syncHook.controlProps}
|
||||||
|
machineId="extruder-graphs"
|
||||||
|
/>
|
||||||
|
</MarkerProvider>
|
||||||
</Page>
|
</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 { Page } from "@/components/Page";
|
||||||
import {
|
import {
|
||||||
AutoSyncedBigGraph,
|
AutoSyncedBigGraph,
|
||||||
|
MarkerProvider,
|
||||||
SyncedFloatingControlPanel,
|
SyncedFloatingControlPanel,
|
||||||
useGraphSync,
|
useGraphSync,
|
||||||
type GraphConfig,
|
type GraphConfig,
|
||||||
@@ -8,6 +9,8 @@ import {
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useMock1 } from "./useMock";
|
import { useMock1 } from "./useMock";
|
||||||
import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries";
|
import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries";
|
||||||
|
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||||
|
import { Unit } from "@/control/units";
|
||||||
|
|
||||||
export function Mock1GraphPage() {
|
export function Mock1GraphPage() {
|
||||||
const { sineWaveSum } = useMock1();
|
const { sineWaveSum } = useMock1();
|
||||||
@@ -97,45 +100,58 @@ export function Mock1GraphPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="pb-27">
|
<Page className="pb-27">
|
||||||
<div className="flex flex-col gap-4">
|
<MarkerProvider>
|
||||||
<AutoSyncedBigGraph
|
<div className="flex flex-col gap-4">
|
||||||
syncHook={syncHook}
|
<GraphWithMarkerControls
|
||||||
newData={singleData}
|
syncHook={syncHook}
|
||||||
config={singleGraphConfig}
|
newData={singleData}
|
||||||
unit={"mm"}
|
config={singleGraphConfig}
|
||||||
renderValue={(value) => value.toFixed(3)}
|
unit={"mm" as Unit}
|
||||||
graphId="single-graph1"
|
renderValue={(value) => value.toFixed(3)}
|
||||||
/>
|
graphId="single-graph1"
|
||||||
<AutoSyncedBigGraph
|
currentTimeSeries={sineWaveSum}
|
||||||
syncHook={syncHook}
|
machineId="mock-graphs"
|
||||||
newData={combinedData}
|
/>
|
||||||
config={{
|
<GraphWithMarkerControls
|
||||||
...config,
|
syncHook={syncHook}
|
||||||
title: "Combined Sine Waves",
|
newData={combinedData}
|
||||||
}}
|
config={{
|
||||||
unit={"mm"}
|
...config,
|
||||||
renderValue={(value) => value.toFixed(3)}
|
title: "Combined Sine Waves",
|
||||||
graphId="combined-graph"
|
}}
|
||||||
/>
|
unit={"mm" as Unit}
|
||||||
<AutoSyncedBigGraph
|
renderValue={(value) => value.toFixed(3)}
|
||||||
syncHook={syncHook}
|
graphId="combined-graph"
|
||||||
newData={singleData}
|
currentTimeSeries={sineWaveSum}
|
||||||
config={singleGraphConfig}
|
machineId="mock-graphs"
|
||||||
unit={"mm"}
|
/>
|
||||||
renderValue={(value) => value.toFixed(3)}
|
<GraphWithMarkerControls
|
||||||
graphId="single-graph2"
|
syncHook={syncHook}
|
||||||
/>
|
newData={singleData}
|
||||||
<AutoSyncedBigGraph
|
config={singleGraphConfig}
|
||||||
syncHook={syncHook}
|
unit={"mm" as Unit}
|
||||||
newData={singleData}
|
renderValue={(value) => value.toFixed(3)}
|
||||||
config={singleGraphConfig}
|
graphId="single-graph2"
|
||||||
unit={"mm"}
|
currentTimeSeries={sineWaveSum}
|
||||||
renderValue={(value) => value.toFixed(3)}
|
machineId="mock-graphs"
|
||||||
graphId="single-graph"
|
/>
|
||||||
/>
|
<GraphWithMarkerControls
|
||||||
</div>
|
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>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Page } from "@/components/Page";
|
import { Page } from "@/components/Page";
|
||||||
import {
|
import {
|
||||||
AutoSyncedBigGraph,
|
MarkerProvider,
|
||||||
SyncedFloatingControlPanel,
|
SyncedFloatingControlPanel,
|
||||||
useGraphSync,
|
useGraphSync,
|
||||||
type GraphConfig,
|
type GraphConfig,
|
||||||
@@ -11,6 +11,7 @@ import { useWinder2 } from "./useWinder";
|
|||||||
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
|
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
|
||||||
import { TimeSeries } from "@/lib/timeseries";
|
import { TimeSeries } from "@/lib/timeseries";
|
||||||
import { Unit } from "@/control/units";
|
import { Unit } from "@/control/units";
|
||||||
|
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||||
|
|
||||||
export function Winder2GraphsPage() {
|
export function Winder2GraphsPage() {
|
||||||
const {
|
const {
|
||||||
@@ -27,50 +28,55 @@ export function Winder2GraphsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Page className="pb-27">
|
<Page className="pb-27">
|
||||||
<div className="flex flex-col gap-4">
|
<MarkerProvider>
|
||||||
<div className="grid gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<PullerSpeedGraph
|
<div className="grid gap-4">
|
||||||
syncHook={syncHook}
|
<PullerSpeedGraph
|
||||||
newData={pullerSpeed}
|
syncHook={syncHook}
|
||||||
targetSeries={targetPullerSpeed}
|
newData={pullerSpeed}
|
||||||
targetSpeed={state?.puller_state?.target_speed}
|
targetSeries={targetPullerSpeed}
|
||||||
unit="m/min"
|
targetSpeed={state?.puller_state?.target_speed}
|
||||||
renderValue={(value) => roundToDecimals(value, 0)}
|
unit="m/min"
|
||||||
/>
|
renderValue={(value) => roundToDecimals(value, 0)}
|
||||||
|
/>
|
||||||
|
|
||||||
<TensionArmAngleGraph
|
<TensionArmAngleGraph
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={tensionArmAngle}
|
newData={tensionArmAngle}
|
||||||
unit="deg"
|
unit="deg"
|
||||||
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpoolRpmGraph
|
<SpoolRpmGraph
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={spoolRpm}
|
newData={spoolRpm}
|
||||||
unit="rpm"
|
unit="rpm"
|
||||||
renderValue={(value) => roundToDecimals(value, 0)}
|
renderValue={(value) => roundToDecimals(value, 0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TraversePositionGraph
|
<TraversePositionGraph
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={traversePosition}
|
newData={traversePosition}
|
||||||
limitInner={state?.traverse_state?.limit_inner}
|
limitInner={state?.traverse_state?.limit_inner}
|
||||||
limitOuter={state?.traverse_state?.limit_outer}
|
limitOuter={state?.traverse_state?.limit_outer}
|
||||||
unit="mm"
|
unit="mm"
|
||||||
renderValue={(value) => roundToDecimals(value, 1)}
|
renderValue={(value) => roundToDecimals(value, 1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SpoolProgressGraph
|
<SpoolProgressGraph
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={spoolProgress}
|
newData={spoolProgress}
|
||||||
unit="m"
|
unit="m"
|
||||||
renderValue={(value) => roundToDecimals(value, 2)}
|
renderValue={(value) => roundToDecimals(value, 2)}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
<SyncedFloatingControlPanel
|
||||||
|
controlProps={syncHook.controlProps}
|
||||||
|
machineId="winder2"
|
||||||
|
/>
|
||||||
|
</MarkerProvider>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,7 +104,7 @@ export function SpoolRpmGraph({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData,
|
newData,
|
||||||
@@ -108,6 +114,8 @@ export function SpoolRpmGraph({
|
|||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
config={config}
|
config={config}
|
||||||
graphId="spool-rpm"
|
graphId="spool-rpm"
|
||||||
|
currentTimeSeries={newData}
|
||||||
|
machineId="winder2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -157,7 +165,7 @@ export function TraversePositionGraph({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData,
|
newData,
|
||||||
@@ -168,6 +176,8 @@ export function TraversePositionGraph({
|
|||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
config={config}
|
config={config}
|
||||||
graphId="traverse-position"
|
graphId="traverse-position"
|
||||||
|
currentTimeSeries={newData}
|
||||||
|
machineId="winder2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,7 +203,7 @@ export function TensionArmAngleGraph({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData,
|
newData,
|
||||||
@@ -203,6 +213,8 @@ export function TensionArmAngleGraph({
|
|||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
config={config}
|
config={config}
|
||||||
graphId="tension-arm-angle"
|
graphId="tension-arm-angle"
|
||||||
|
currentTimeSeries={newData}
|
||||||
|
machineId="winder2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -227,8 +239,9 @@ export function SpoolProgressGraph({
|
|||||||
exportFilename: "spool_progress",
|
exportFilename: "spool_progress",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NOTE: Assuming this graph starts at 0, and the max is the total capacity.
|
||||||
return (
|
return (
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData,
|
newData,
|
||||||
@@ -238,6 +251,8 @@ export function SpoolProgressGraph({
|
|||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
config={config}
|
config={config}
|
||||||
graphId="spool-progress"
|
graphId="spool-progress"
|
||||||
|
currentTimeSeries={newData}
|
||||||
|
machineId="winder2"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,7 +294,7 @@ export function PullerSpeedGraph({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutoSyncedBigGraph
|
<GraphWithMarkerControls
|
||||||
syncHook={syncHook}
|
syncHook={syncHook}
|
||||||
newData={{
|
newData={{
|
||||||
newData,
|
newData,
|
||||||
@@ -290,6 +305,8 @@ export function PullerSpeedGraph({
|
|||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
config={config}
|
config={config}
|
||||||
graphId="puller-speed"
|
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;
|
||||||