Compare commits

..
Author SHA1 Message Date
dependabot[bot] 0844eca8ce Bump syn from 2.0.114 to 2.0.117
Bumps [syn](https://github.com/dtolnay/syn) from 2.0.114 to 2.0.117.
- [Release notes](https://github.com/dtolnay/syn/releases)
- [Commits](https://github.com/dtolnay/syn/compare/2.0.114...2.0.117)

---
updated-dependencies:
- dependency-name: syn
  dependency-version: 2.0.117
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-01 02:26:31 +00:00
Robin Krämer 45c7b24420 fixed xdg autostart (#1180) 2026-02-28 19:25:12 +01:00
21 changed files with 293 additions and 1557 deletions

No files matched your search

Generated
+23 -23
View File
@@ -204,7 +204,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -221,7 +221,7 @@ checksum = "99e1aca718ea7b89985790c94aad72d77533063fe00bc497bb79a7c2dae6a661"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -291,7 +291,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -539,7 +539,7 @@ version = "0.1.0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -682,7 +682,7 @@ dependencies = [
"deluxe-macros",
"once_cell",
"proc-macro2",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -695,7 +695,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -710,7 +710,7 @@ dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -721,7 +721,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -962,7 +962,7 @@ dependencies = [
"deluxe",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1011,7 +1011,7 @@ dependencies = [
"criterion",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1875,7 +1875,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1905,7 +1905,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -2218,7 +2218,7 @@ checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -2248,7 +2248,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -2571,9 +2571,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.114"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
@@ -2625,7 +2625,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -2685,7 +2685,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -2847,7 +2847,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3078,7 +3078,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
"wasm-bindgen-shared",
]
@@ -3143,7 +3143,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3154,7 +3154,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -3430,7 +3430,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
"syn 2.0.117",
]
[[package]]
@@ -1,182 +0,0 @@
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>
);
}
+4 -6
View File
@@ -93,7 +93,6 @@ export function BigGraph({
config,
graphId,
syncGraph,
uplotRefOut,
onRegisterForExport,
onUnregisterFromExport,
}: BigGraphProps & {
@@ -458,7 +457,6 @@ export function BigGraph({
useBigGraphEffects({
containerRef,
uplotRef,
uplotRefOut,
startTimeRef,
manualScaleRef,
lastProcessedCountRef,
@@ -497,8 +495,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 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">
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
<div className="mt-1 flex items-center gap-4">
<Icon
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
className="size-6 text-gray-600"
@@ -509,9 +507,9 @@ export function BigGraph({
</h2>
</div>
<div className="flex shrink-0 items-center gap-4">
<div className="flex items-center gap-4">
{normalizedSeries.length === 1 && (
<ControlCard className="rounded-lg border-gray-200/80 bg-gray-50/80 px-4 py-3 shadow-sm">
<ControlCard className="rounded-md px-4 py-3">
<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)}
+10 -32
View File
@@ -21,7 +21,6 @@ export function GraphControls({
onSwitchToLive,
onSwitchToHistorical,
onExport,
onAddMarker,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
showFromTimestamp,
onShowFromChange,
@@ -103,27 +102,16 @@ export function GraphControls({
Live
</TouchButton>
{(onExport || onAddMarker) && (
{onExport && (
<>
<div className="mx-2 h-8 w-px bg-gray-200"></div>
{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>
)}
<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>
@@ -139,7 +127,6 @@ export function FloatingControlPanel({
onSwitchToLive,
onSwitchToHistorical,
onExport,
onAddMarker,
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
showFromTimestamp,
onShowFromChange,
@@ -231,19 +218,10 @@ export function FloatingControlPanel({
>
Live
</TouchButton>
{isExpanded && (onExport || onAddMarker) && (
{isExpanded && onExport && (
<div className="h-8 w-px bg-gray-200"></div>
)}
{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 && (
{onExport && (
<TouchButton
onClick={onExport}
variant="outline"
@@ -1,393 +0,0 @@
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}
/>
);
}
@@ -1,49 +0,0 @@
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,12 +1,9 @@
import React, { useState } from "react";
import React 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,
@@ -53,71 +50,21 @@ export function SyncedGraphControls({
);
}
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}
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;
}) {
const defaultSync = useGraphSync();
const finalProps = controlProps || defaultSync.controlProps;
return (
<SyncedFloatingControlPanelInner
controlProps={controlProps}
<FloatingControlPanel
{...finalProps}
timeWindowOptions={timeWindowOptions}
machineId={machineId}
{...props}
/>
);
@@ -15,7 +15,6 @@ import { normalizeDataSeries } from "./animation";
export function createChart({
containerRef,
uplotRef,
uplotRefOut,
newData,
config,
colors,
@@ -198,9 +197,6 @@ 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;
}
@@ -502,10 +498,6 @@ export function createChart({
containerRef.current,
);
if (uplotRefOut) {
uplotRefOut.current = uplotRef.current;
}
// Create handler callbacks
const handlerCallbacks: HandlerCallbacks = {
updateYAxisScale,
@@ -84,27 +84,6 @@ 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++;
});
@@ -311,119 +290,6 @@ function createGraphLineDataSheet(graphLine: {
});
}
function createGraphLineMarkerReportSheet(graphLine: {
graphTitle: string;
lineTitle: string;
series: TimeSeries;
color?: string;
unit?: Unit;
renderValue?: (value: number) => string;
config: GraphConfig;
targetLines: GraphLine[];
}): any[][] {
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
// Initialize Report Data and Header
const reportData: any[][] = [
[`Marker Report: ${graphLine.lineTitle}`],
["Graph", graphLine.graphTitle],
["Line Name", graphLine.lineTitle],
["", ""],
["--- Data Point Marker Status ---", ""],
["", ""],
];
if (timestamps.length === 0) {
reportData.push(["No data points to report"]);
return reportData;
}
// Filter User Markers
const allTargetLines = graphLine.targetLines.filter(
(line) => line.show !== false,
);
const userMarkers = allTargetLines.filter(
(line) => line.type === "user_marker" && line.label,
);
// Map Markers to Closest Data Point Index
const markerIndexMap = new Map<
number,
{ label: string; originalTimestamp: number }
>();
userMarkers.forEach((line) => {
const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp
let closestDataPointIndex = -1;
let minTimeDifference = Infinity;
// Find the data point with the closest timestamp
timestamps.forEach((ts, index) => {
const difference = Math.abs(ts - markerTime);
if (difference < minTimeDifference) {
minTimeDifference = difference;
closestDataPointIndex = index;
}
});
// Store the marker data at the index of the closest data point
if (closestDataPointIndex !== -1) {
markerIndexMap.set(closestDataPointIndex, {
label: line.label || "User Marker",
originalTimestamp: markerTime,
});
}
});
// Add the final header before the timestamp report starts
reportData.push(["--- BEGIN DETAILED REPORT ---", ""], ["", ""]);
// Handle case where no user markers were created
if (userMarkers.length === 0) {
reportData.push(["No user-created markers found.", ""]);
}
timestamps.forEach((dataPointTimestamp, index) => {
const value = values[index];
const markerData = markerIndexMap.get(index);
let finalMarkerLabel = "";
let timeToDisplay = dataPointTimestamp; // Default to data sample time
if (markerData) {
finalMarkerLabel = `${markerData.label}`;
timeToDisplay = markerData.originalTimestamp;
}
// Format the time (using timeToDisplay)
const formattedTime = new Date(timeToDisplay)
.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
.replace(/ /g, "");
// Row 1: Timestamp
reportData.push(["Timestamp", formattedTime]);
// Row 2: Value
const formattedValue = graphLine.renderValue
? graphLine.renderValue(value)
: value?.toFixed(3) || "";
reportData.push([`Value (${unitSymbol})`, formattedValue]);
// Row 3: Marker Name
reportData.push(["Marker", finalMarkerLabel]);
// Separator
reportData.push(["", ""]);
});
return reportData;
}
// Ensure sheet names are unique and valid for Excel
function generateUniqueSheetName(
name: string,
-3
View File
@@ -10,9 +10,6 @@ export {
AutoSyncedBigGraph,
} from "./SyncedComponents";
// Context
export { MarkerProvider } from "./MarkerContext";
// Hooks
export { useGraphSync } from "./useGraphSync";
+1 -8
View File
@@ -1,4 +1,3 @@
import type uPlot from "uplot";
import { IconName } from "@/components/Icon";
import { Unit } from "@/control/units";
import { TimeSeries } from "@/lib/timeseries";
@@ -29,7 +28,7 @@ export type PropGraphSync = {
// Configuration types for additional lines
export type GraphLine = {
type: "threshold" | "target" | "user_marker"; // TODO: redundant or not?
type: "threshold" | "target";
value: number;
targetSeries?: TimeSeries;
color: string;
@@ -37,7 +36,6 @@ export type GraphLine = {
width?: number;
dash?: number[];
show?: boolean;
markerTimestamp?: number;
};
export type GraphConfig = {
@@ -77,8 +75,6 @@ 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 = {
@@ -93,7 +89,6 @@ export type ControlProps = {
onSwitchToLive: () => void;
onSwitchToHistorical: (origin: SwitchOrigin) => void;
onExport?: () => void;
onAddMarker?: () => void;
timeWindowOptions?: TimeWindowOption[];
showFromTimestamp?: number | null;
onShowFromChange?: (timestamp: number | null) => void;
@@ -116,8 +111,6 @@ 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,11 +1,5 @@
/* eslint-disable react-compiler/react-compiler */
import {
useEffect,
MutableRefObject,
RefObject,
useRef,
useState,
} from "react";
import { useEffect, RefObject, useRef, useState } from "react";
import uPlot from "uplot";
import { seriesToUPlotData } from "@/lib/timeseries";
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
@@ -19,7 +13,6 @@ 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 };
@@ -78,7 +71,6 @@ interface UseBigGraphEffectsProps {
export function useBigGraphEffects({
containerRef,
uplotRef,
uplotRefOut,
startTimeRef,
manualScaleRef,
lastProcessedCountRef,
@@ -265,7 +257,6 @@ export function useBigGraphEffects({
const cleanup = createChart({
containerRef,
uplotRef,
uplotRefOut,
newData,
config,
colors,
@@ -300,7 +291,6 @@ export function useBigGraphEffects({
cleanup?.();
uplotRef.current?.destroy();
uplotRef.current = null;
if (uplotRefOut) uplotRefOut.current = null;
stopAnimations(animationRefs);
setIsChartCreated(false);
chartCreatedRef.current = false;
@@ -1,41 +0,0 @@
import { useCallback } from "react";
import { useMarkerStore, type Marker } from "@/stores/markerStore";
// Stable empty array to avoid "getSnapshot should be cached" infinite loop
const EMPTY_MARKERS: Marker[] = [];
export function useMarkerManager(machineId: string) {
const markers = useMarkerStore((state) => {
const list = state.markersByMachine[machineId];
return list ?? EMPTY_MARKERS;
});
const addMarkerAction = useMarkerStore((state) => state.addMarker);
const removeMarkerAction = useMarkerStore((state) => state.removeMarker);
const clearMarkersAction = useMarkerStore((state) => state.clearMarkers);
const addMarker = useCallback(
(name: string, timestamp: number, color?: string, value?: number) => {
addMarkerAction(machineId, { name, timestamp, color, value });
return { name, timestamp, color, value } as Marker;
},
[machineId, addMarkerAction],
);
const removeMarker = useCallback(
(timestamp: number) => {
removeMarkerAction(machineId, timestamp);
},
[machineId, removeMarkerAction],
);
const clearMarkers = useCallback(() => {
clearMarkersAction(machineId);
}, [machineId, clearMarkersAction]);
return {
markers,
addMarker,
removeMarker,
clearMarkers,
};
}
+2 -2
View File
@@ -36,7 +36,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] 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-50 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-[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",
"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",
className,
)}
{...props}
@@ -1,13 +1,12 @@
import { Page } from "@/components/Page";
import {
MarkerProvider,
AutoSyncedBigGraph,
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 {
@@ -253,102 +252,87 @@ export function Extruder2GraphsPage() {
return (
<Page className="pb-25">
<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"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
currentTimeSeries={nozzleTemperature}
machineId="extruder-graphs"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
currentTimeSeries={combinedPower}
machineId="extruder-graphs"
/>
<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"
/>
<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}
machineId="extruder-graphs"
<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>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
/>
<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>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -1,11 +1,10 @@
import { Page } from "@/components/Page";
import {
MarkerProvider,
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
} from "@/components/graph";
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
import React from "react";
import { useExtruder3 } from "./useExtruder";
@@ -253,102 +252,87 @@ export function Extruder3GraphsPage() {
return (
<Page className="pb-25">
<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"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
currentTimeSeries={nozzleTemperature ?? null}
machineId="extruder-graphs"
/>
<GraphWithMarkerControls
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
currentTimeSeries={combinedPower ?? null}
machineId="extruder-graphs"
/>
<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"
/>
<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}
machineId="extruder-graphs"
<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>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={temperatureData}
config={temperatureConfig}
unit="C"
renderValue={(value) => value.toFixed(1)}
graphId="combined-temperatures"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={powerData}
config={powerConfig}
unit="W"
renderValue={(value) => value.toFixed(1)}
graphId="combined-power"
/>
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData: motorCurrent,
color: "#3b82f6",
}}
config={currentConfig}
unit="A"
renderValue={(value) => value.toFixed(2)}
graphId="motor-current"
/>
<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>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -1,153 +0,0 @@
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;
}
+38 -54
View File
@@ -1,7 +1,6 @@
import { Page } from "@/components/Page";
import {
AutoSyncedBigGraph,
MarkerProvider,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
@@ -9,8 +8,6 @@ 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();
@@ -100,58 +97,45 @@ export function Mock1GraphPage() {
return (
<Page className="pb-27">
<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}
machineId="mock-graphs"
<div className="flex flex-col gap-4">
<AutoSyncedBigGraph
syncHook={syncHook}
newData={singleData}
config={singleGraphConfig}
unit={"mm"}
renderValue={(value) => value.toFixed(3)}
graphId="single-graph1"
/>
</MarkerProvider>
<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>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -1,6 +1,6 @@
import { Page } from "@/components/Page";
import {
MarkerProvider,
AutoSyncedBigGraph,
SyncedFloatingControlPanel,
useGraphSync,
type GraphConfig,
@@ -11,7 +11,6 @@ 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 {
@@ -28,55 +27,50 @@ export function Winder2GraphsPage() {
return (
<Page className="pb-27">
<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)}
/>
<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)}
/>
</div>
<SpoolProgressGraph
syncHook={syncHook}
newData={spoolProgress}
unit="m"
renderValue={(value) => roundToDecimals(value, 2)}
/>
</div>
</div>
<SyncedFloatingControlPanel
controlProps={syncHook.controlProps}
machineId="winder2"
/>
</MarkerProvider>
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
</Page>
);
}
@@ -104,7 +98,7 @@ export function SpoolRpmGraph({
};
return (
<GraphWithMarkerControls
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
@@ -114,8 +108,6 @@ export function SpoolRpmGraph({
renderValue={renderValue}
config={config}
graphId="spool-rpm"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -165,7 +157,7 @@ export function TraversePositionGraph({
};
return (
<GraphWithMarkerControls
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
@@ -176,8 +168,6 @@ export function TraversePositionGraph({
renderValue={renderValue}
config={config}
graphId="traverse-position"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -203,7 +193,7 @@ export function TensionArmAngleGraph({
};
return (
<GraphWithMarkerControls
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
@@ -213,8 +203,6 @@ export function TensionArmAngleGraph({
renderValue={renderValue}
config={config}
graphId="tension-arm-angle"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -239,9 +227,8 @@ export function SpoolProgressGraph({
exportFilename: "spool_progress",
};
// NOTE: Assuming this graph starts at 0, and the max is the total capacity.
return (
<GraphWithMarkerControls
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
@@ -251,8 +238,6 @@ export function SpoolProgressGraph({
renderValue={renderValue}
config={config}
graphId="spool-progress"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
@@ -294,7 +279,7 @@ export function PullerSpeedGraph({
};
return (
<GraphWithMarkerControls
<AutoSyncedBigGraph
syncHook={syncHook}
newData={{
newData,
@@ -305,8 +290,6 @@ export function PullerSpeedGraph({
renderValue={renderValue}
config={config}
graphId="puller-speed"
currentTimeSeries={newData}
machineId="winder2"
/>
);
}
-131
View File
@@ -1,131 +0,0 @@
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,
},
),
);
+2 -13
View File
@@ -3,19 +3,8 @@
home.packages = with pkgs; [ pkgs.qitechPackages.electron ];
# Add autostart entry for the QiTech electron app
xdg.configFile."autostart/de.qitech.control-electron.desktop".text = ''
[Desktop Entry]
Type=Application
Name=QiTech Control
Comment=QiTech Control
Exec=qitech-control-electron
Icon=de.qitech.control-electron
Terminal=false
StartupWMClass=QiTech Control
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Phase=Applications
'';
xdg.configFile."autostart/de.qitech.control-electron.desktop".source =
"${pkgs.qitechPackages.electron}/share/applications/de.qitech.control-electron.desktop";
dconf.settings = {
# Set GNOME wallpaper