Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4e0f78c6 | ||
|
|
c85a4d3ebe | ||
|
|
635c08cfc5 | ||
|
|
1ecdf33d29 | ||
|
|
d88a87c8a2 | ||
|
|
94950c3c38 | ||
|
|
ebc6cb9c40 | ||
|
|
921f0fa445 | ||
|
|
2ab668b2f1 | ||
|
|
3a9801b97c | ||
|
|
612b6ecb25 | ||
|
|
677f51ae33 | ||
|
|
bf28b41afd | ||
|
|
723072c5bc | ||
|
|
3813ce70bf | ||
|
|
b523b3de21 | ||
|
|
09b1eb9433 | ||
|
|
c7feef6bcd | ||
|
|
84d2766ca9 | ||
|
|
b4d2974f45 | ||
|
|
082d5dc952 | ||
|
|
55af1120bf | ||
|
|
35864b4de7 | ||
|
|
621cb1913f | ||
|
|
3e3e3f1c0b | ||
|
|
cdf0374020 | ||
|
|
ec406889f7 | ||
|
|
b731a3bc36 | ||
|
|
27e15820f7 |
No files matched your search
@@ -0,0 +1,14 @@
|
||||
## What does this PR do / add / change?
|
||||
|
||||
<!-- Describe the purpose of this PR. What problem does it solve or what feature does it add? -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Include screenshots for any UI changes. Remove this section if not applicable. -->
|
||||
|
||||
## Checklist before opening the pr
|
||||
|
||||
- [ ] Tested locally
|
||||
- [ ] Tested on the machine (if hardware interaction is involved)
|
||||
- [ ] No format errors (`cargo fmt` / `npm run format`)
|
||||
- [ ] No build errors (`cargo build` / `npm run build`)
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: electron
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NIX_PATH: nixpkgs=channel:nixos-25.05
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_VERSION: "1.86"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+52
-12
@@ -1,8 +1,8 @@
|
||||
# REST API `/api/v2`
|
||||
|
||||
The Qitech Control Panel exposes a small HTTP interface for discovering machines and reading their current values. All examples below assume you are connected to the panel’s Ethernet subnet and talk directly to the panel at:
|
||||
The panel behaves like any other NetworkManager-managed linux and is able to either receive an IP-Address over DHCP or a statically defined one. Where the static IP is most likely the easiest to work with.
|
||||
|
||||
- **Base URL:** `http://10.10.10.1:3001`
|
||||
- **Base URL:** `https://IP_ADDRESS`
|
||||
|
||||
> **Schema note:** This documentation intentionally stays light on field details beyond what is shown in the examples. For the exact datatypes and complete payload shapes, refer to the corresponding Rust types (linked below).
|
||||
|
||||
@@ -10,17 +10,57 @@ The Qitech Control Panel exposes a small HTTP interface for discovering machines
|
||||
|
||||
## Authentication
|
||||
|
||||
The panel does **not** perform HTTP authentication. The expected security model is **network-level isolation**: anything that can send packets to the panel’s Ethernet interface is treated as trusted. This matches common security assumptions in EtherCAT-style control networks. It is therefore suggested to run the entire production line in isolated operation.
|
||||
The panel performs Basic-Auth authentication.
|
||||
For EVERY request made over HTTPS, Basic Auth is required.
|
||||
|
||||
The panel is configured to administer its own subnet `10.10.10.0/24` via DHCP, while Wi-Fi can be used for upstream internet connectivity. To let the outside world communicate with the production line, a router should be placed at the network bondary bridging the production line's subnet and the rest of the network. The router can run in **client/bridge mode** on the line's subnet and expose the panel's API via **port forwading** or through a **reverse proxy** where one can add authentication, logging, rate limiting, etc.
|
||||
To set the password on the Panel for Basic Auth, do the following:
|
||||
1. Open a Terminal
|
||||
2. cd /home/qitech/control
|
||||
3. ./credentials.sh
|
||||
|
||||
The following graphic depicts the suggested network topology for a single production line. Many lines could potentially share the same router.
|
||||
The credentials.sh script sets and prints the username and password for the api ONCE in the console.
|
||||
Afterwards its accessible in machine_password in /tmp/ until the machine shuts down.
|
||||
If you ever forget your password you can simply run ./credentials.sh again, resetting the password used, however the username will always be machine
|
||||
|
||||
<p align="center">
|
||||
<img width="769" height="528" alt="Suggested network topology" src="./assets/subnet.png" />
|
||||
</p>
|
||||
Example request with curl:
|
||||
```
|
||||
# username=machine
|
||||
# password=VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
# With -k you can ignore curl's self signed certificate error
|
||||
curl https://192.168.130.195/api/v2/machine -k -u machine:VoMZKZK4K4ZIvON+OU2ZcCXy
|
||||
```
|
||||
|
||||
|
||||
## Handling Self signed Certificate Warnings
|
||||
|
||||
For HTTPS we use self-signed certificates.
|
||||
These are untrusted by default in browsers,tools like curl and https libraries.
|
||||
|
||||
For most programs you can either ignore this warning or you can manually trust the certificate on your system.
|
||||
How these are installed depends on your system.
|
||||
|
||||
On the panel pc, the root Certificate is located at: /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
|
||||
|
||||
You need to copy this file to an usb drive or copy it some other way and add it as a trusted root certificate in your OS.
|
||||
|
||||
Please refer to your OS' documentation or search the Internet about how to install a trusted SSL certificate.
|
||||
|
||||
|
||||
## Bypassing HTTPS and Authentication
|
||||
If you have an isolated network and dont wish to perform Authentication, then you can achieve this by:
|
||||
1. Forking our repository
|
||||
2. Changing the Open Ports in nixos/os/configuration.nix:
|
||||
```
|
||||
# All the way at the Bottom
|
||||
networking.firewall.allowedUDPPorts = [ 53 67 69 ];
|
||||
# Change this
|
||||
networking.firewall.allowedTCPPorts = [ 443 ];
|
||||
# To this
|
||||
networking.firewall.allowedTCPPorts = [ 3001 ];
|
||||
```
|
||||
|
||||
Again, you should only ever do this if you have your own reverse proxy with authentication or you have an isolated network.
|
||||
|
||||
If DNS is available on the subnet, you may be able to resolve `qitech.control`; otherwise use the static address `10.10.10.1`.
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +78,7 @@ Each machine also includes a `legacy_id` to support older **v1** workflows. If a
|
||||
### Example request
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine"
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -119,7 +159,7 @@ To receive continuous updates (via WebSockets), subscribe to the machine namespa
|
||||
### Example request (mock machine)
|
||||
|
||||
```bash
|
||||
curl -X GET "http://10.10.10.1:3001/api/v2/machine/mock/57922"
|
||||
curl -X GET "https://IP_ADDRESS/api/v2/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
@@ -174,7 +214,7 @@ The API does **not** return the newly-applied state in the POST response. The pa
|
||||
curl -X POST \
|
||||
-d \
|
||||
-H "Content-Type: application/json" \
|
||||
"http://10.10.10.1:3001/api/v1/machine/mock/57922"
|
||||
"https://IP_ADDRESS/api/v1/machine/mock/57922" -u machine:YOUR_PASSWORD
|
||||
```
|
||||
|
||||
### Example response
|
||||
|
||||
Generated
+5
-22
@@ -71,7 +71,7 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron": "^40.6.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@@ -5196,15 +5196,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "37.10.3",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-37.10.3.tgz",
|
||||
"integrity": "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g==",
|
||||
"version": "40.6.1",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-40.6.1.tgz",
|
||||
"integrity": "sha512-u9YfoixttdauciHV9Ut9Zf3YipJoU093kR1GSYTTXTAXqhiXI0G1A0NnL/f0O2m2UULCXaXMf2W71PloR6V9pQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5285,16 +5285,6 @@
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "22.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
|
||||
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
@@ -5330,13 +5320,6 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"@types/react": "^19.1.16",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"electron": "^37.3.1",
|
||||
"electron": "^40.6.1",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { TouchButton } from "../touch/TouchButton";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
import { TargetDashOverlay } from "./TargetDashOverlay";
|
||||
|
||||
// Collects lines connected to visible series for rendering
|
||||
const getVisibleLines = (data: any, visibleSeries: boolean[]) => {
|
||||
@@ -92,6 +93,7 @@ export function BigGraph({
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
uplotRefOut,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
}: BigGraphProps & {
|
||||
@@ -456,6 +458,7 @@ export function BigGraph({
|
||||
useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
@@ -494,8 +497,8 @@ export function BigGraph({
|
||||
return (
|
||||
<div className="h-[50vh] w-full">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white shadow">
|
||||
<div className="flex items-center justify-between pt-4 pr-5 pb-6 pl-6">
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 pt-4 pr-5 pb-6 pl-6">
|
||||
<div className="mt-1 flex min-w-0 flex-1 items-center gap-4">
|
||||
<Icon
|
||||
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
||||
className="size-6 text-gray-600"
|
||||
@@ -506,9 +509,9 @@ export function BigGraph({
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
{normalizedSeries.length === 1 && (
|
||||
<ControlCard className="rounded-md px-4 py-3">
|
||||
<ControlCard className="rounded-lg border-gray-200/80 bg-gray-50/80 px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-base text-gray-600">
|
||||
<span className="font-mono leading-none font-bold text-gray-900">
|
||||
{formatDisplayValue(displayValue, renderValue)}
|
||||
@@ -595,10 +598,18 @@ export function BigGraph({
|
||||
|
||||
<div className="flex-1 overflow-hidden rounded-b-3xl pt-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`h-full w-full overflow-hidden transition-opacity duration-100`}
|
||||
className={`relative h-full w-full overflow-hidden transition-opacity duration-100`}
|
||||
style={{ backgroundColor: colors.background }}
|
||||
/>
|
||||
>
|
||||
<div ref={containerRef} className="h-full w-full overflow-hidden" />
|
||||
<TargetDashOverlay
|
||||
uplotRef={uplotRef}
|
||||
newData={newData}
|
||||
config={enhancedConfig}
|
||||
selectedTimeWindow={selectedTimeWindow}
|
||||
isLiveMode={isLiveMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function GraphControls({
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
onAddMarker,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
@@ -102,16 +103,27 @@ export function GraphControls({
|
||||
Live
|
||||
</TouchButton>
|
||||
|
||||
{onExport && (
|
||||
{(onExport || onAddMarker) && (
|
||||
<>
|
||||
<div className="mx-2 h-8 w-px bg-gray-200"></div>
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
{onAddMarker && (
|
||||
<TouchButton
|
||||
onClick={onAddMarker}
|
||||
variant="outline"
|
||||
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Marker
|
||||
</TouchButton>
|
||||
)}
|
||||
{onExport && (
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
className="h-auto bg-green-600 px-3 py-3 text-base font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Export
|
||||
</TouchButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,6 +139,7 @@ export function FloatingControlPanel({
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
onAddMarker,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
showFromTimestamp,
|
||||
onShowFromChange,
|
||||
@@ -218,10 +231,19 @@ export function FloatingControlPanel({
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
{isExpanded && onExport && (
|
||||
{isExpanded && (onExport || onAddMarker) && (
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
)}
|
||||
{onExport && (
|
||||
{isExpanded && onAddMarker && (
|
||||
<TouchButton
|
||||
onClick={onAddMarker}
|
||||
variant="outline"
|
||||
className="h-auto bg-blue-600 px-3 py-3 text-base font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Add Marker
|
||||
</TouchButton>
|
||||
)}
|
||||
{isExpanded && onExport && (
|
||||
<TouchButton
|
||||
onClick={onExport}
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import type uPlot from "uplot";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import { TimeSeries, TimeSeriesValue } from "@/lib/timeseries";
|
||||
import { Unit } from "@/control/units";
|
||||
import { useMarkerManager } from "./useMarkerManager";
|
||||
import { useMarkerContext } from "./MarkerContext";
|
||||
|
||||
type TimeSeriesData = {
|
||||
newData: TimeSeries | null;
|
||||
title?: string;
|
||||
color?: string;
|
||||
lines?: any[];
|
||||
};
|
||||
|
||||
type GraphWithMarkerControlsProps = {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
newData: TimeSeriesData | TimeSeriesData[];
|
||||
config: GraphConfig;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
graphId: string;
|
||||
currentTimeSeries: TimeSeries | null;
|
||||
machineId?: string;
|
||||
markers?: Array<{
|
||||
timestamp: number;
|
||||
name: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const MARKER_HIT_WIDTH = 28;
|
||||
|
||||
const LABEL_TOP_OFFSET = 30;
|
||||
|
||||
function applyLabelHighlight(label: HTMLDivElement, highlighted: boolean) {
|
||||
if (highlighted) {
|
||||
label.style.zIndex = "20";
|
||||
label.style.boxShadow = "0 4px 12px rgba(0,0,0,0.2)";
|
||||
label.style.borderColor = "rgb(59 130 246)";
|
||||
} else {
|
||||
label.style.zIndex = "";
|
||||
label.style.boxShadow = "0 2px 6px rgba(0,0,0,0.15)";
|
||||
label.style.borderColor = "rgba(0,0,0,0.12)";
|
||||
}
|
||||
}
|
||||
|
||||
function unhighlightAllLabels(container: HTMLElement) {
|
||||
container.querySelectorAll<HTMLDivElement>(".marker-label").forEach((el) => {
|
||||
applyLabelHighlight(el, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply or restore overlay styles (avoids React Compiler "mutates immutable" on inline DOM writes)
|
||||
function setOverlayOverflow(
|
||||
container: HTMLElement,
|
||||
parent: HTMLElement | null,
|
||||
visible: boolean,
|
||||
previous: { overflow: string; position: string; parentOverflow: string },
|
||||
) {
|
||||
if (visible) {
|
||||
container.style.overflow = "visible";
|
||||
container.style.position = "relative";
|
||||
if (parent) parent.style.overflow = "visible";
|
||||
} else {
|
||||
container.style.overflow = previous.overflow;
|
||||
container.style.position = previous.position;
|
||||
if (parent) parent.style.overflow = previous.parentOverflow;
|
||||
}
|
||||
}
|
||||
|
||||
// Duration (ms) to hold pointer on marker before it is deleted
|
||||
const LONG_PRESS_DELETE_MS = 5000;
|
||||
|
||||
// Build marker DOM: one wrapper (hit area) for hover/tap, contains line, point, label.
|
||||
// Optional onLongPress: called after LONG_PRESS_DELETE_MS hold (e.g. to delete marker).
|
||||
function createMarkerElement(
|
||||
u: uPlot,
|
||||
overlayRect: DOMRect,
|
||||
timestamp: number,
|
||||
name: string,
|
||||
value: number,
|
||||
color?: string,
|
||||
onLongPress?: () => void,
|
||||
): HTMLDivElement {
|
||||
const plotRect = u.rect;
|
||||
const plotLeftInOverlay = plotRect.left - overlayRect.left;
|
||||
const plotTopInOverlay = plotRect.top - overlayRect.top;
|
||||
const plotHeight = plotRect.height;
|
||||
const plotWidth = plotRect.width;
|
||||
|
||||
let xInPlot = u.valToPos(timestamp, "x", false);
|
||||
xInPlot = Math.max(0, Math.min(plotWidth, xInPlot));
|
||||
const xPos = plotLeftInOverlay + xInPlot;
|
||||
let yInPlot = u.valToPos(value, "y", false);
|
||||
yInPlot = Math.max(0, Math.min(plotHeight, yInPlot));
|
||||
|
||||
const lineColor = color || "rgba(0, 0, 0, 0.5)";
|
||||
const pointColor = color || "rgba(0, 0, 0, 0.8)";
|
||||
const half = MARKER_HIT_WIDTH / 2;
|
||||
|
||||
const wrapperTop = plotTopInOverlay - LABEL_TOP_OFFSET;
|
||||
const plotStartInWrapper = LABEL_TOP_OFFSET;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.style.position = "absolute";
|
||||
wrapper.style.left = `${xPos - half}px`;
|
||||
wrapper.style.top = `${wrapperTop}px`;
|
||||
wrapper.style.width = `${MARKER_HIT_WIDTH}px`;
|
||||
wrapper.style.height = `${plotHeight + 50 + plotStartInWrapper}px`;
|
||||
wrapper.style.cursor = "pointer";
|
||||
wrapper.style.touchAction = "manipulation";
|
||||
wrapper.style.zIndex = "10";
|
||||
wrapper.className = "marker-wrapper";
|
||||
|
||||
const line = document.createElement("div");
|
||||
line.style.position = "absolute";
|
||||
line.style.left = `${half - 1}px`;
|
||||
line.style.top = `${plotStartInWrapper}px`;
|
||||
line.style.height = `${plotHeight}px`;
|
||||
line.style.width = "2px";
|
||||
line.style.background = lineColor;
|
||||
line.style.pointerEvents = "none";
|
||||
line.title = name;
|
||||
line.className = "vertical-marker";
|
||||
|
||||
const point = document.createElement("div");
|
||||
point.style.position = "absolute";
|
||||
point.style.left = `${half}px`;
|
||||
point.style.top = `${plotStartInWrapper + yInPlot}px`;
|
||||
point.style.width = "8px";
|
||||
point.style.height = "8px";
|
||||
point.style.borderRadius = "50%";
|
||||
point.style.background = pointColor;
|
||||
point.style.transform = "translate(-50%, -50%)";
|
||||
point.style.border = "2px solid white";
|
||||
point.style.pointerEvents = "none";
|
||||
point.title = name;
|
||||
point.className = "marker-point";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.textContent = name;
|
||||
label.title = name;
|
||||
label.style.position = "absolute";
|
||||
label.style.left = `${half}px`;
|
||||
label.style.top = "6px";
|
||||
label.style.transform = "translateX(-50%)";
|
||||
label.style.color = "rgb(30 41 59)";
|
||||
label.style.padding = "4px 8px";
|
||||
label.style.fontSize = "12px";
|
||||
label.style.fontWeight = "600";
|
||||
label.style.whiteSpace = "nowrap";
|
||||
label.style.maxWidth = "160px";
|
||||
label.style.overflow = "hidden";
|
||||
label.style.textOverflow = "ellipsis";
|
||||
label.style.background = "rgba(255, 255, 255, 1)";
|
||||
label.style.borderRadius = "6px";
|
||||
label.style.boxShadow = "0 2px 8px rgba(0,0,0,0.2)";
|
||||
label.style.border = "1px solid rgba(0,0,0,0.2)";
|
||||
label.style.pointerEvents = "none";
|
||||
label.style.transition = "box-shadow 0.15s ease, border-color 0.15s ease";
|
||||
label.style.zIndex = "1";
|
||||
label.className = "marker-label";
|
||||
|
||||
const highlight = () => applyLabelHighlight(label, true);
|
||||
const unhighlight = () => applyLabelHighlight(label, false);
|
||||
|
||||
wrapper.addEventListener("mouseenter", highlight);
|
||||
wrapper.addEventListener("mouseleave", unhighlight);
|
||||
wrapper.addEventListener("click", () => highlight());
|
||||
|
||||
// Long-press: hold 5s on marker to delete it (timer on pointerdown, clear on up/leave/cancel)
|
||||
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const clearLongPressTimer = () => {
|
||||
if (longPressTimer !== null) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
wrapper.addEventListener("pointerdown", () => {
|
||||
if (!onLongPress) return;
|
||||
clearLongPressTimer();
|
||||
longPressTimer = setTimeout(() => {
|
||||
longPressTimer = null;
|
||||
onLongPress(); // removes this marker
|
||||
}, LONG_PRESS_DELETE_MS);
|
||||
});
|
||||
wrapper.addEventListener("pointerup", clearLongPressTimer);
|
||||
wrapper.addEventListener("pointerleave", clearLongPressTimer);
|
||||
wrapper.addEventListener("pointercancel", clearLongPressTimer);
|
||||
|
||||
wrapper.appendChild(line);
|
||||
wrapper.appendChild(point);
|
||||
wrapper.appendChild(label);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function GraphWithMarkerControlsContent({
|
||||
syncHook,
|
||||
newData,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
graphId,
|
||||
currentTimeSeries,
|
||||
machineId: providedMachineId,
|
||||
markers: providedMarkers,
|
||||
graphWrapperRef,
|
||||
uplotRefOut,
|
||||
}: GraphWithMarkerControlsProps & {
|
||||
graphWrapperRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRefOut: React.MutableRefObject<uPlot | null>;
|
||||
}) {
|
||||
const { setMachineId, setCurrentTimestamp, setCurrentValue } =
|
||||
useMarkerContext();
|
||||
|
||||
// Auto-detect machineId from graphId if not provided (extract base name)
|
||||
// e.g., "pressure-graph" -> "pressure", "extruder-graphs" -> "extruder-graphs"
|
||||
const machineId = providedMachineId || graphId.split("-")[0] || "default";
|
||||
|
||||
// Update context with machineId, current timestamp and value for marker creation
|
||||
useEffect(() => {
|
||||
setMachineId(machineId);
|
||||
}, [machineId, setMachineId]);
|
||||
|
||||
useEffect(() => {
|
||||
const curr = currentTimeSeries?.current;
|
||||
if (curr?.timestamp != null) {
|
||||
setCurrentTimestamp(curr.timestamp);
|
||||
setCurrentValue(curr.value);
|
||||
} else {
|
||||
setCurrentValue(null);
|
||||
}
|
||||
}, [
|
||||
currentTimeSeries?.current?.timestamp,
|
||||
currentTimeSeries?.current?.value,
|
||||
setCurrentTimestamp,
|
||||
setCurrentValue,
|
||||
]);
|
||||
|
||||
// Use provided markers or load from marker manager
|
||||
const markerManager = useMarkerManager(machineId);
|
||||
const markers = providedMarkers || markerManager.markers;
|
||||
|
||||
// Time Tick for forcing marker redraw
|
||||
const [timeTick, setTimeTick] = useState(0);
|
||||
|
||||
// Set interval to force redraw the marker effect frequently (e.g., every 50ms)
|
||||
useEffect(() => {
|
||||
if (!currentTimeSeries?.current) return;
|
||||
const intervalId = setInterval(() => {
|
||||
setTimeTick((prev) => prev + 1);
|
||||
}, 50);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [currentTimeSeries?.current]);
|
||||
|
||||
// Marker Drawing Effect: use uPlot instance for exact valToPos so point sits on the curve
|
||||
useEffect(() => {
|
||||
const u = uplotRefOut.current;
|
||||
if (!u?.root?.parentElement) return;
|
||||
|
||||
u.syncRect();
|
||||
|
||||
const overlayContainer = u.root.parentElement;
|
||||
const overlayParent = overlayContainer.parentElement;
|
||||
|
||||
const previous = {
|
||||
overflow: overlayContainer.style.overflow,
|
||||
position: overlayContainer.style.position,
|
||||
parentOverflow: overlayParent?.style.overflow ?? "",
|
||||
};
|
||||
setOverlayOverflow(overlayContainer, overlayParent, true, previous);
|
||||
|
||||
const overlayRect = overlayContainer.getBoundingClientRect();
|
||||
|
||||
// Only show markers within the visible time window (saved markers stay in store for export)
|
||||
const xMin = u.scales.x?.min ?? -Infinity;
|
||||
const xMax = u.scales.x?.max ?? Infinity;
|
||||
const visibleMarkers = markers.filter(
|
||||
(m) => m.timestamp >= xMin && m.timestamp <= xMax,
|
||||
);
|
||||
|
||||
const wrappers: HTMLDivElement[] = visibleMarkers.map(
|
||||
({ timestamp, name, value, color }) => {
|
||||
let markerValue = value;
|
||||
if (markerValue === undefined && currentTimeSeries) {
|
||||
const validValues = currentTimeSeries.long.values.filter(
|
||||
(v): v is TimeSeriesValue => v !== null,
|
||||
);
|
||||
if (validValues.length > 0) {
|
||||
// Use linear interpolation between surrounding points for stable positioning
|
||||
// (avoids "closest" flipping between adjacent points which causes jumping)
|
||||
const sorted = [...validValues].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
const after = sorted.find((p) => p.timestamp >= timestamp);
|
||||
const before = [...sorted]
|
||||
.reverse()
|
||||
.find((p) => p.timestamp <= timestamp);
|
||||
if (after && before) {
|
||||
if (after.timestamp === before.timestamp) {
|
||||
markerValue = after.value;
|
||||
} else {
|
||||
const t =
|
||||
(timestamp - before.timestamp) /
|
||||
(after.timestamp - before.timestamp);
|
||||
markerValue = before.value + t * (after.value - before.value);
|
||||
}
|
||||
} else if (after) {
|
||||
markerValue = after.value;
|
||||
} else if (before) {
|
||||
markerValue = before.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (markerValue === undefined) {
|
||||
const yScale = u.scales.y;
|
||||
markerValue =
|
||||
yScale?.min != null && yScale?.max != null
|
||||
? (yScale.min + yScale.max) / 2
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Pass remove callback so long-press (5s) on this marker deletes it
|
||||
return createMarkerElement(
|
||||
u,
|
||||
overlayRect,
|
||||
timestamp,
|
||||
name,
|
||||
markerValue,
|
||||
color,
|
||||
() => markerManager.removeMarker(timestamp),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
overlayContainer
|
||||
.querySelectorAll(".marker-wrapper")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
wrappers.forEach((wrapper) => overlayContainer.appendChild(wrapper));
|
||||
|
||||
const onOverlayClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest(".marker-wrapper")) {
|
||||
unhighlightAllLabels(overlayContainer);
|
||||
}
|
||||
};
|
||||
overlayContainer.addEventListener("click", onOverlayClick, true);
|
||||
|
||||
return () => {
|
||||
overlayContainer.removeEventListener("click", onOverlayClick, true);
|
||||
setOverlayOverflow(overlayContainer, overlayParent, false, previous);
|
||||
};
|
||||
}, [markers, currentTimeSeries, timeTick, uplotRefOut, graphWrapperRef]);
|
||||
|
||||
// Use original config without adding marker lines (markers are overlay elements)
|
||||
const finalConfig = config;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={graphWrapperRef} className="relative">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={newData}
|
||||
config={finalConfig}
|
||||
unit={unit}
|
||||
renderValue={renderValue}
|
||||
graphId={graphId}
|
||||
uplotRefOut={uplotRefOut}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphWithMarkerControls(props: GraphWithMarkerControlsProps) {
|
||||
const graphWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRefOut = useRef<uPlot | null>(null);
|
||||
|
||||
return (
|
||||
<GraphWithMarkerControlsContent
|
||||
{...props}
|
||||
graphWrapperRef={graphWrapperRef}
|
||||
uplotRefOut={uplotRefOut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
|
||||
type MarkerContextType = {
|
||||
machineId: string | null;
|
||||
setMachineId: (id: string) => void;
|
||||
currentTimestamp: number | null;
|
||||
setCurrentTimestamp: (timestamp: number) => void;
|
||||
currentValue: number | null;
|
||||
setCurrentValue: (value: number | null) => void;
|
||||
};
|
||||
|
||||
const MarkerContext = createContext<MarkerContextType | null>(null);
|
||||
|
||||
export function MarkerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [machineId, setMachineId] = useState<string | null>(null);
|
||||
const [currentTimestamp, setCurrentTimestamp] = useState<number | null>(null);
|
||||
const [currentValue, setCurrentValue] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<MarkerContext.Provider
|
||||
value={{
|
||||
machineId,
|
||||
setMachineId,
|
||||
currentTimestamp,
|
||||
setCurrentTimestamp,
|
||||
currentValue,
|
||||
setCurrentValue,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MarkerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMarkerContext() {
|
||||
const context = useContext(MarkerContext);
|
||||
// Return a default context if not within a provider (for non-graph pages)
|
||||
if (!context) {
|
||||
return {
|
||||
machineId: null,
|
||||
setMachineId: () => {},
|
||||
currentTimestamp: null,
|
||||
setCurrentTimestamp: () => {},
|
||||
currentValue: null,
|
||||
setCurrentValue: () => {},
|
||||
};
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { BigGraph } from "./BigGraph";
|
||||
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
import { useGraphSync } from "./useGraphSync";
|
||||
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { useMarkerManager } from "./useMarkerManager";
|
||||
import { AddMarkerDialog } from "./AddMarkerDialog";
|
||||
import { useMarkerContext } from "./MarkerContext";
|
||||
|
||||
export function SyncedBigGraph({
|
||||
syncGraph: externalSyncGraph,
|
||||
@@ -50,21 +53,71 @@ export function SyncedGraphControls({
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
function SyncedFloatingControlPanelInner({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
machineId: machineIdProp,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
machineId?: string;
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
const {
|
||||
machineId: machineIdContext,
|
||||
currentTimestamp,
|
||||
currentValue,
|
||||
} = useMarkerContext();
|
||||
|
||||
// Prefer explicit prop so panel and graphs always use the same store
|
||||
const detectedMachineId = machineIdProp ?? machineIdContext ?? "default";
|
||||
const { addMarker, markers } = useMarkerManager(detectedMachineId);
|
||||
const [isMarkerDialogOpen, setIsMarkerDialogOpen] = useState(false);
|
||||
|
||||
// Always use current timestamp from context (live time from graphs) or current time
|
||||
// As per requirement: "always use the current time"
|
||||
const markerTimestamp = currentTimestamp || Date.now();
|
||||
|
||||
const handleAddMarker = (name: string, timestamp: number, color?: string) => {
|
||||
addMarker(name, timestamp, color, currentValue ?? undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
<>
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
onAddMarker={() => setIsMarkerDialogOpen(true)}
|
||||
{...props}
|
||||
/>
|
||||
<AddMarkerDialog
|
||||
open={isMarkerDialogOpen}
|
||||
onOpenChange={setIsMarkerDialogOpen}
|
||||
onAddMarker={handleAddMarker}
|
||||
currentTimestamp={markerTimestamp}
|
||||
existingNames={markers.map((m) => m.name)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
machineId,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
machineId?: string;
|
||||
}) {
|
||||
return (
|
||||
<SyncedFloatingControlPanelInner
|
||||
controlProps={controlProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
machineId={machineId}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import React, { useEffect, useMemo, useState, useId } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { BigGraphProps, GraphConfig } from "./types";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
|
||||
type OverlayLine = {
|
||||
key: string;
|
||||
color: string;
|
||||
width: number;
|
||||
dash: number[];
|
||||
d: string;
|
||||
};
|
||||
|
||||
type ClipRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function findFirstGe(arr: number[], value: number): number {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = arr.length;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] >= value) {
|
||||
ans = mid;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return ans;
|
||||
}
|
||||
|
||||
function findLastLe(arr: number[], value: number): number {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = -1;
|
||||
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] <= value) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return ans;
|
||||
}
|
||||
|
||||
function buildSteppedPath(
|
||||
u: uPlot,
|
||||
xData: number[],
|
||||
yData: Array<number | null>,
|
||||
): string {
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
|
||||
if (xData.length < 2 || xMin === undefined || xMax === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1);
|
||||
const lastVisibleIdx = Math.min(
|
||||
xData.length - 1,
|
||||
findLastLe(xData, xMax) + 1,
|
||||
);
|
||||
|
||||
if (lastVisibleIdx < firstVisibleIdx) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
let started = false;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) {
|
||||
const value = yData[i];
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
const x = u.valToPos(xData[i], "x", true);
|
||||
const y = u.valToPos(value, "y", true);
|
||||
|
||||
if (!started) {
|
||||
parts.push(`M ${x} ${y}`);
|
||||
started = true;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x !== prevX) {
|
||||
parts.push(`L ${x} ${prevY}`);
|
||||
}
|
||||
if (y !== prevY) {
|
||||
parts.push(`L ${x} ${y}`);
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function buildFlatLatestValuePath(
|
||||
u: uPlot,
|
||||
yData: Array<number | null>,
|
||||
): string {
|
||||
let latestValue: number | null = null;
|
||||
|
||||
for (let i = yData.length - 1; i >= 0; i--) {
|
||||
const value = yData[i];
|
||||
if (value !== null && value !== undefined) {
|
||||
latestValue = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (latestValue === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const y = u.valToPos(latestValue, "y", true);
|
||||
const left = u.bbox.left;
|
||||
const right = u.bbox.left + u.bbox.width;
|
||||
|
||||
return `M ${left} ${y} L ${right} ${y}`;
|
||||
}
|
||||
|
||||
function getHistoricalDashTargets(
|
||||
data: BigGraphProps["newData"],
|
||||
config: GraphConfig,
|
||||
): Array<{
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
}> {
|
||||
const allOriginalSeries = getAllTimeSeries(data);
|
||||
const firstConfigLineDataIndex = 1 + allOriginalSeries.length;
|
||||
let visibleLineIndex = 0;
|
||||
|
||||
const targets: Array<{
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
}> = [];
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show === false) return;
|
||||
|
||||
const dash = line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
if (isHistoricalDashedTarget) {
|
||||
targets.push({
|
||||
dataIndex: firstConfigLineDataIndex + visibleLineIndex,
|
||||
dash: dash!,
|
||||
color: line.color,
|
||||
width: line.width ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
visibleLineIndex++;
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function areDashArraysEqual(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function areOverlayLinesEqual(a: OverlayLine[], b: OverlayLine[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (
|
||||
a[i].key !== b[i].key ||
|
||||
a[i].color !== b[i].color ||
|
||||
a[i].width !== b[i].width ||
|
||||
a[i].d !== b[i].d ||
|
||||
!areDashArraysEqual(a[i].dash, b[i].dash)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSameClipRect(a: ClipRect | null, b: ClipRect): boolean {
|
||||
return (
|
||||
!!a &&
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.width === b.width &&
|
||||
a.height === b.height
|
||||
);
|
||||
}
|
||||
|
||||
export function TargetDashOverlay({
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
}: {
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: GraphConfig;
|
||||
selectedTimeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
}) {
|
||||
const targetMeta = useMemo(
|
||||
() => getHistoricalDashTargets(newData, config),
|
||||
[newData, config],
|
||||
);
|
||||
const clipPathId = useId().replace(/:/g, "");
|
||||
|
||||
const [lines, setLines] = useState<OverlayLine[]>([]);
|
||||
const [clipRect, setClipRect] = useState<ClipRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetMeta.length === 0) {
|
||||
setLines([]);
|
||||
setClipRect(null);
|
||||
}
|
||||
}, [targetMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId: number | null = null;
|
||||
let bootstrapRafId: number | null = null;
|
||||
let recalcScheduled = false;
|
||||
let hooksAttached = false;
|
||||
const removeHookFns: Array<() => void> = [];
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
const recalc = () => {
|
||||
recalcScheduled = false;
|
||||
const u = uplotRef.current;
|
||||
if (!u || targetMeta.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLines = targetMeta
|
||||
.map((meta, index) => {
|
||||
const yData = u.data[meta.dataIndex] as
|
||||
| Array<number | null>
|
||||
| undefined;
|
||||
if (!yData || yData.length < 2) return null;
|
||||
|
||||
const useFlatShortWindowPath =
|
||||
isLiveMode &&
|
||||
selectedTimeWindow !== "all" &&
|
||||
selectedTimeWindow <= 60_000;
|
||||
|
||||
const d = useFlatShortWindowPath
|
||||
? buildFlatLatestValuePath(u, yData)
|
||||
: buildSteppedPath(u, xData, yData);
|
||||
if (!d) return null;
|
||||
|
||||
return {
|
||||
key: `${meta.dataIndex}-${index}`,
|
||||
color: meta.color,
|
||||
width: meta.width,
|
||||
dash: meta.dash,
|
||||
d,
|
||||
} as OverlayLine;
|
||||
})
|
||||
.filter((line): line is OverlayLine => !!line);
|
||||
|
||||
if (nextLines.length > 0) {
|
||||
setLines((prev) =>
|
||||
areOverlayLinesEqual(prev, nextLines) ? prev : nextLines,
|
||||
);
|
||||
const nextClipRect: ClipRect = {
|
||||
x: u.bbox.left,
|
||||
y: u.bbox.top,
|
||||
width: u.bbox.width,
|
||||
height: u.bbox.height,
|
||||
};
|
||||
setClipRect((prev) =>
|
||||
isSameClipRect(prev, nextClipRect) ? prev : nextClipRect,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRecalc = () => {
|
||||
if (recalcScheduled) return;
|
||||
recalcScheduled = true;
|
||||
rafId = window.requestAnimationFrame(recalc);
|
||||
};
|
||||
|
||||
scheduleRecalc();
|
||||
|
||||
if (targetMeta.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addHook = (u: any, hookName: string, fn: () => void) => {
|
||||
const hooks = u.hooks?.[hookName];
|
||||
if (!Array.isArray(hooks)) return;
|
||||
hooks.push(fn);
|
||||
removeHookFns.push(() => {
|
||||
const idx = hooks.indexOf(fn);
|
||||
if (idx >= 0) hooks.splice(idx, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const onWindowResize = () => scheduleRecalc();
|
||||
window.addEventListener("resize", onWindowResize);
|
||||
|
||||
const attachHooks = (): boolean => {
|
||||
if (hooksAttached) return true;
|
||||
const u = uplotRef.current as any;
|
||||
if (!u) return false;
|
||||
|
||||
addHook(u, "draw", scheduleRecalc);
|
||||
addHook(u, "setScale", scheduleRecalc);
|
||||
addHook(u, "setData", scheduleRecalc);
|
||||
|
||||
const rootElement = u.root as HTMLElement | undefined;
|
||||
if (rootElement && typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleRecalc());
|
||||
resizeObserver.observe(rootElement);
|
||||
}
|
||||
|
||||
hooksAttached = true;
|
||||
scheduleRecalc();
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!attachHooks()) {
|
||||
const tryAttach = () => {
|
||||
if (attachHooks()) return;
|
||||
bootstrapRafId = window.requestAnimationFrame(tryAttach);
|
||||
};
|
||||
bootstrapRafId = window.requestAnimationFrame(tryAttach);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId !== null) {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (bootstrapRafId !== null) {
|
||||
window.cancelAnimationFrame(bootstrapRafId);
|
||||
}
|
||||
removeHookFns.forEach((fn) => fn());
|
||||
window.removeEventListener("resize", onWindowResize);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [uplotRef, targetMeta, isLiveMode, selectedTimeWindow]);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
aria-hidden
|
||||
>
|
||||
{clipRect && (
|
||||
<defs>
|
||||
<clipPath id={clipPathId}>
|
||||
<rect
|
||||
x={clipRect.x}
|
||||
y={clipRect.y}
|
||||
width={clipRect.width}
|
||||
height={clipRect.height}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<g clipPath={clipRect ? `url(#${clipPathId})` : undefined}>
|
||||
{lines.map((line) => {
|
||||
return (
|
||||
<path
|
||||
key={line.key}
|
||||
d={line.d}
|
||||
fill="none"
|
||||
stroke={line.color}
|
||||
strokeWidth={line.width}
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={line.dash.join(" ")}
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -12,18 +12,10 @@ import {
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
type CustomDashedTargetLine = {
|
||||
dataIndex: number;
|
||||
dash: number[];
|
||||
color: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const DASH_FLOW_PX_PER_SEC = 180;
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
@@ -161,8 +153,6 @@ export function createChart({
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
const customDashedTargetLines: CustomDashedTargetLine[] = [];
|
||||
const dashFlowStartTime = performance.now();
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
@@ -188,40 +178,29 @@ export function createChart({
|
||||
});
|
||||
|
||||
// Add config lines
|
||||
let visibleLineIndex = 0;
|
||||
const firstConfigLineDataIndex = 1 + allOriginalSeries.length;
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const dash =
|
||||
line.dash ?? (line.type === "threshold" ? [5, 5] : undefined);
|
||||
const lineDataIndex = firstConfigLineDataIndex + visibleLineIndex;
|
||||
const isHistoricalDashedTarget = !!line.targetSeries && !!dash?.length;
|
||||
|
||||
if (isHistoricalDashedTarget) {
|
||||
customDashedTargetLines.push({
|
||||
dataIndex: lineDataIndex,
|
||||
dash: dash!,
|
||||
color: line.color,
|
||||
width: line.width ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
// Historical dashed targets are rendered manually in draw hook to
|
||||
// avoid dash shimmer while the live x-axis slides.
|
||||
// Historical dashed targets are rendered in an SVG overlay.
|
||||
stroke: isHistoricalDashedTarget ? "rgba(0,0,0,0)" : line.color,
|
||||
width: line.width ?? 1,
|
||||
dash: isHistoricalDashedTarget ? undefined : dash,
|
||||
show: true,
|
||||
points: { show: false },
|
||||
});
|
||||
visibleLineIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// Always destroy existing chart before creating new one
|
||||
if (uplotRef.current) {
|
||||
if (uplotRefOut?.current != null) {
|
||||
uplotRefOut.current = null;
|
||||
}
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
}
|
||||
@@ -346,119 +325,6 @@ export function createChart({
|
||||
}
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(u) => {
|
||||
if (customDashedTargetLines.length === 0) return;
|
||||
const xData = u.data[0] as number[] | undefined;
|
||||
if (!xData || xData.length < 2) return;
|
||||
|
||||
const xMin = u.scales.x?.min;
|
||||
const xMax = u.scales.x?.max;
|
||||
if (xMin === undefined || xMax === undefined) return;
|
||||
|
||||
const ctx = u.ctx;
|
||||
const left = u.bbox.left;
|
||||
const top = u.bbox.top;
|
||||
const snap = (v: number) => Math.round(v) + 0.5;
|
||||
const findFirstGe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = arr.length;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] >= value) {
|
||||
ans = mid;
|
||||
hi = mid - 1;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const findLastLe = (arr: number[], value: number): number => {
|
||||
let lo = 0;
|
||||
let hi = arr.length - 1;
|
||||
let ans = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (arr[mid] <= value) {
|
||||
ans = mid;
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
return ans;
|
||||
};
|
||||
const firstVisibleIdx = Math.max(0, findFirstGe(xData, xMin) - 1);
|
||||
const lastVisibleIdx = Math.min(
|
||||
xData.length - 1,
|
||||
findLastLe(xData, xMax) + 1,
|
||||
);
|
||||
if (lastVisibleIdx < firstVisibleIdx) return;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(left, top, u.bbox.width, u.bbox.height);
|
||||
ctx.clip();
|
||||
ctx.lineCap = "butt";
|
||||
|
||||
customDashedTargetLines.forEach(
|
||||
({ dataIndex, dash, color, width }) => {
|
||||
const yData = u.data[dataIndex] as
|
||||
| Array<number | null>
|
||||
| undefined;
|
||||
if (!yData || yData.length < 2) return;
|
||||
|
||||
const dashPeriod = dash.reduce((acc, curr) => acc + curr, 0);
|
||||
ctx.setLineDash(dash);
|
||||
const elapsedMs = performance.now() - dashFlowStartTime;
|
||||
const dashTravelPx = (elapsedMs * DASH_FLOW_PX_PER_SEC) / 1000;
|
||||
ctx.lineDashOffset =
|
||||
dashPeriod > 0 ? -(dashTravelPx % dashPeriod) : 0;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.beginPath();
|
||||
|
||||
let started = false;
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
|
||||
for (let i = firstVisibleIdx; i <= lastVisibleIdx; i++) {
|
||||
const value = yData[i];
|
||||
if (value === null || value === undefined) continue;
|
||||
|
||||
const x = snap(u.valToPos(xData[i], "x", true));
|
||||
const y = snap(u.valToPos(value, "y", true));
|
||||
|
||||
if (!started) {
|
||||
ctx.moveTo(x, y);
|
||||
started = true;
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (x !== prevX) {
|
||||
ctx.lineTo(x, prevY);
|
||||
}
|
||||
if (y !== prevY) {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
|
||||
prevX = x;
|
||||
prevY = y;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
ctx.stroke();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@@ -636,6 +502,10 @@ export function createChart({
|
||||
containerRef.current,
|
||||
);
|
||||
|
||||
if (uplotRefOut) {
|
||||
uplotRefOut.current = uplotRef.current;
|
||||
}
|
||||
|
||||
// Create handler callbacks
|
||||
const handlerCallbacks: HandlerCallbacks = {
|
||||
updateYAxisScale,
|
||||
@@ -660,25 +530,12 @@ export function createChart({
|
||||
|
||||
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
||||
|
||||
let dashFlowAnimationFrame: number | null = null;
|
||||
if (customDashedTargetLines.length > 0) {
|
||||
const animateDashFlow = () => {
|
||||
if (!uplotRef.current) return;
|
||||
uplotRef.current.redraw();
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
};
|
||||
dashFlowAnimationFrame = requestAnimationFrame(animateDashFlow);
|
||||
}
|
||||
|
||||
animationRefs.lastRenderedData.current = {
|
||||
timestamps: [...timestamps],
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (dashFlowAnimationFrame !== null) {
|
||||
cancelAnimationFrame(dashFlowAnimationFrame);
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +84,27 @@ export function exportGraphsToExcel(
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
// Excel worksheet for timestamps and timestamp markers
|
||||
if (graphLineData.targetLines.length > 0) {
|
||||
const markerReportData =
|
||||
createGraphLineMarkerReportSheet(graphLineData);
|
||||
const markerReportWorksheet = XLSX.utils.aoa_to_sheet(markerReportData);
|
||||
// Set column widths here (e.g., Column A = 15, Column B = 25)
|
||||
markerReportWorksheet["!cols"] = [
|
||||
{ wch: 20 }, // Column A (Labels: 'Timestamp', 'Value', etc.)
|
||||
{ wch: 30 }, // Column B (Values, where the Date object resides)
|
||||
];
|
||||
const markerReportSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Marker Report`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
markerReportWorksheet,
|
||||
markerReportSheetName,
|
||||
);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
@@ -290,6 +311,119 @@ function createGraphLineDataSheet(graphLine: {
|
||||
});
|
||||
}
|
||||
|
||||
function createGraphLineMarkerReportSheet(graphLine: {
|
||||
graphTitle: string;
|
||||
lineTitle: string;
|
||||
series: TimeSeries;
|
||||
color?: string;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
targetLines: GraphLine[];
|
||||
}): any[][] {
|
||||
const [timestamps, values] = seriesToUPlotData(graphLine.series.long);
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
// Initialize Report Data and Header
|
||||
const reportData: any[][] = [
|
||||
[`Marker Report: ${graphLine.lineTitle}`],
|
||||
["Graph", graphLine.graphTitle],
|
||||
["Line Name", graphLine.lineTitle],
|
||||
["", ""],
|
||||
["--- Data Point Marker Status ---", ""],
|
||||
["", ""],
|
||||
];
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
reportData.push(["No data points to report"]);
|
||||
return reportData;
|
||||
}
|
||||
|
||||
// Filter User Markers
|
||||
const allTargetLines = graphLine.targetLines.filter(
|
||||
(line) => line.show !== false,
|
||||
);
|
||||
const userMarkers = allTargetLines.filter(
|
||||
(line) => line.type === "user_marker" && line.label,
|
||||
);
|
||||
|
||||
// Map Markers to Closest Data Point Index
|
||||
const markerIndexMap = new Map<
|
||||
number,
|
||||
{ label: string; originalTimestamp: number }
|
||||
>();
|
||||
|
||||
userMarkers.forEach((line) => {
|
||||
const markerTime = line.markerTimestamp || line.value; // Use the correct high-precision timestamp
|
||||
let closestDataPointIndex = -1;
|
||||
let minTimeDifference = Infinity;
|
||||
|
||||
// Find the data point with the closest timestamp
|
||||
timestamps.forEach((ts, index) => {
|
||||
const difference = Math.abs(ts - markerTime);
|
||||
if (difference < minTimeDifference) {
|
||||
minTimeDifference = difference;
|
||||
closestDataPointIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
// Store the marker data at the index of the closest data point
|
||||
if (closestDataPointIndex !== -1) {
|
||||
markerIndexMap.set(closestDataPointIndex, {
|
||||
label: line.label || "User Marker",
|
||||
originalTimestamp: markerTime,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add the final header before the timestamp report starts
|
||||
reportData.push(["--- BEGIN DETAILED REPORT ---", ""], ["", ""]);
|
||||
|
||||
// Handle case where no user markers were created
|
||||
if (userMarkers.length === 0) {
|
||||
reportData.push(["No user-created markers found.", ""]);
|
||||
}
|
||||
|
||||
timestamps.forEach((dataPointTimestamp, index) => {
|
||||
const value = values[index];
|
||||
const markerData = markerIndexMap.get(index);
|
||||
|
||||
let finalMarkerLabel = "";
|
||||
let timeToDisplay = dataPointTimestamp; // Default to data sample time
|
||||
|
||||
if (markerData) {
|
||||
finalMarkerLabel = `${markerData.label}`;
|
||||
timeToDisplay = markerData.originalTimestamp;
|
||||
}
|
||||
|
||||
// Format the time (using timeToDisplay)
|
||||
const formattedTime = new Date(timeToDisplay)
|
||||
.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
.replace(/ /g, "");
|
||||
|
||||
// Row 1: Timestamp
|
||||
reportData.push(["Timestamp", formattedTime]);
|
||||
|
||||
// Row 2: Value
|
||||
const formattedValue = graphLine.renderValue
|
||||
? graphLine.renderValue(value)
|
||||
: value?.toFixed(3) || "";
|
||||
reportData.push([`Value (${unitSymbol})`, formattedValue]);
|
||||
|
||||
// Row 3: Marker Name
|
||||
reportData.push(["Marker", finalMarkerLabel]);
|
||||
|
||||
// Separator
|
||||
reportData.push(["", ""]);
|
||||
});
|
||||
|
||||
return reportData;
|
||||
}
|
||||
|
||||
// Ensure sheet names are unique and valid for Excel
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
|
||||
@@ -10,6 +10,9 @@ export {
|
||||
AutoSyncedBigGraph,
|
||||
} from "./SyncedComponents";
|
||||
|
||||
// Context
|
||||
export { MarkerProvider } from "./MarkerContext";
|
||||
|
||||
// Hooks
|
||||
export { useGraphSync } from "./useGraphSync";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type uPlot from "uplot";
|
||||
import { IconName } from "@/components/Icon";
|
||||
import { Unit } from "@/control/units";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
@@ -28,7 +29,7 @@ export type PropGraphSync = {
|
||||
|
||||
// Configuration types for additional lines
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
type: "threshold" | "target" | "user_marker"; // TODO: redundant or not?
|
||||
value: number;
|
||||
targetSeries?: TimeSeries;
|
||||
color: string;
|
||||
@@ -36,6 +37,7 @@ export type GraphLine = {
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
show?: boolean;
|
||||
markerTimestamp?: number;
|
||||
};
|
||||
|
||||
export type GraphConfig = {
|
||||
@@ -75,6 +77,8 @@ export type BigGraphProps = {
|
||||
config: GraphConfig;
|
||||
graphId: string;
|
||||
syncGraph?: PropGraphSync;
|
||||
/** Optional ref to receive the uPlot instance when chart is created (e.g. for marker positioning) */
|
||||
uplotRefOut?: React.MutableRefObject<uPlot | null>;
|
||||
};
|
||||
|
||||
export type TimeWindowOption = {
|
||||
@@ -89,6 +93,7 @@ export type ControlProps = {
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: (origin: SwitchOrigin) => void;
|
||||
onExport?: () => void;
|
||||
onAddMarker?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
showFromTimestamp?: number | null;
|
||||
onShowFromChange?: (timestamp: number | null) => void;
|
||||
@@ -111,6 +116,8 @@ export interface HistoricalModeHandlers {
|
||||
export interface CreateChartParams {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
/** Optional ref to expose uPlot instance (e.g. for marker overlay positioning) */
|
||||
uplotRefOut?: React.MutableRefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: BigGraphProps["config"];
|
||||
colors: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useEffect, RefObject, useRef, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
MutableRefObject,
|
||||
RefObject,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||
@@ -13,6 +19,7 @@ interface UseBigGraphEffectsProps {
|
||||
// Refs
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
uplotRef: RefObject<uPlot | null>;
|
||||
uplotRefOut?: MutableRefObject<uPlot | null>;
|
||||
startTimeRef: RefObject<number | null>;
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
@@ -71,6 +78,7 @@ interface UseBigGraphEffectsProps {
|
||||
export function useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
@@ -257,6 +265,7 @@ export function useBigGraphEffects({
|
||||
const cleanup = createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
uplotRefOut,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
@@ -291,6 +300,7 @@ export function useBigGraphEffects({
|
||||
cleanup?.();
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
if (uplotRefOut) uplotRefOut.current = null;
|
||||
stopAnimations(animationRefs);
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMarkerStore, type Marker } from "@/stores/markerStore";
|
||||
|
||||
// Stable empty array to avoid "getSnapshot should be cached" infinite loop
|
||||
const EMPTY_MARKERS: Marker[] = [];
|
||||
|
||||
export function useMarkerManager(machineId: string) {
|
||||
const markers = useMarkerStore((state) => {
|
||||
const list = state.markersByMachine[machineId];
|
||||
return list ?? EMPTY_MARKERS;
|
||||
});
|
||||
const addMarkerAction = useMarkerStore((state) => state.addMarker);
|
||||
const removeMarkerAction = useMarkerStore((state) => state.removeMarker);
|
||||
const clearMarkersAction = useMarkerStore((state) => state.clearMarkers);
|
||||
|
||||
const addMarker = useCallback(
|
||||
(name: string, timestamp: number, color?: string, value?: number) => {
|
||||
addMarkerAction(machineId, { name, timestamp, color, value });
|
||||
return { name, timestamp, color, value } as Marker;
|
||||
},
|
||||
[machineId, addMarkerAction],
|
||||
);
|
||||
|
||||
const removeMarker = useCallback(
|
||||
(timestamp: number) => {
|
||||
removeMarkerAction(machineId, timestamp);
|
||||
},
|
||||
[machineId, removeMarkerAction],
|
||||
);
|
||||
|
||||
const clearMarkers = useCallback(() => {
|
||||
clearMarkersAction(machineId);
|
||||
}, [machineId, clearMarkersAction]);
|
||||
|
||||
return {
|
||||
markers,
|
||||
addMarker,
|
||||
removeMarker,
|
||||
clearMarkers,
|
||||
};
|
||||
}
|
||||
@@ -36,7 +36,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -55,7 +55,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import React from "react";
|
||||
import { useExtruder2 } from "./useExtruder";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
|
||||
export function Extruder2GraphsPage() {
|
||||
const {
|
||||
@@ -252,87 +253,102 @@ export function Extruder2GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-25">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
currentTimeSeries={pressure}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
currentTimeSeries={nozzleTemperature}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
currentTimeSeries={combinedPower}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
currentTimeSeries={motorCurrent}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
/>
|
||||
</div>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
currentTimeSeries={motorScrewRpm}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
} from "@/components/graph";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
import React from "react";
|
||||
import { useExtruder3 } from "./useExtruder";
|
||||
|
||||
@@ -252,87 +253,102 @@ export function Extruder3GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-25">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: pressure,
|
||||
color: "#3b82f6",
|
||||
lines:
|
||||
state?.pressure_state.target_bar !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.pressure_state.target_bar,
|
||||
targetSeries: targetPressure,
|
||||
color: "#3b82f6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={pressureConfig}
|
||||
unit="bar"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="pressure-graph"
|
||||
currentTimeSeries={pressure}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={temperatureData}
|
||||
config={temperatureConfig}
|
||||
unit="C"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-temperatures"
|
||||
currentTimeSeries={nozzleTemperature ?? null}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={powerData}
|
||||
config={powerConfig}
|
||||
unit="W"
|
||||
renderValue={(value) => value.toFixed(1)}
|
||||
graphId="combined-power"
|
||||
currentTimeSeries={combinedPower ?? null}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorCurrent,
|
||||
color: "#3b82f6",
|
||||
}}
|
||||
config={currentConfig}
|
||||
unit="A"
|
||||
renderValue={(value) => value.toFixed(2)}
|
||||
graphId="motor-current"
|
||||
currentTimeSeries={motorCurrent}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
/>
|
||||
</div>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData: motorScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
lines:
|
||||
state?.screw_state.target_rpm !== undefined
|
||||
? [
|
||||
{
|
||||
type: "target" as const,
|
||||
value: state.screw_state.target_rpm,
|
||||
targetSeries: targetScrewRpm,
|
||||
color: "#8b5cf6",
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}}
|
||||
config={rpmConfig}
|
||||
unit="rpm"
|
||||
renderValue={(value) => value.toFixed(0)}
|
||||
graphId="rpm-graph"
|
||||
currentTimeSeries={motorScrewRpm}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="extruder-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Icon } from "@/components/Icon";
|
||||
|
||||
type HeatingFaultState = {
|
||||
heating_fault_state?: {
|
||||
fault_zone?: string | null;
|
||||
fault_acknowledged?: boolean;
|
||||
};
|
||||
is_default_state?: boolean;
|
||||
};
|
||||
|
||||
type HeatingFaultToastProps = {
|
||||
zoneName: string;
|
||||
onAcknowledge: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
function HeatingFaultToast({
|
||||
zoneName,
|
||||
onAcknowledge,
|
||||
children,
|
||||
}: HeatingFaultToastProps) {
|
||||
return (
|
||||
<div className="flex w-100 flex-col gap-3 rounded-xl border border-red-400 bg-red-600 p-4 text-white shadow-xl backdrop-blur-sm transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon name="lu:TriangleAlert" className="h-5 w-5 text-yellow-200" />
|
||||
<strong className="text-lg font-semibold tracking-wide">
|
||||
Heating Fault
|
||||
</strong>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-2xl font-bold text-white/80 hover:bg-red-500 hover:text-white focus:ring-2 focus:ring-white/30 focus:outline-none"
|
||||
onClick={onAcknowledge}
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{children ?? (
|
||||
<p className="text-base leading-snug text-red-50">
|
||||
The <strong>{zoneName}</strong> heating zone did not increase in
|
||||
temperature by at least 5°C within 60 seconds. The extruder has been
|
||||
automatically set to standby mode.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toZoneName(faultZone: string | null): string {
|
||||
switch (faultZone) {
|
||||
case "front":
|
||||
return "Front";
|
||||
case "middle":
|
||||
return "Middle";
|
||||
case "back":
|
||||
return "Back";
|
||||
case "nozzle":
|
||||
return "Nozzle";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function useHeatingFaultToastEffect({
|
||||
state,
|
||||
onAcknowledgeHeatingFault,
|
||||
}: {
|
||||
state: HeatingFaultState | null | undefined;
|
||||
onAcknowledgeHeatingFault: () => void;
|
||||
}) {
|
||||
// Deduplicate by active fault zone
|
||||
const activeFaultZone = useRef<string | null>(null);
|
||||
const currentToastId = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const faultZone = state.heating_fault_state?.fault_zone ?? null;
|
||||
const faultAcknowledged =
|
||||
state.heating_fault_state?.fault_acknowledged ?? false;
|
||||
const isDefault = !!state.is_default_state;
|
||||
|
||||
if (isDefault) return;
|
||||
|
||||
const faultZoneStr = faultZone ?? "unknown";
|
||||
const zoneName = toZoneName(faultZone);
|
||||
|
||||
if (
|
||||
faultZone &&
|
||||
!faultAcknowledged &&
|
||||
activeFaultZone.current !== faultZone
|
||||
) {
|
||||
activeFaultZone.current = faultZone;
|
||||
const toastId = `heating-fault-${faultZoneStr}`;
|
||||
currentToastId.current = toastId;
|
||||
|
||||
toast(
|
||||
<HeatingFaultToast
|
||||
zoneName={zoneName}
|
||||
onAcknowledge={() => {
|
||||
onAcknowledgeHeatingFault();
|
||||
toast.dismiss(toastId);
|
||||
activeFaultZone.current = null;
|
||||
currentToastId.current = null;
|
||||
}}
|
||||
/>,
|
||||
{
|
||||
id: toastId,
|
||||
duration: Infinity,
|
||||
position: "top-center",
|
||||
style: {
|
||||
background: "transparent",
|
||||
padding: 0,
|
||||
boxShadow: "none",
|
||||
border: "none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (faultAcknowledged || !faultZone) {
|
||||
if (currentToastId.current) {
|
||||
toast.dismiss(currentToastId.current);
|
||||
currentToastId.current = null;
|
||||
}
|
||||
activeFaultZone.current = null;
|
||||
}
|
||||
}, [state, onAcknowledgeHeatingFault]);
|
||||
}
|
||||
|
||||
export function GlobalHeatingFaultToastManager({
|
||||
state,
|
||||
onAcknowledgeHeatingFault,
|
||||
}: {
|
||||
state:
|
||||
| {
|
||||
heating_fault_state?: {
|
||||
fault_zone?: string | null;
|
||||
fault_acknowledged?: boolean;
|
||||
};
|
||||
is_default_state?: boolean;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
onAcknowledgeHeatingFault: () => void;
|
||||
}) {
|
||||
useHeatingFaultToastEffect({ state, onAcknowledgeHeatingFault });
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
import React from "react";
|
||||
import { useMock1 } from "./useMock";
|
||||
import { TimeSeriesValue, type Series, TimeSeries } from "@/lib/timeseries";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
import { Unit } from "@/control/units";
|
||||
|
||||
export function Mock1GraphPage() {
|
||||
const { sineWaveSum } = useMock1();
|
||||
@@ -97,45 +100,58 @@ export function Mock1GraphPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph1"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={combinedData}
|
||||
config={{
|
||||
...config,
|
||||
title: "Combined Sine Waves",
|
||||
}}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="combined-graph"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph2"
|
||||
/>
|
||||
<AutoSyncedBigGraph
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm"}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph"
|
||||
/>
|
||||
</div>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph1"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={combinedData}
|
||||
config={{
|
||||
...config,
|
||||
title: "Combined Sine Waves",
|
||||
}}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="combined-graph"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph2"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={singleData}
|
||||
config={singleGraphConfig}
|
||||
unit={"mm" as Unit}
|
||||
renderValue={(value) => value.toFixed(3)}
|
||||
graphId="single-graph"
|
||||
currentTimeSeries={sineWaveSum}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="mock-graphs"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Page } from "@/components/Page";
|
||||
import {
|
||||
AutoSyncedBigGraph,
|
||||
MarkerProvider,
|
||||
SyncedFloatingControlPanel,
|
||||
useGraphSync,
|
||||
type GraphConfig,
|
||||
@@ -11,6 +11,7 @@ import { useWinder2 } from "./useWinder";
|
||||
import { roundDegreesToDecimals, roundToDecimals } from "@/lib/decimal";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { Unit } from "@/control/units";
|
||||
import { GraphWithMarkerControls } from "@/components/graph/GraphWithMarkerControls";
|
||||
|
||||
export function Winder2GraphsPage() {
|
||||
const {
|
||||
@@ -27,50 +28,55 @@ export function Winder2GraphsPage() {
|
||||
|
||||
return (
|
||||
<Page className="pb-27">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4">
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
<MarkerProvider>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-4">
|
||||
<PullerSpeedGraph
|
||||
syncHook={syncHook}
|
||||
newData={pullerSpeed}
|
||||
targetSeries={targetPullerSpeed}
|
||||
targetSpeed={state?.puller_state?.target_speed}
|
||||
unit="m/min"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<TensionArmAngleGraph
|
||||
syncHook={syncHook}
|
||||
newData={tensionArmAngle}
|
||||
unit="deg"
|
||||
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
||||
/>
|
||||
<TensionArmAngleGraph
|
||||
syncHook={syncHook}
|
||||
newData={tensionArmAngle}
|
||||
unit="deg"
|
||||
renderValue={(value) => roundDegreesToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<SpoolRpmGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolRpm}
|
||||
unit="rpm"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
<SpoolRpmGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolRpm}
|
||||
unit="rpm"
|
||||
renderValue={(value) => roundToDecimals(value, 0)}
|
||||
/>
|
||||
|
||||
<TraversePositionGraph
|
||||
syncHook={syncHook}
|
||||
newData={traversePosition}
|
||||
limitInner={state?.traverse_state?.limit_inner}
|
||||
limitOuter={state?.traverse_state?.limit_outer}
|
||||
unit="mm"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
<TraversePositionGraph
|
||||
syncHook={syncHook}
|
||||
newData={traversePosition}
|
||||
limitInner={state?.traverse_state?.limit_inner}
|
||||
limitOuter={state?.traverse_state?.limit_outer}
|
||||
unit="mm"
|
||||
renderValue={(value) => roundToDecimals(value, 1)}
|
||||
/>
|
||||
|
||||
<SpoolProgressGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolProgress}
|
||||
unit="m"
|
||||
renderValue={(value) => roundToDecimals(value, 2)}
|
||||
/>
|
||||
<SpoolProgressGraph
|
||||
syncHook={syncHook}
|
||||
newData={spoolProgress}
|
||||
unit="m"
|
||||
renderValue={(value) => roundToDecimals(value, 2)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SyncedFloatingControlPanel controlProps={syncHook.controlProps} />
|
||||
<SyncedFloatingControlPanel
|
||||
controlProps={syncHook.controlProps}
|
||||
machineId="winder2"
|
||||
/>
|
||||
</MarkerProvider>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +104,7 @@ export function SpoolRpmGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -108,6 +114,8 @@ export function SpoolRpmGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="spool-rpm"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -157,7 +165,7 @@ export function TraversePositionGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -168,6 +176,8 @@ export function TraversePositionGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="traverse-position"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -193,7 +203,7 @@ export function TensionArmAngleGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -203,6 +213,8 @@ export function TensionArmAngleGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="tension-arm-angle"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -227,8 +239,9 @@ export function SpoolProgressGraph({
|
||||
exportFilename: "spool_progress",
|
||||
};
|
||||
|
||||
// NOTE: Assuming this graph starts at 0, and the max is the total capacity.
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -238,6 +251,8 @@ export function SpoolProgressGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="spool-progress"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -279,7 +294,7 @@ export function PullerSpeedGraph({
|
||||
};
|
||||
|
||||
return (
|
||||
<AutoSyncedBigGraph
|
||||
<GraphWithMarkerControls
|
||||
syncHook={syncHook}
|
||||
newData={{
|
||||
newData,
|
||||
@@ -290,6 +305,8 @@ export function PullerSpeedGraph({
|
||||
renderValue={renderValue}
|
||||
config={config}
|
||||
graphId="puller-speed"
|
||||
currentTimeSeries={newData}
|
||||
machineId="winder2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
export type Marker = {
|
||||
timestamp: number;
|
||||
name: string;
|
||||
value?: number;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const MAX_MARKERS_PER_MACHINE = 200;
|
||||
const STORAGE_NAME = "machine-markers-storage";
|
||||
const OLD_STORAGE_PREFIX = "machine-markers-";
|
||||
|
||||
export type MarkerStoreState = {
|
||||
markersByMachine: Record<string, Marker[]>;
|
||||
};
|
||||
|
||||
export type MarkerStoreActions = {
|
||||
getMarkers: (machineId: string) => Marker[];
|
||||
addMarker: (machineId: string, marker: Marker) => void;
|
||||
removeMarker: (machineId: string, timestamp: number) => void;
|
||||
clearMarkers: (machineId: string) => void;
|
||||
};
|
||||
|
||||
export type MarkerStore = MarkerStoreState & MarkerStoreActions;
|
||||
|
||||
function trimToMax(markers: Marker[]): Marker[] {
|
||||
return markers.length > MAX_MARKERS_PER_MACHINE
|
||||
? markers.slice(-MAX_MARKERS_PER_MACHINE)
|
||||
: markers;
|
||||
}
|
||||
|
||||
/** Migrate from legacy per-machine keys (machine-markers-{id}) to unified store */
|
||||
function migrateFromLegacyStorage(): Partial<MarkerStoreState> | null {
|
||||
try {
|
||||
const markersByMachine: Record<string, Marker[]> = {};
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key?.startsWith(OLD_STORAGE_PREFIX) && key !== STORAGE_NAME) {
|
||||
const machineId = key.slice(OLD_STORAGE_PREFIX.length);
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Marker[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
markersByMachine[machineId] = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed entries
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(markersByMachine).length > 0) {
|
||||
return { markersByMachine };
|
||||
}
|
||||
} catch {
|
||||
// Ignore migration errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerStorage = createJSONStorage<MarkerStoreState>(() => ({
|
||||
getItem: (name: string): string | null => {
|
||||
const next = localStorage.getItem(name);
|
||||
if (next) return next;
|
||||
const migrated = migrateFromLegacyStorage();
|
||||
if (migrated) {
|
||||
return JSON.stringify({ state: migrated, version: 0 });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
setItem: (name: string, value: string): void => {
|
||||
localStorage.setItem(name, value);
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
localStorage.removeItem(name);
|
||||
},
|
||||
}));
|
||||
|
||||
export const useMarkerStore = create<MarkerStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
markersByMachine: {},
|
||||
|
||||
getMarkers: (machineId: string) => {
|
||||
return get().markersByMachine[machineId] ?? [];
|
||||
},
|
||||
|
||||
addMarker: (machineId: string, marker: Marker) => {
|
||||
set((state) => {
|
||||
const current = state.markersByMachine[machineId] ?? [];
|
||||
const updated = trimToMax([...current, marker]);
|
||||
return {
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMarker: (machineId: string, timestamp: number) => {
|
||||
set((state) => {
|
||||
const current = state.markersByMachine[machineId] ?? [];
|
||||
const updated = current.filter((m) => m.timestamp !== timestamp);
|
||||
return {
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: updated,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearMarkers: (machineId: string) => {
|
||||
set((state) => ({
|
||||
markersByMachine: {
|
||||
...state.markersByMachine,
|
||||
[machineId]: [],
|
||||
},
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: STORAGE_NAME,
|
||||
storage: markerStorage,
|
||||
},
|
||||
),
|
||||
);
|
||||
Generated
+111
-12
@@ -1,6 +1,21 @@
|
||||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1771796463,
|
||||
"narHash": "sha256-9bCDuUzpwJXcHMQYMS1yNuzYMmKO/CCwCexpjWOl62I=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "3d3de3313e263e04894f284ac18177bd26169bad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane_2": {
|
||||
"locked": {
|
||||
"lastModified": 1750266157,
|
||||
"narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
|
||||
@@ -15,7 +30,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"crane_2": {
|
||||
"crane_3": {
|
||||
"locked": {
|
||||
"lastModified": 1750266157,
|
||||
"narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=",
|
||||
@@ -102,12 +117,51 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_5": {
|
||||
"inputs": {
|
||||
"systems": "systems_5"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1771851181,
|
||||
"narHash": "sha256-gFgE6mGUftwseV3DUENMb0k0EiHd739lZexPo5O/sdQ=",
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"rev": "9a4b494b1aa1b93d8edf167f46dc8e0c0011280c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "home-manager",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1750973805,
|
||||
"narHash": "sha256-BZXgag7I0rnL/HMHAsBz3tQrfKAibpY2vovexl2lS+Y=",
|
||||
@@ -122,9 +176,10 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_2": {
|
||||
"home-manager_3": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
]
|
||||
@@ -143,9 +198,10 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"home-manager_3": {
|
||||
"home-manager_4": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -167,11 +223,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1750969886,
|
||||
"narHash": "sha256-zW/OFnotiz/ndPFdebpo3X0CrbVNf22n4DjN2vxlb58=",
|
||||
"lastModified": 1767313136,
|
||||
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a676066377a2fe7457369dd37c31fd2263b662f4",
|
||||
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -207,6 +263,31 @@
|
||||
],
|
||||
"qitech-control": "qitech-control_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1772023797,
|
||||
"narHash": "sha256-RipiUlH5UKiofTRquREluUFOwEfNjLFEvyDol/VWDRg=",
|
||||
"owner": "qitechgmbh",
|
||||
"repo": "control",
|
||||
"rev": "3e3e3f1c0bc2b26fe0fc1e7d49b65e3fd3d7f368",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "qitechgmbh",
|
||||
"repo": "control",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_2": {
|
||||
"inputs": {
|
||||
"crane": "crane_3",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"home-manager": "home-manager_3",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
],
|
||||
"qitech-control": "qitech-control_3"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1751047510,
|
||||
"narHash": "sha256-SZ+DxQvFJKJOro1AVUwWltLQO1L9TIc6AI4tnM9V4tk=",
|
||||
@@ -221,15 +302,16 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_2": {
|
||||
"qitech-control_3": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_3",
|
||||
"home-manager": "home-manager_3",
|
||||
"flake-utils": "flake-utils_4",
|
||||
"home-manager": "home-manager_4",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
],
|
||||
"qitech-control": "qitech-control_3",
|
||||
"qitech-control": "qitech-control_4",
|
||||
"rust-overlay": "rust-overlay_2"
|
||||
},
|
||||
"locked": {
|
||||
@@ -246,10 +328,11 @@
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"qitech-control_3": {
|
||||
"qitech-control_4": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_4",
|
||||
"flake-utils": "flake-utils_5",
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -300,6 +383,7 @@
|
||||
"rust-overlay_2": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"qitech-control",
|
||||
"nixpkgs"
|
||||
@@ -378,6 +462,21 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_5": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description = "QiTech Control";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.05";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
|
||||
|
||||
# Crane for Rust builds with dependency caching
|
||||
crane = { url = "github:ipetkov/crane"; };
|
||||
|
||||
+16
-10
@@ -15,7 +15,7 @@ in {
|
||||
consoleMode = "max"; # Use the highest available resolution
|
||||
};
|
||||
boot.loader.efi.canTouchEfiVariables = true;
|
||||
boot.kernelPackages = pkgs.linuxPackages_6_13;
|
||||
boot.kernelPackages = pkgs.linuxPackages_6_18;
|
||||
boot.kernelModules = [ "i915" ];
|
||||
|
||||
boot.kernelParams = [
|
||||
@@ -134,7 +134,6 @@ in {
|
||||
|
||||
# Enable the X11 windowing system.
|
||||
services.xserver.enable = true;
|
||||
#services.xserver.videoDrivers = [ "intel" ];
|
||||
services.xserver.displayManager.gdm = {
|
||||
enable = true;
|
||||
autoSuspend = false;
|
||||
@@ -186,18 +185,25 @@ in {
|
||||
powertop.enable = false;
|
||||
};
|
||||
|
||||
# Ensure all power management is disabled
|
||||
|
||||
services.logind = {
|
||||
# This remains for backward compatibility/high-level override
|
||||
lidSwitch = "ignore";
|
||||
extraConfig = ''
|
||||
HandlePowerKey=ignore
|
||||
HandleSuspendKey=ignore
|
||||
HandleHibernateKey=ignore
|
||||
HandleLidSwitch=ignore
|
||||
IdleAction=ignore
|
||||
'';
|
||||
|
||||
# Structured settings for logind.conf
|
||||
settings = {
|
||||
Login = {
|
||||
HandlePowerKey = "ignore";
|
||||
HandleSuspendKey = "ignore";
|
||||
HandleHibernateKey = "ignore";
|
||||
HandleLidSwitch = "ignore";
|
||||
IdleAction = "ignore";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
|
||||
# Configure keymap in X11
|
||||
services.xserver.xkb = {
|
||||
layout = "de";
|
||||
|
||||
@@ -10,7 +10,7 @@ buildNpmPackage rec {
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD = 1;
|
||||
|
||||
makeCacheWritable = true;
|
||||
npmDepsHash = "sha256-N2kdS9IKkEMNOPPnRNQ9GvCenR/k88fVyAFzXMJyxs4=";
|
||||
npmDepsHash = "sha256-jW9nogboiuYBzjLNM5rlYsdfp+58OlDf+kxjgNlST70=";
|
||||
npmFlags = [ "--no-audit" "--no-fund" ];
|
||||
|
||||
installPhase = ''
|
||||
@@ -44,7 +44,6 @@ buildNpmPackage rec {
|
||||
--enable-gpu \
|
||||
--ignore-gpu-blocklist \
|
||||
--enable-gpu-rasterization \
|
||||
--disable-software-rasterizer \
|
||||
"$@"
|
||||
EOF
|
||||
chmod +x $out/bin/qitech-control-electron
|
||||
|
||||
@@ -30,6 +30,9 @@ docker run --rm \
|
||||
--workdir /workspace \
|
||||
nixos/nix:latest \
|
||||
sh -c "
|
||||
whoami
|
||||
ls -lh
|
||||
cd /workspace
|
||||
echo 'Enabling flakes and nix-command...'
|
||||
mkdir -p ~/.config/nix
|
||||
echo 'experimental-features = nix-command flakes' > ~/.config/nix/nix.conf
|
||||
|
||||
Generated
+868
@@ -0,0 +1,868 @@
|
||||
{
|
||||
"name": "control",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"electron": "^38.8.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/is": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
"integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defer-to-connect": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
|
||||
"integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-cache-semantics": "*",
|
||||
"@types/keyv": "^3.1.4",
|
||||
"@types/node": "*",
|
||||
"@types/responselike": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/keyv": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz",
|
||||
"integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||
"integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-lookup": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
|
||||
"integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cacheable-request": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz",
|
||||
"integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clone-response": "^1.0.2",
|
||||
"get-stream": "^5.1.0",
|
||||
"http-cache-semantics": "^4.0.0",
|
||||
"keyv": "^4.0.0",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"normalize-url": "^6.0.1",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clone-response": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz",
|
||||
"integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response/node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/defer-to-connect": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
|
||||
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/define-data-property": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/define-properties": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
"has-property-descriptors": "^1.0.0",
|
||||
"object-keys": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "38.8.4",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-38.8.4.tgz",
|
||||
"integrity": "sha512-vYZ2SH2OhzkCmkbRaijtZLIEJRMnLUL2KQmcAM/7kamf4++3AKSNCBjE1epmQnYFY1M4oKjOUcS3G02ayxR5Bw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^22.7.7",
|
||||
"extract-zip": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es6-error": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
"matcher": "^3.0.0",
|
||||
"roarr": "^2.15.3",
|
||||
"semver": "^7.3.2",
|
||||
"serialize-error": "^7.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/global-agent/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
"gopd": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/got": {
|
||||
"version": "11.8.6",
|
||||
"resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz",
|
||||
"integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/is": "^4.0.0",
|
||||
"@szmarczak/http-timer": "^4.0.5",
|
||||
"@types/cacheable-request": "^6.0.1",
|
||||
"@types/responselike": "^1.0.0",
|
||||
"cacheable-lookup": "^5.0.3",
|
||||
"cacheable-request": "^7.0.2",
|
||||
"decompress-response": "^6.0.0",
|
||||
"http2-wrapper": "^1.0.0-beta.5.2",
|
||||
"lowercase-keys": "^2.0.0",
|
||||
"p-cancelable": "^2.0.0",
|
||||
"responselike": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/got?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-property-descriptors": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||
"integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"quick-lru": "^5.1.1",
|
||||
"resolve-alpn": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-buffer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-stringify-safe": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-buffer": "3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
|
||||
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
|
||||
"integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/normalize-url": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
|
||||
"integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-cancelable": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
|
||||
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
"integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-lru": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-alpn": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
|
||||
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||
"integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lowercase-keys": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/roarr": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
"globalthis": "^1.0.1",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"semver-compare": "^1.0.0",
|
||||
"sprintf-js": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver-compare": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sumchecker": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"electron": "^38.8.4"
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -33,7 +33,6 @@ use rest::init::start_api_thread;
|
||||
use serialport::UsbPortInfo;
|
||||
use smol::{
|
||||
channel::{Receiver, Sender},
|
||||
future,
|
||||
lock::RwLock,
|
||||
};
|
||||
use socketioxide::extract::SocketRef;
|
||||
@@ -413,7 +412,7 @@ fn main() {
|
||||
app_state.clone(),
|
||||
));
|
||||
}
|
||||
future::yield_now().await;
|
||||
smol::Timer::after(Duration::from_micros(150)).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user