Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92cacbc6c8 | ||
|
|
4bad318ce7 | ||
|
|
0eeb7f372e | ||
|
|
1a72f3f8bb | ||
|
|
522ace8a4f | ||
|
|
5c00d28396 | ||
|
|
2b0cff358e | ||
|
|
c3b7f9d050 | ||
|
|
a7fce331a0 | ||
|
|
6dd8573584 | ||
|
|
124418dc7a | ||
|
|
59e8ddbf82 | ||
|
|
366f58f072 | ||
|
|
7a12950c22 | ||
|
|
668000adf9 | ||
|
|
f2a2fee8f6 | ||
|
|
b412fb9d4e | ||
|
|
73de9f2e4c | ||
|
|
f4e5570ad5 | ||
|
|
2d1da2bbf5 | ||
|
|
23cb7ce992 | ||
|
|
941a1edb0c | ||
|
|
19af948deb | ||
|
|
63ea9a0f7d | ||
|
|
784e0f8652 | ||
|
|
e5f6f3f8e6 | ||
|
|
7c47ef0dee | ||
|
|
2a668d8602 | ||
|
|
efe18e9d81 | ||
|
|
cf20bb57b9 | ||
|
|
284f427da0 | ||
|
|
9f55171286 | ||
|
|
d427f50037 | ||
|
|
5bfaf8178f | ||
|
|
d57866e1a6 | ||
|
|
401f1bf7db | ||
|
|
a46dcbd0ae | ||
|
|
d2fea5f23b | ||
|
|
11b78638ec | ||
|
|
d6b6f76b55 | ||
|
|
bfba0b1d62 | ||
|
|
3b93e6ddc6 | ||
|
|
2a0ba7f72a | ||
|
|
ff9fbe6a6e | ||
|
|
7c712faff9 | ||
|
|
5a4ca867a8 | ||
|
|
88633798e7 | ||
|
|
86df807b01 | ||
|
|
3ae04f5547 | ||
|
|
c1ad22266d | ||
|
|
ec4f2517f6 | ||
|
|
4c73710665 | ||
|
|
fcea61979f | ||
|
|
bc68644468 | ||
|
|
652af28c76 | ||
|
|
31d6c5c4b9 | ||
|
|
28fdbd8bce | ||
|
|
314f5b3eed | ||
|
|
8983a67390 | ||
|
|
74d67cc261 | ||
|
|
2c4c64c0b7 | ||
|
|
758816815e | ||
|
|
bf52030506 | ||
|
|
29cf495dd0 | ||
|
|
500917937b | ||
|
|
812080341c | ||
|
|
221fa7edb3 | ||
|
|
fd58786524 | ||
|
|
a3398cbe34 | ||
|
|
b4cb5916bd | ||
|
|
b7e9c3c1eb | ||
|
|
c291c532ec | ||
|
|
edded9e5a6 | ||
|
|
eb2f0f34a4 | ||
|
|
a1fca74d66 | ||
|
|
36089c3e75 | ||
|
|
d27eecd54c | ||
|
|
183166d8d5 | ||
|
|
e01a8268aa | ||
|
|
d4d1969ec3 | ||
|
|
3dc371ce68 | ||
|
|
fba882adb8 | ||
|
|
e8ca268f1c | ||
|
|
3a0840d758 | ||
|
|
409460736d | ||
|
|
879de6ffcc | ||
|
|
d513f1ad84 | ||
|
|
98b65fadb6 | ||
|
|
832bafa217 | ||
|
|
769dc6220f | ||
|
|
a15af859d4 | ||
|
|
9a4d1e288d | ||
|
|
e20791aa6b | ||
|
|
19c4a48150 | ||
|
|
fba5c5c4f3 | ||
|
|
1f05edc79f | ||
|
|
f79c7ba58d | ||
|
|
5e7a6f1523 | ||
|
|
cc41a2e696 | ||
|
|
32ba1b0f75 | ||
|
|
d38b7e8051 | ||
|
|
353be9a4f8 | ||
|
|
ff14618ec0 | ||
|
|
208306cba5 | ||
|
|
7ef2c95b32 | ||
|
|
a41c822618 | ||
|
|
69a3cdbef3 | ||
|
|
ff51018d35 | ||
|
|
a228906eed | ||
|
|
a238fc9246 | ||
|
|
82a7e2fbfa | ||
|
|
23b2fa5faf | ||
|
|
1d78bacbe8 | ||
|
|
043e781049 | ||
|
|
2d23d9db2f | ||
|
|
c260403370 | ||
|
|
8ac9b73bd5 | ||
|
|
609cce0a42 | ||
|
|
ec304649bd | ||
|
|
217edd7db0 | ||
|
|
5d441b2b9a | ||
|
|
157989316e | ||
|
|
7fc5a35b6a | ||
|
|
a93c863688 | ||
|
|
7735a76b1f | ||
|
|
58b0412c68 | ||
|
|
7ade900f46 | ||
|
|
ad678e9c67 | ||
|
|
6455a8b6f1 | ||
|
|
b1b26fb5ae | ||
|
|
d68be75be1 | ||
|
|
f40a5ac3af | ||
|
|
0c2cf6f460 | ||
|
|
b5a989b7fe | ||
|
|
ab9fad2514 | ||
|
|
e3e7cbb20f | ||
|
|
7b1395b803 | ||
|
|
d67db133ea | ||
|
|
d74f3aad96 | ||
|
|
7f2b24f075 | ||
|
|
d609d41628 | ||
|
|
50c241dd59 | ||
|
|
1a6e7da996 | ||
|
|
3a0eaf53f3 | ||
|
|
16a952349f | ||
|
|
c40f256df5 | ||
|
|
8f0cc645ef | ||
|
|
1f436a6abe | ||
|
|
477b44c8bb | ||
|
|
4147528d0e | ||
|
|
2b533f2c57 | ||
|
|
afaf8c919e | ||
|
|
2e87efdb63 | ||
|
|
65fe3626ba | ||
|
|
36bae74613 | ||
|
|
2e34fa11f8 | ||
|
|
3410493c62 | ||
|
|
f13ad8fd0a | ||
|
|
8208762da5 | ||
|
|
1beb01da98 | ||
|
|
e3dcfaec3b | ||
|
|
efbf7bc350 | ||
|
|
6cf769891f | ||
|
|
840e4b8566 | ||
|
|
38341f6968 | ||
|
|
80e9726ba3 | ||
|
|
8afce68864 | ||
|
|
5f7b7544c3 | ||
|
|
27897182f9 | ||
|
|
2af1f0d79c | ||
|
|
4eeaebe355 | ||
|
|
1dc85828d9 | ||
|
|
bf5408cd18 | ||
|
|
d80b06256a | ||
|
|
aa0fb88fd2 | ||
|
|
fa85501578 | ||
|
|
500390e6dd | ||
|
|
bf73f996ca | ||
|
|
cb7fb902be | ||
|
|
8fabc46ad4 | ||
|
|
38a7da7ee2 | ||
|
|
bbae14a727 | ||
|
|
f4609bfb3c | ||
|
|
afa4663176 | ||
|
|
9b806ee8b3 | ||
|
|
4975d2f6a7 | ||
|
|
1a675db872 | ||
|
|
0230ff4f81 | ||
|
|
d8039156ae | ||
|
|
e7105334c4 | ||
|
|
ad04ea0a62 | ||
|
|
0128268719 | ||
|
|
7873975980 | ||
|
|
57883fcb7e | ||
|
|
5979d9497e | ||
|
|
5dade5a633 | ||
|
|
694b46afbe | ||
|
|
5f81b2df5b | ||
|
|
ff47ed3261 | ||
|
|
da84e2a562 | ||
|
|
a272fd57bb | ||
|
|
e2fb623cbb | ||
|
|
d797584637 | ||
|
|
4549d806c9 | ||
|
|
a22d1d0ff1 | ||
|
|
adde259aa0 | ||
|
|
2768609286 | ||
|
|
1973d8841d | ||
|
|
b337031272 | ||
|
|
507b7d507a | ||
|
|
2f231b203c | ||
|
|
6e7a528d6f | ||
|
|
43dd37e8e0 | ||
|
|
11e965ddeb | ||
|
|
eb08493fdc | ||
|
|
27b0386eac | ||
|
|
c4314a45dd | ||
|
|
02a436f053 | ||
|
|
fea487c74e | ||
|
|
7e317c4b23 | ||
|
|
2c02d98890 | ||
|
|
bdbad1dca2 | ||
|
|
202ae24eea | ||
|
|
38db16468e | ||
|
|
1f272f4bce | ||
|
|
ed2b94ce05 | ||
|
|
146365fae2 | ||
|
|
3bf9956c41 | ||
|
|
7d8f833063 | ||
|
|
fcdac437eb | ||
|
|
cb74dbc22b | ||
|
|
a749f98e81 | ||
|
|
634c00cdd1 | ||
|
|
5524be3ce0 | ||
|
|
77bdb2f92c | ||
|
|
163fae21eb | ||
|
|
f58226aa27 | ||
|
|
2742a296ef | ||
|
|
bb204d6efb | ||
|
|
3a7c99cbbd | ||
|
|
7796d20984 | ||
|
|
9b3223c014 | ||
|
|
a5c5ba4b87 | ||
|
|
b53fea1812 | ||
|
|
b71a3456ce | ||
|
|
81cb44ed2b | ||
|
|
8c1fe6df1f | ||
|
|
91fadd1c3d | ||
|
|
28660a0873 | ||
|
|
5171ef5ef4 | ||
|
|
0cfd7eec9f | ||
|
|
2ed2361fdf | ||
|
|
7c8b05241a | ||
|
|
80e6edc474 | ||
|
|
9f13765db6 | ||
|
|
465f8e9f07 | ||
|
|
87112035fb | ||
|
|
ecb27ea068 | ||
|
|
8c7a71a3f6 | ||
|
|
4ea2d3bee2 | ||
|
|
42867bf41c |
No files matched your search
@@ -36,6 +36,9 @@ jobs:
|
||||
- name: Install
|
||||
run: npm i
|
||||
|
||||
- name: Format
|
||||
run: npm run format
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
|
||||
+69
-4
@@ -1,4 +1,69 @@
|
||||
# 2.0.0
|
||||
Initial stable release of QiTech Control.
|
||||
## Features
|
||||
- Stabilized **Winder V2**
|
||||
# `2.2.0`
|
||||
|
||||
_15.07.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- [#455](https://github.com/qitechgmbh/control/pull/458), Greatly improved stability and precision of pressure-based regulation for the motor
|
||||
- [#467](https://github.com/qitechgmbh/control/pull/468), [#448](https://github.com/qitechgmbh/control/pull/449), [#485](https://github.com/qitechgmbh/control/pull/486) Improved the Responsiveness for motor control and monitoring
|
||||
- [#138](https://github.com/qitechgmbh/control/pull/495), Added a Warning in case the Pressure sensor is disconnected or incorrectly wired
|
||||
- [#169](https://github.com/qitechgmbh/control/pull/492), Added a Confirmation Dialog when extruder has not reached 90% of target temperature
|
||||
|
||||
## Winder V2
|
||||
- [#471](https://github.com/qitechgmbh/control/pull/480), Added a live value to track the pulled distance and the option of switching to a different Mode after a given distance.
|
||||
|
||||
## General
|
||||
|
||||
- [#121](https://github.com/qitechgmbh/control/pull/121), Use Linux realtime threads for EtherCAT and control loop (PREEMPT_RT)
|
||||
- [#138](https://github.com/qitechgmbh/control/pull/495), Updated Quick Start Guide
|
||||
|
||||
# `2.1.0`
|
||||
|
||||
_30.06.2025_
|
||||
|
||||
## Extruder V2
|
||||
|
||||
- Intial Stable Release of **Extruder V2**
|
||||
|
||||
## Winder V2
|
||||
|
||||
- [#420](https://github.com/qitechgmbh/control/pull/429), [#401](https://github.com/qitechgmbh/control/pull/401) Add _Adaptive_ spool speed algorithm, the old algorithm is now called _Minmax_ ans tillavailable.
|
||||
- [#427](https://github.com/qitechgmbh/control/pull/427) Add _Estimated Spool Diameter_ graph
|
||||
- [#419](https://github.com/qitechgmbh/control/pull/419) Fix Issue where traverse won't work at low spool speed
|
||||
|
||||
## Laser V1
|
||||
|
||||
- Initial Stable Release of **Laser V1**
|
||||
|
||||
## HMI Panel
|
||||
|
||||
- [#339](https://github.com/qitechgmbh/control/pull/338) Don't go into sleep Mmde
|
||||
- [#410](https://github.com/qitechgmbh/control/pull/410) Ship with Wireshark
|
||||
- [#424](https://github.com/qitechgmbh/control/pull/424) Updates can be canceled
|
||||
|
||||
## General
|
||||
|
||||
- [#269](https://github.com/qitechgmbh/control/pull/269), [#370](https://github.com/qitechgmbh/control/pull/370) Improved performace of live values
|
||||
- [#358](https://github.com/qitechgmbh/control/pull/358), [#343](https://github.com/qitechgmbh/control/pull/343), [#324](https://github.com/qitechgmbh/control/pull/324), [#390](https://github.com/qitechgmbh/control/pull/390), [#299](https://github.com/qitechgmbh/control/pull/299) Improved functionality for graphs
|
||||
- [#405](https://github.com/qitechgmbh/control/pull/405) Remove weird color behaviour on tocuhscreens
|
||||
- [#369](https://github.com/qitechgmbh/control/pull/369) More failsafe EtherCAT loop
|
||||
|
||||
# `2.0.1`
|
||||
|
||||
_10.06.2025_
|
||||
|
||||
# `2.0.0`
|
||||
|
||||
_10.06.2025_
|
||||
|
||||
## General
|
||||
|
||||
- Initial stable release of QiTech Control.
|
||||
|
||||
## Winder V2
|
||||
|
||||
- Initial stable release of **Winder V2**
|
||||
|
||||
## Features
|
||||
|
||||
- Stabilized **Winder V2**
|
||||
Generated
+26
@@ -581,17 +581,20 @@ dependencies = [
|
||||
"approx",
|
||||
"axum",
|
||||
"bitvec",
|
||||
"core_affinity",
|
||||
"crc",
|
||||
"erased-serde",
|
||||
"ethercat_hal",
|
||||
"ethercrab",
|
||||
"interfaces",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial",
|
||||
"serialport",
|
||||
"smol",
|
||||
"socketioxide",
|
||||
"textplots",
|
||||
"tracing",
|
||||
"uom",
|
||||
]
|
||||
@@ -612,6 +615,17 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "core_affinity"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num_cpus",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -992,6 +1006,7 @@ dependencies = [
|
||||
"bitvec",
|
||||
"ethercat_hal_derive",
|
||||
"ethercrab",
|
||||
"futures",
|
||||
"rand",
|
||||
"smol",
|
||||
"uom",
|
||||
@@ -1945,6 +1960,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_cpus"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
|
||||
dependencies = [
|
||||
"hermit-abi 0.5.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
@@ -2689,6 +2714,7 @@ dependencies = [
|
||||
"ethercat_hal",
|
||||
"ethercrab",
|
||||
"euclid",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"open",
|
||||
"opentelemetry",
|
||||
|
||||
@@ -41,6 +41,8 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
|
||||
# Dev Setup
|
||||
|
||||
[Developer Documentation](./docs/developer-docs/)
|
||||
|
||||
## Backend
|
||||
|
||||
- Rust stable 1.86^ toolchain (install via [rustup](https://rustup.rs/))
|
||||
@@ -66,7 +68,6 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
| Laser | V1 | ??? | Diameter Measuring Laser | - | 1 (Qitech Industries GmbH) | 6 | Not Yet | |
|
||||
| Mock | - | ??? | Mock Machine for Testing | - | 1 (Qitech Industries GmbH) | 7 | Not Yet | - |
|
||||
|
||||
|
||||
# More Docs
|
||||
|
||||
- [x] [Architecture & Data Flow](./docs/architecture-overview.md)
|
||||
@@ -90,8 +91,9 @@ We could combine the code of the frontend and backend using [Doxius](https://dio
|
||||
- Write Device Identification
|
||||
|
||||
- Server
|
||||
- [X] [Threading](./docs/control-loop.md)
|
||||
- [X] [Logging](./docs/logging.md)
|
||||
|
||||
- [x] [Threading](./docs/control-loop.md)
|
||||
- [x] [Logging](./docs/logging.md)
|
||||
- [ ] Control Loop Setup
|
||||
- Control Loop Thread
|
||||
- [ ] realtime
|
||||
|
||||
+18
-8
@@ -1,13 +1,23 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ $1 == "release" ]; then
|
||||
echo "building Release Code"
|
||||
cargo build --release
|
||||
FEATURE=""
|
||||
|
||||
# Check if "mock-machine" was passed as an argument
|
||||
if [ "$1" == "mock-machine" ]; then
|
||||
echo "Building Debug Code with mock-machine feature"
|
||||
FEATURE="--features mock-machine"
|
||||
else
|
||||
echo "building Debug Code"
|
||||
cargo build
|
||||
echo "Building Debug Code"
|
||||
fi
|
||||
# set cap for server executable
|
||||
sudo setcap cap_net_raw=eip ./target/debug/server
|
||||
# run
|
||||
|
||||
# Build the project with or without features
|
||||
cargo build $FEATURE
|
||||
|
||||
# Only setcap if not building with mock-machine
|
||||
if [ "$1" != "mock-machine" ]; then
|
||||
echo "Setting capabilities for server executable"
|
||||
sudo setcap cap_net_raw=eip ./target/debug/server
|
||||
fi
|
||||
|
||||
# Run the server
|
||||
./target/debug/server
|
||||
@@ -20,5 +20,11 @@ crc = "3.3.0"
|
||||
serial = "0.4.0"
|
||||
tracing = "0.1.41"
|
||||
erased-serde = "0.4.6"
|
||||
core_affinity = "0.8.3"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.172"
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5.1"
|
||||
textplots = "0.8"
|
||||
@@ -1,91 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::analog_output::AnalogOutput;
|
||||
use std::{
|
||||
f64::consts::PI,
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uom::si::{angle::radian, f64::Angle, ratio::ratio};
|
||||
pub type AnalogFunction = Box<dyn Fn(Duration) -> f64 + Send + Sync>;
|
||||
|
||||
/// Can module analog output with a function
|
||||
pub struct AnalogFunctionGenerator {
|
||||
output: AnalogOutput,
|
||||
function: AnalogFunction,
|
||||
start_ts: Instant,
|
||||
}
|
||||
|
||||
impl AnalogFunctionGenerator {
|
||||
pub fn new(output: AnalogOutput, function: AnalogFunction) -> Self {
|
||||
Self {
|
||||
output,
|
||||
function,
|
||||
start_ts: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for AnalogFunctionGenerator {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let diff = now - self.start_ts;
|
||||
let value = (self.function)(diff);
|
||||
(self.output.write)((value as f32).into()).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn analog_sine(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
// Convert to seconds as f64 for calculation
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let angle = Angle::new::<radian>(2.0 * PI * phase_secs / period_secs);
|
||||
let value = angle.sin().get::<ratio>();
|
||||
value * (amplitude / 2.0) + normal + (amplitude / 2.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_multiply<const N: usize>(functions: [AnalogFunction; N]) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| functions.iter().fold(1.0, |acc, func| acc * func(dt)))
|
||||
}
|
||||
|
||||
pub fn analog_sawtooth(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = phase_secs / period_secs;
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_square(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = if phase_secs < period_secs / 2.0 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
|
||||
pub fn analog_triangle(amplitude: f64, normal: f64, period: Duration) -> AnalogFunction {
|
||||
Box::new(move |dt: Duration| {
|
||||
let dt_secs = dt.as_secs_f64();
|
||||
let period_secs = period.as_secs_f64();
|
||||
let phase_secs = dt_secs % period_secs;
|
||||
let value = if phase_secs < period_secs / 2.0 {
|
||||
phase_secs / period_secs * 2.0
|
||||
} else {
|
||||
2.0 - phase_secs / period_secs * 2.0
|
||||
};
|
||||
value * amplitude + normal
|
||||
})
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::analog_input::{AnalogInput, physical::AnalogInputValue};
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a analog input
|
||||
#[derive(Debug)]
|
||||
pub struct AnalogInputGetter {
|
||||
input: AnalogInput,
|
||||
normalized: Option<f32>,
|
||||
}
|
||||
|
||||
impl AnalogInputGetter {
|
||||
pub fn new(input: AnalogInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
normalized: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Value from -1.0 to 1.0
|
||||
pub fn get_normalized(&self) -> Option<f32> {
|
||||
self.normalized
|
||||
}
|
||||
|
||||
pub fn get_physical(&self) -> Option<AnalogInputValue> {
|
||||
match self.get_normalized() {
|
||||
Some(normalized) => Some(self.input.range.normalized_to_physical(normalized)),
|
||||
None => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for AnalogInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)().await;
|
||||
self.normalized = Some(state.input.normalized);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_input::DigitalInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a digital input
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalInputGetter {
|
||||
input: DigitalInput,
|
||||
value: bool,
|
||||
}
|
||||
|
||||
impl DigitalInputGetter {
|
||||
pub fn new(input: DigitalInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
value: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current value of the digital input
|
||||
pub fn value(&self) -> bool {
|
||||
self.value
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)()
|
||||
.await
|
||||
.expect("Failed to get digital input state");
|
||||
self.value = state.input.value;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_input::DigitalInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a digital input
|
||||
pub struct DigitalInputLogger {
|
||||
input: DigitalInput,
|
||||
}
|
||||
|
||||
impl DigitalInputLogger {
|
||||
pub fn new(input: DigitalInput) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalInputLogger {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)()
|
||||
.await
|
||||
.expect("Failed to get digital input state");
|
||||
println!("DigitalInputLogger: {}", state.input.value);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputBlinker {
|
||||
last_toggle: Instant,
|
||||
output: DigitalOutput,
|
||||
interval: Duration,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl DigitalOutputBlinker {
|
||||
pub fn new(output: DigitalOutput, interval: Duration) -> Self {
|
||||
Self {
|
||||
last_toggle: Instant::now(),
|
||||
output,
|
||||
interval,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.interval = interval;
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputBlinker {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
let state = (self.output.state)().await;
|
||||
if now - self.last_toggle > self.interval {
|
||||
match state.output.into() {
|
||||
true => (self.output.write)(false.into()).await,
|
||||
false => (self.output.write)(true.into()).await,
|
||||
}
|
||||
self.last_toggle = now;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Set a series of digital outputs high in sequence with a given interval
|
||||
pub struct DigitalOutputBlinkers {
|
||||
last_toggle: Instant,
|
||||
outputs: Vec<Option<DigitalOutput>>,
|
||||
index: usize,
|
||||
interval: Duration,
|
||||
enabled: bool,
|
||||
amount: usize,
|
||||
}
|
||||
|
||||
impl DigitalOutputBlinkers {
|
||||
pub fn new(outputs: Vec<Option<DigitalOutput>>, interval: Duration, amount: usize) -> Self {
|
||||
Self {
|
||||
last_toggle: Instant::now(),
|
||||
outputs,
|
||||
interval,
|
||||
enabled: true,
|
||||
index: 0,
|
||||
amount,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_interval(&mut self, interval: Duration) {
|
||||
self.interval = interval;
|
||||
}
|
||||
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputBlinkers {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if now - self.last_toggle > self.interval {
|
||||
if let Some(output) = &self.outputs[self.index] {
|
||||
(output.write)(true.into()).await;
|
||||
}
|
||||
let index_end =
|
||||
(self.index + self.outputs.len() - self.amount) % self.outputs.len();
|
||||
if let Some(output) = &self.outputs[index_end] {
|
||||
(output.write)(false.into()).await;
|
||||
}
|
||||
self.last_toggle = now;
|
||||
self.index = (self.index + 1) % self.outputs.len();
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputSetter {
|
||||
output: DigitalOutput,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl DigitalOutputSetter {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self {
|
||||
output,
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
pub fn set(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
pub fn get(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
pub fn toggle(&mut self) {
|
||||
self.enabled = !self.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for DigitalOutputSetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
(self.output.write)(self.enabled.into()).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,625 +0,0 @@
|
||||
use super::Actor;
|
||||
use crate::modbus::{
|
||||
ModbusFunctionCode, ModbusRequest, ModbusResponse, calculate_modbus_rtu_timeout,
|
||||
};
|
||||
use axum::http::request;
|
||||
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
pin::Pin,
|
||||
time::{Duration, Instant},
|
||||
u16,
|
||||
};
|
||||
use uom::{
|
||||
ConstZero,
|
||||
si::{f64::Frequency, frequency::centihertz},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
/// WaitingForResponse is set after sending a request through the serial_interface
|
||||
WaitingForResponse,
|
||||
/// After Sending a Resuest we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has transmit accepted toggled
|
||||
/// Then we can set state = ReadyToSend
|
||||
WaitingForRequestAccept,
|
||||
/// After Receiving a Response we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has received accepted toggled
|
||||
WaitingForReceiveAccept,
|
||||
/// ReadyToSend is set after receiving the response from the serial_interface
|
||||
ReadyToSend,
|
||||
/// Initial State
|
||||
Uninitialized,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OperationMode {
|
||||
PU,
|
||||
EXT,
|
||||
NET,
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
pub fn convert_hz_float_to_word(&mut self, value: Frequency) -> u16 {
|
||||
let scaled = value.get::<centihertz>(); // Convert Hz to 0.01 Hz units
|
||||
scaled.round() as u16
|
||||
}
|
||||
|
||||
pub fn set_frequency_target(&mut self, frequency: Frequency) {
|
||||
let mut request: MitsubishiModbusRequest =
|
||||
MitsubishiControlRequests::WriteRunningFrequency.into();
|
||||
let result: u16 = self.convert_hz_float_to_word(frequency); // convert hz float to short
|
||||
request.request.data[2] = result.to_le_bytes()[1];
|
||||
request.request.data[3] = result.to_le_bytes()[0];
|
||||
self.add_request(request);
|
||||
}
|
||||
|
||||
pub fn read_running_frequency() {
|
||||
// Check if the current element pushed to front is freq event
|
||||
}
|
||||
|
||||
pub fn switch_operation_mode() {}
|
||||
}
|
||||
|
||||
/// Specifies all System environmet Variables
|
||||
/// Register addresses are calculated as follows: Register-value 40002 -> address: 40002-40001 -> address:0x1
|
||||
enum MitsubishiSystemRegister {
|
||||
/// Register 40002
|
||||
InverterReset,
|
||||
/// Register 40003
|
||||
ParameterClear,
|
||||
/// Register 40004
|
||||
AllParameterClear,
|
||||
/// Register 40006
|
||||
ParamClearNonCommunication,
|
||||
/// Register 40007
|
||||
AllParameterClearNonCommunication,
|
||||
/// Register 40009
|
||||
InverterStatusAndControl,
|
||||
/// Register 40010
|
||||
OperationModeAndSetting,
|
||||
/// Register 40014
|
||||
RunningFrequencyRAM,
|
||||
/// Register 40015
|
||||
RunningFrequencyEEPROM,
|
||||
/// Register 40201
|
||||
MotorFrequency,
|
||||
}
|
||||
|
||||
impl MitsubishiControlRequests {
|
||||
fn get_system_register(value: MitsubishiSystemRegister) -> u16 {
|
||||
match value {
|
||||
MitsubishiSystemRegister::InverterReset => 0x1,
|
||||
MitsubishiSystemRegister::ParameterClear => 0x2,
|
||||
MitsubishiSystemRegister::AllParameterClear => 0x3,
|
||||
MitsubishiSystemRegister::ParamClearNonCommunication => 0x5,
|
||||
MitsubishiSystemRegister::AllParameterClearNonCommunication => 0x6,
|
||||
MitsubishiSystemRegister::InverterStatusAndControl => 0x8,
|
||||
MitsubishiSystemRegister::OperationModeAndSetting => 0x9,
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM => 0x0d,
|
||||
MitsubishiSystemRegister::RunningFrequencyEEPROM => 0x0e,
|
||||
MitsubishiSystemRegister::MotorFrequency => 0x00C8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
MitsubishiModbusRequest get executed by their priority
|
||||
Start and StopMotor are highest priority while writeRunningFrequency and readMotorFrequency are one lower
|
||||
lets say we had StartMotor and readMotorFrequency the order of execution is:
|
||||
1. StartMotor
|
||||
2. readMotorFrequency
|
||||
|
||||
this is because StartMotor is higher priority
|
||||
Since the events do not need to be pushed into a queue this makes the inverter operation more stable
|
||||
*/
|
||||
impl From<MitsubishiControlRequests> for MitsubishiModbusRequest {
|
||||
fn from(request: MitsubishiControlRequests) -> Self {
|
||||
match request {
|
||||
MitsubishiControlRequests::WriteRunningFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0x0],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::WriteFrequency,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::WriteRunningFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadInverterStatus => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Read 1 register
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterStatus,
|
||||
priority: u16::MAX - 3,
|
||||
control_request_type: MitsubishiControlRequests::ReadInverterStatus,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StopMotor => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Value 1 to stop
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX, // StopMotor should have highest priority
|
||||
control_request_type: MitsubishiControlRequests::StopMotor,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartForwardRotation => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0, 0b00000010], // Value 2 for forward rotation
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::StartForwardRotation,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::StartReverseRotation => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterStatusAndControl,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0, 0b00000100], // Value 4 for reverse rotation
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::InverterControl,
|
||||
priority: u16::MAX - 1,
|
||||
control_request_type: MitsubishiControlRequests::StartReverseRotation,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadRunningFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::RunningFrequencyRAM,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x00, 0x01], // Read 1 register
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadFrequency,
|
||||
priority: u16::MAX - 4,
|
||||
control_request_type: MitsubishiControlRequests::ReadRunningFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ReadMotorFrequency => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::MotorFrequency,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::ReadHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0x1],
|
||||
},
|
||||
request_type: RequestType::OperationCommand,
|
||||
expected_response_type: ResponseType::ReadMotorFrequency,
|
||||
priority: u16::MAX - 2,
|
||||
control_request_type: MitsubishiControlRequests::ReadMotorFrequency,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ResetInverter => {
|
||||
let reg: u16 = MitsubishiControlRequests::get_system_register(
|
||||
MitsubishiSystemRegister::InverterReset,
|
||||
);
|
||||
let reg_bytes = reg.to_be_bytes();
|
||||
MitsubishiModbusRequest {
|
||||
request: ModbusRequest {
|
||||
slave_id: 1,
|
||||
function_code: ModbusFunctionCode::PresetHoldingRegister,
|
||||
data: vec![reg_bytes[0], reg_bytes[1], 0x0, 0b00000001],
|
||||
},
|
||||
request_type: RequestType::Reset,
|
||||
expected_response_type: ResponseType::NoResponse,
|
||||
priority: u16::MAX,
|
||||
control_request_type: MitsubishiControlRequests::ResetInverter,
|
||||
ignored_times: 0,
|
||||
}
|
||||
}
|
||||
MitsubishiControlRequests::ClearAllParameters => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameter => todo!(),
|
||||
MitsubishiControlRequests::ClearNonCommunicationParameters => todo!(),
|
||||
MitsubishiControlRequests::None => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These Requests Serve as Templates for controling the inverter
|
||||
#[derive(Debug, Clone, Eq, Hash, PartialEq)]
|
||||
pub enum MitsubishiControlRequests {
|
||||
None,
|
||||
/// Register 40002, Reset/Restart the Inverter
|
||||
ResetInverter,
|
||||
/// Register 40004, Clear ALL parameters
|
||||
ClearAllParameters,
|
||||
/// Register 40006, Clear a non communication parameter
|
||||
ClearNonCommunicationParameter,
|
||||
/// Register 40007, Clear all Non Communication related Parameters
|
||||
ClearNonCommunicationParameters,
|
||||
/// Register 40009, Read Inverter Status
|
||||
ReadInverterStatus,
|
||||
/// Register 40009, Stops the Motor
|
||||
StopMotor,
|
||||
/// Register 40009, Starts the Motor in Forward Rotation
|
||||
StartForwardRotation,
|
||||
/// Register 40009, Starts the Motor in Reverse Rotation
|
||||
StartReverseRotation,
|
||||
/// Register 40014, Read the current frequency the motor runs at (RAM)
|
||||
ReadRunningFrequency,
|
||||
/// Register 40014, Write the frequency
|
||||
WriteRunningFrequency,
|
||||
/// Read Register 40201, This contains the actual output frequency
|
||||
ReadMotorFrequency,
|
||||
}
|
||||
|
||||
// We need to know from the request queue which events are of what operation type, so that the correct timeout can be used
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MitsubishiModbusRequest {
|
||||
request: ModbusRequest,
|
||||
control_request_type: MitsubishiControlRequests,
|
||||
request_type: RequestType,
|
||||
expected_response_type: ResponseType,
|
||||
priority: u16,
|
||||
ignored_times: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RotationDirection {
|
||||
Forward,
|
||||
Backwards,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ResponseType {
|
||||
NoResponse,
|
||||
ReadFrequency,
|
||||
ReadMotorFrequency, // Motor Frequency is the actual frequency, that the motor is running at right now
|
||||
ReadInverterStatus,
|
||||
WriteFrequency,
|
||||
InverterStatus,
|
||||
InverterControl,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct MitsubishiInverterRS485Actor {
|
||||
// Communication
|
||||
pub serial_interface: SerialInterface,
|
||||
pub baudrate: Option<u32>,
|
||||
pub encoding: Option<SerialEncoding>,
|
||||
pub request_map: HashMap<MitsubishiControlRequests, MitsubishiModbusRequest>,
|
||||
pub response: Option<ModbusResponse>,
|
||||
|
||||
// State
|
||||
pub last_ts: Instant,
|
||||
pub last_message_size: usize,
|
||||
pub last_request_type: RequestType,
|
||||
pub last_control_request_type: MitsubishiControlRequests,
|
||||
pub state: State,
|
||||
pub next_response_type: ResponseType,
|
||||
pub frequency: Frequency,
|
||||
}
|
||||
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
pub fn new(serial_interface: SerialInterface) -> Self {
|
||||
Self {
|
||||
serial_interface,
|
||||
last_ts: Instant::now(),
|
||||
state: State::Uninitialized,
|
||||
request_map: HashMap::new(),
|
||||
response: None,
|
||||
next_response_type: ResponseType::NoResponse,
|
||||
last_request_type: RequestType::OperationCommand,
|
||||
last_message_size: 0,
|
||||
baudrate: None,
|
||||
encoding: None,
|
||||
frequency: Frequency::ZERO,
|
||||
last_control_request_type: MitsubishiControlRequests::ResetInverter,
|
||||
}
|
||||
}
|
||||
|
||||
/// This would get called by the api to add a new request to the inverter
|
||||
pub fn add_request(&mut self, request: MitsubishiModbusRequest) {
|
||||
// If a request already exists and its values get replaced, then use the olde requests ignored_times, to ensure its executed at some point
|
||||
// Otherwise requests that are added frequently are never called, because new requests have ignored_times = 0
|
||||
if self.request_map.contains_key(&request.control_request_type) {
|
||||
// unwrap is safe here
|
||||
let old_request = self.request_map.get(&request.control_request_type).unwrap();
|
||||
|
||||
let mut new_request = request.clone();
|
||||
new_request.ignored_times = old_request.ignored_times;
|
||||
|
||||
self.request_map
|
||||
.insert(request.control_request_type.clone(), new_request);
|
||||
} else {
|
||||
self.request_map
|
||||
.insert(request.control_request_type.clone(), request);
|
||||
}
|
||||
}
|
||||
|
||||
/// This is used internally to read the receive buffer of the el6021
|
||||
fn read_modbus_response(
|
||||
&mut self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<ModbusResponse, anyhow::Error>> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let res: Option<Vec<u8>> = (self.serial_interface.read_message)().await;
|
||||
let raw_response = match res {
|
||||
Some(res) => res,
|
||||
None => {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
let response: Result<ModbusResponse, _> =
|
||||
ModbusResponse::try_from(raw_response.clone());
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
self.last_message_size = result.clone().data.len() + 4;
|
||||
self.state = State::WaitingForReceiveAccept;
|
||||
Ok(result)
|
||||
}
|
||||
Err(_) => {
|
||||
self.last_message_size = 22;
|
||||
self.state = State::WaitingForReceiveAccept;
|
||||
Err(anyhow::anyhow!("error"))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn set_ignored_times_modbus_requests(&mut self) {
|
||||
for (_, value) in self.request_map.iter_mut() {
|
||||
value.ignored_times += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// This is used internally to fill the write buffer of the el6021 with the modbus request
|
||||
/// Decides what requests to send first by finding the one with the highest priority
|
||||
/// For example Highest Priority requests: ResetInverter StopMotor
|
||||
fn send_modbus_request(&mut self) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
if self.request_map.len() == 0 {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut highest_prio_request: Option<&mut MitsubishiModbusRequest> = None;
|
||||
let mut highest_priority: u32 = 0;
|
||||
|
||||
for (_, value) in self.request_map.iter_mut() {
|
||||
// borrowchecker complaining
|
||||
let priority = value.priority as u32;
|
||||
let ignored_times = value.ignored_times;
|
||||
let effective_priority: u32 = priority as u32 + ignored_times;
|
||||
|
||||
if effective_priority > highest_priority {
|
||||
highest_prio_request = Some(value);
|
||||
highest_priority = effective_priority;
|
||||
}
|
||||
}
|
||||
|
||||
let request = match highest_prio_request {
|
||||
Some(request) => request,
|
||||
None => return,
|
||||
};
|
||||
let modbus_request: Vec<u8> = request.request.clone().into();
|
||||
let res = (self.serial_interface.write_message)(modbus_request.clone()).await;
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
self.next_response_type = request.expected_response_type;
|
||||
self.last_request_type = request.request_type;
|
||||
self.last_control_request_type = request.control_request_type.clone();
|
||||
}
|
||||
Err(_) => tracing::error!("ERROR: serial_interface.write_message has failed"),
|
||||
}
|
||||
|
||||
self.state = State::WaitingForRequestAccept;
|
||||
self.last_message_size = modbus_request.len();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum RequestType {
|
||||
None,
|
||||
/// Monitoring, Operation (start,stop etc) command, frequency setting (RAM), less than 12 milliseconds timeout for Response
|
||||
OperationCommand,
|
||||
/// Parameter Read/Write and Frequency (EEPROM), Less than 30 milliseconds timeout for Response
|
||||
ReadWrite,
|
||||
/// Less than 5 seconds timeout for Response
|
||||
ParamClear,
|
||||
/// Supposedly no waiting time, however inverter takes a while to start ~300ms should be more than enough
|
||||
Reset,
|
||||
}
|
||||
|
||||
impl RequestType {
|
||||
fn timeout_duration(self) -> Duration {
|
||||
match self {
|
||||
RequestType::OperationCommand => Duration::from_millis(12),
|
||||
RequestType::ReadWrite => Duration::from_millis(30),
|
||||
RequestType::ParamClear => Duration::from_millis(5000),
|
||||
RequestType::Reset => Duration::from_millis(300),
|
||||
RequestType::None => Duration::from_millis(12),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum MitsubishiModbusExceptionCode {
|
||||
IllegalFunction,
|
||||
IllegalDataAddress,
|
||||
IllegalDataValue,
|
||||
None,
|
||||
}
|
||||
|
||||
impl From<MitsubishiModbusExceptionCode> for u8 {
|
||||
fn from(value: MitsubishiModbusExceptionCode) -> Self {
|
||||
match value {
|
||||
MitsubishiModbusExceptionCode::None => 0,
|
||||
MitsubishiModbusExceptionCode::IllegalFunction => 1,
|
||||
MitsubishiModbusExceptionCode::IllegalDataAddress => 2,
|
||||
MitsubishiModbusExceptionCode::IllegalDataValue => 3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for MitsubishiModbusExceptionCode {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
1 => MitsubishiModbusExceptionCode::IllegalFunction,
|
||||
2 => MitsubishiModbusExceptionCode::IllegalDataAddress,
|
||||
3 => MitsubishiModbusExceptionCode::IllegalDataValue,
|
||||
_ => MitsubishiModbusExceptionCode::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle different Response types
|
||||
impl MitsubishiInverterRS485Actor {
|
||||
fn handle_motor_frequency(&mut self, resp: ModbusResponse) {
|
||||
let freq_bytes = &resp.data[1..3]; // bytes 1 and 2 are needed
|
||||
let raw_frequency = u16::from_be_bytes([freq_bytes[0], freq_bytes[1]]) as f64;
|
||||
self.frequency = Frequency::new::<centihertz>(raw_frequency);
|
||||
}
|
||||
|
||||
fn handle_response(&mut self, resp: ModbusResponse) {
|
||||
match self.next_response_type {
|
||||
ResponseType::ReadFrequency => (),
|
||||
ResponseType::ReadInverterStatus => (),
|
||||
ResponseType::WriteFrequency => (),
|
||||
ResponseType::ReadMotorFrequency => self.handle_motor_frequency(resp),
|
||||
ResponseType::InverterStatus => (),
|
||||
ResponseType::InverterControl => (),
|
||||
ResponseType::NoResponse => (),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_state(&mut self) {
|
||||
self.state = State::ReadyToSend;
|
||||
self.last_ts = Instant::now();
|
||||
self.request_map = HashMap::new();
|
||||
self.response = None;
|
||||
self.next_response_type = ResponseType::InverterStatus;
|
||||
self.last_request_type = RequestType::OperationCommand;
|
||||
self.last_message_size = 0;
|
||||
self.frequency = Frequency::ZERO;
|
||||
self.last_control_request_type = MitsubishiControlRequests::ReadInverterStatus;
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for MitsubishiInverterRS485Actor {
|
||||
fn act(&mut self, now_ts: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let elapsed: Duration = now_ts.duration_since(self.last_ts);
|
||||
// State is uninitialized until serial interface init returns true, which takes a few cycles on the el6021
|
||||
if let State::Uninitialized = self.state {
|
||||
let res = (self.serial_interface.initialize)().await;
|
||||
if res == true {
|
||||
self.state = State::ReadyToSend;
|
||||
// every time when our inverter is "Uninitialzed" reset it first to clear any error states it may have
|
||||
self.add_request(MitsubishiControlRequests::ResetInverter.into());
|
||||
self.baudrate = (self.serial_interface.get_baudrate)().await;
|
||||
self.encoding = (self.serial_interface.get_serial_encoding)().await;
|
||||
self.last_ts = now_ts;
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let encoding = match self.encoding {
|
||||
Some(encoding) => encoding,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let baudrate = match self.baudrate {
|
||||
Some(baudrate) => baudrate,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let timeout = calculate_modbus_rtu_timeout(
|
||||
encoding.total_bits(),
|
||||
self.last_request_type.timeout_duration(),
|
||||
baudrate,
|
||||
self.last_message_size,
|
||||
);
|
||||
|
||||
if elapsed < timeout {
|
||||
return;
|
||||
}
|
||||
self.add_request(MitsubishiControlRequests::ReadMotorFrequency.into());
|
||||
|
||||
self.last_ts = now_ts;
|
||||
match self.state {
|
||||
State::WaitingForResponse => {
|
||||
let ret = self.read_modbus_response().await;
|
||||
match ret {
|
||||
Ok(ret) => {
|
||||
self.handle_response(ret);
|
||||
}
|
||||
Err(_) => (), // Do nothing for now
|
||||
}
|
||||
|
||||
self.next_response_type = ResponseType::NoResponse;
|
||||
}
|
||||
State::ReadyToSend => {
|
||||
self.send_modbus_request().await;
|
||||
self.request_map.remove(&self.last_control_request_type);
|
||||
self.set_ignored_times_modbus_requests();
|
||||
}
|
||||
State::WaitingForReceiveAccept => {
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
State::WaitingForRequestAccept => {
|
||||
self.state = State::WaitingForResponse;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
self.response = None;
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
pub mod analog_function_generator;
|
||||
pub mod analog_input_getter;
|
||||
pub mod digital_input_getter;
|
||||
pub mod digital_input_logger;
|
||||
pub mod digital_output_blinker;
|
||||
pub mod digital_output_blinkers;
|
||||
pub mod digital_output_setter;
|
||||
pub mod mitsubishi_inverter_rs485;
|
||||
pub mod stepper_driver_el70x1;
|
||||
pub mod stepper_driver_max_speed;
|
||||
pub mod stepper_driver_pulse_train;
|
||||
pub mod temperature_input_getter;
|
||||
pub mod temperature_input_logger;
|
||||
|
||||
use std::{pin::Pin, time::Instant};
|
||||
|
||||
pub trait Actor: Send + Sync {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use super::Actor;
|
||||
use core::panic;
|
||||
use ethercat_hal::{
|
||||
helpers::el70xx_velocity_converter::EL70x1VelocityConverter,
|
||||
io::stepper_velocity_el70x1::StepperVelocityEL70x1, shared_config::el70x1::EL70x1SpeedRange,
|
||||
};
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct StepperDriverEL70x1 {
|
||||
stepper: StepperVelocityEL70x1,
|
||||
enabled: bool,
|
||||
velocity: i16,
|
||||
position: i128,
|
||||
set_position: Option<i128>,
|
||||
pub converter: EL70x1VelocityConverter,
|
||||
}
|
||||
|
||||
impl StepperDriverEL70x1 {
|
||||
pub fn new(stepper: StepperVelocityEL70x1, speed_range: &EL70x1SpeedRange) -> Self {
|
||||
Self {
|
||||
stepper,
|
||||
enabled: false,
|
||||
velocity: 0,
|
||||
converter: EL70x1VelocityConverter::new(speed_range),
|
||||
position: 0,
|
||||
set_position: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the speed in steps per second
|
||||
pub fn set_speed(&mut self, steps_per_second: i32) {
|
||||
self.velocity = self.converter.steps_to_velocity(steps_per_second, true)
|
||||
}
|
||||
|
||||
/// Get the speed in steps per second
|
||||
pub fn get_speed(&self) -> i32 {
|
||||
self.converter.velocity_to_steps(self.velocity, true) as i32
|
||||
}
|
||||
|
||||
/// Enable or disable the stepper
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
}
|
||||
|
||||
/// Get the enabled state of the stepper
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
/// Get the current position of the stepper
|
||||
pub fn get_position(&self) -> i128 {
|
||||
self.position
|
||||
}
|
||||
|
||||
/// Set the position of the stepper
|
||||
pub fn set_position(&mut self, position: i128) {
|
||||
self.set_position = Some(position);
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for StepperDriverEL70x1 {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = match (self.stepper.state)().await {
|
||||
Ok(state) => state,
|
||||
Err(e) => {
|
||||
panic!("Error while reading StepperVelocity {:?}", e);
|
||||
}
|
||||
};
|
||||
// set the input
|
||||
self.position = state.input.counter_value;
|
||||
|
||||
let mut output = state.output.clone();
|
||||
|
||||
// set the output
|
||||
output.enable = self.enabled;
|
||||
output.velocity = self.velocity;
|
||||
output.set_counter = self.set_position.take();
|
||||
|
||||
// write the output
|
||||
match (self.stepper.write)(output).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
panic!("Error while writing StepperVelocity {:?}", e);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::digital_output::DigitalOutput;
|
||||
use std::{
|
||||
future::Future,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
pub struct StepperDriverMaxSpeed {
|
||||
pulse: DigitalOutput,
|
||||
}
|
||||
|
||||
impl StepperDriverMaxSpeed {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self { pulse: output }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for StepperDriverMaxSpeed {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.pulse.state)().await;
|
||||
match state.output.into() {
|
||||
true => (self.pulse.write)(false.into()).await,
|
||||
false => (self.pulse.write)(true.into()).await,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StepperDriverMaxSpeed> for Arc<RwLock<StepperDriverMaxSpeed>> {
|
||||
fn from(actor: StepperDriverMaxSpeed) -> Self {
|
||||
Arc::new(RwLock::new(actor))
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/// Translates the encoder value to a larger space and handle underflow/overflow situations
|
||||
#[derive(Debug)]
|
||||
pub struct Encoder {
|
||||
pub position: i64,
|
||||
pub last_encoder_position: u32,
|
||||
}
|
||||
|
||||
impl Encoder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
position: 0,
|
||||
last_encoder_position: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, counter_value: u32, counter_overflow: bool, counter_underflow: bool) {
|
||||
// Handle overflow case
|
||||
if counter_overflow {
|
||||
// When overflow occurs, the counter wraps from MAX to 0
|
||||
// Calculate how much it moved past MAX
|
||||
let delta = counter_value as i64;
|
||||
// Add the remaining distance from last position to MAX, plus the overflow amount
|
||||
self.position += (u32::MAX - self.last_encoder_position) as i64 + 1 + delta;
|
||||
}
|
||||
// Handle underflow case
|
||||
else if counter_underflow {
|
||||
// When underflow occurs, the counter wraps from 0 to MAX
|
||||
// Calculate how much it moved below 0
|
||||
let delta = (u32::MAX - counter_value) as i64;
|
||||
// Subtract the distance from last position to 0, plus the underflow amount
|
||||
self.position -= self.last_encoder_position as i64 + 1 + delta;
|
||||
}
|
||||
// Normal case - no overflow/underflow
|
||||
else {
|
||||
// Calculate the signed difference between current and last position
|
||||
let delta = counter_value as i64 - self.last_encoder_position as i64;
|
||||
// Update the position
|
||||
self.position += delta;
|
||||
}
|
||||
|
||||
// Update the last encoder position for next time
|
||||
self.last_encoder_position = counter_value;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tests that the position tracker correctly handles normal forward movement
|
||||
/// without any overflow conditions.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Initial state is zeroed
|
||||
/// 2. Moving forward updates both position and last_encoder_position
|
||||
/// 3. Consecutive forward movements accumulate correctly
|
||||
#[test]
|
||||
fn test_normal_forward_movement() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Initial state
|
||||
assert_eq!(tracker.position, 0);
|
||||
assert_eq!(tracker.last_encoder_position, 0);
|
||||
|
||||
// Move forward by 100
|
||||
tracker.update(100, false, false);
|
||||
assert_eq!(tracker.position, 100);
|
||||
assert_eq!(tracker.last_encoder_position, 100);
|
||||
|
||||
// Move forward by another 50
|
||||
tracker.update(150, false, false);
|
||||
assert_eq!(tracker.position, 150);
|
||||
assert_eq!(tracker.last_encoder_position, 150);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles normal backward movement
|
||||
/// without any underflow conditions.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Starting from a non-zero position
|
||||
/// 2. Moving backward updates both position and last_encoder_position
|
||||
/// 3. Consecutive backward movements accumulate correctly
|
||||
#[test]
|
||||
fn test_normal_backward_movement() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Start at position 1000
|
||||
tracker.position = 1000;
|
||||
tracker.last_encoder_position = 1000;
|
||||
|
||||
// Move backward by 100
|
||||
tracker.update(900, false, false);
|
||||
assert_eq!(tracker.position, 900);
|
||||
assert_eq!(tracker.last_encoder_position, 900);
|
||||
|
||||
// Move backward by another 50
|
||||
tracker.update(850, false, false);
|
||||
assert_eq!(tracker.position, 850);
|
||||
assert_eq!(tracker.last_encoder_position, 850);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles overflow conditions
|
||||
/// where the encoder counter wraps from near its maximum value to a small value.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. When overflow flag is set, the tracker correctly calculates the true movement
|
||||
/// 2. Position is updated to account for the full distance traveled
|
||||
/// 3. last_encoder_position is updated to the new counter value
|
||||
#[test]
|
||||
fn test_overflow() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Set position near the maximum u32 value
|
||||
tracker.position = 100;
|
||||
tracker.last_encoder_position = u32::MAX - 10;
|
||||
|
||||
// Overflow: counter goes from (MAX-10) to 5
|
||||
// This means we moved forward by 10 steps to MAX, then 1 step to overflow,
|
||||
// then 5 more steps to reach the current position
|
||||
tracker.update(5, true, false);
|
||||
|
||||
// Expected: position += (10 + 1 + 5) = position += 16
|
||||
assert_eq!(tracker.position, 116);
|
||||
assert_eq!(tracker.last_encoder_position, 5);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker correctly handles underflow conditions
|
||||
/// where the encoder counter wraps from near zero to a value near its maximum.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. When underflow flag is set, the tracker correctly calculates the true movement
|
||||
/// 2. Position is updated to account for the full distance traveled (in negative direction)
|
||||
/// 3. last_encoder_position is updated to the new counter value
|
||||
#[test]
|
||||
fn test_underflow() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Set position near zero
|
||||
tracker.position = 100;
|
||||
tracker.last_encoder_position = 10;
|
||||
|
||||
// Underflow: counter goes from 10 to (MAX-5)
|
||||
// This means we moved backward by 10 steps to 0, then 1 step to underflow,
|
||||
// then 5 more steps to reach the current position
|
||||
tracker.update(u32::MAX - 5, false, true);
|
||||
|
||||
// Expected: position -= (10 + 1 + 5) = position -= 16
|
||||
assert_eq!(tracker.position, 84);
|
||||
assert_eq!(tracker.last_encoder_position, u32::MAX - 5);
|
||||
}
|
||||
|
||||
/// Tests that the position tracker maintains correct behavior across
|
||||
/// a sequence of different operations including normal movement,
|
||||
/// overflow, and underflow.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. The tracker maintains state correctly between operations
|
||||
/// 2. Different types of movements can be handled in sequence
|
||||
/// 3. Position calculations remain accurate throughout various scenarios
|
||||
#[test]
|
||||
fn test_multiple_operations() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Normal forward
|
||||
tracker.update(100, false, false);
|
||||
assert_eq!(tracker.position, 100);
|
||||
|
||||
// Normal backward
|
||||
tracker.update(50, false, false);
|
||||
assert_eq!(tracker.position, 50);
|
||||
|
||||
// Overflow
|
||||
tracker.last_encoder_position = u32::MAX - 5;
|
||||
tracker.position = 50; // Reset for clarity
|
||||
tracker.update(10, true, false);
|
||||
assert_eq!(tracker.position, 66); // 50 + (5 + 1 + 10)
|
||||
|
||||
// Underflow
|
||||
tracker.last_encoder_position = 5;
|
||||
tracker.position = 100; // Reset for clarity
|
||||
tracker.update(u32::MAX - 10, false, true);
|
||||
assert_eq!(tracker.position, 84); // 100 - (5 + 1 + 10)
|
||||
}
|
||||
|
||||
/// Tests that the position tracker can handle multiple overflow and underflow
|
||||
/// events, maintaining an accurate position count that exceeds the u32 range.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. Multiple overflow events accumulate correctly in the i64 position
|
||||
/// 2. Multiple underflow events correctly subtract from the position
|
||||
/// 3. The i64 position field properly extends the range beyond u32 limits
|
||||
#[test]
|
||||
fn test_large_movements() {
|
||||
let mut tracker = Encoder::new();
|
||||
|
||||
// Test tracking beyond u32 limits with multiple overflows
|
||||
for _ in 0..5 {
|
||||
// Simulate overflow: move from (MAX-100) to 100
|
||||
tracker.last_encoder_position = u32::MAX - 100;
|
||||
tracker.update(100, true, false);
|
||||
|
||||
// Each iteration adds (100 + 1 + 100) = 201 to position
|
||||
// We move 100 steps to MAX, 1 step to overflow, and 100 more steps to reach 100
|
||||
}
|
||||
|
||||
// After 5 iterations, position should be 5 * 201 = 1005
|
||||
assert_eq!(tracker.position, 1005);
|
||||
|
||||
// Test tracking with multiple underflows
|
||||
for _ in 0..3 {
|
||||
// Simulate underflow: move from 100 to (MAX-100)
|
||||
tracker.last_encoder_position = 100;
|
||||
tracker.update(u32::MAX - 100, false, true);
|
||||
|
||||
// Each iteration subtracts (100 + 1 + 100) = 201 from position
|
||||
// We move 100 steps to 0, 1 step to underflow, and 100 more steps to reach MAX-100
|
||||
}
|
||||
|
||||
// Final position: 1005 - (3 * 201) = 1005 - 603 = 402
|
||||
assert_eq!(tracker.position, 402);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
use super::Actor;
|
||||
use encoder::Encoder;
|
||||
use ethercat_hal::io::pulse_train_output::PulseTrainOutput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
pub mod encoder;
|
||||
|
||||
/// Set a digital output high and low with a given interval
|
||||
#[derive(Debug)]
|
||||
pub struct StepperDriverPulseTrain {
|
||||
pulse: PulseTrainOutput,
|
||||
encoder: Encoder,
|
||||
frequency: i32,
|
||||
}
|
||||
|
||||
impl<'ptodevice> StepperDriverPulseTrain {
|
||||
pub fn new(output: PulseTrainOutput) -> Self {
|
||||
Self {
|
||||
pulse: output,
|
||||
encoder: Encoder::new(),
|
||||
frequency: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ptodevice> Actor for StepperDriverPulseTrain {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.pulse.state)().await;
|
||||
let mut output = state.output.clone();
|
||||
|
||||
// sync the counter to the encoder
|
||||
self.encoder.update(
|
||||
state.input.counter_value,
|
||||
state.input.counter_overflow,
|
||||
state.input.counter_underflow,
|
||||
);
|
||||
|
||||
// write frequency
|
||||
output.frequency_value = self.frequency;
|
||||
|
||||
(self.pulse.write)(output).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'ptodevice> StepperDriverPulseTrain {
|
||||
pub fn set_frequency(&mut self, frequency: i32) {
|
||||
self.frequency = frequency;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use std::{pin::Pin, time::Instant};
|
||||
|
||||
use ethercat_hal::io::temperature_input::TemperatureInput;
|
||||
|
||||
use super::Actor;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TemperatureInputGetter {
|
||||
input: TemperatureInput,
|
||||
temperature: f64,
|
||||
wiring_error: bool,
|
||||
}
|
||||
|
||||
impl TemperatureInputGetter {
|
||||
pub fn new(input: TemperatureInput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
temperature: 0.0,
|
||||
wiring_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_temperature(&self) -> f64 {
|
||||
self.temperature
|
||||
}
|
||||
|
||||
pub fn get_wiring_error(&self) -> bool {
|
||||
self.wiring_error
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for TemperatureInputGetter {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let input = (self.input.state)().await.input;
|
||||
self.temperature = input.temperature as f64;
|
||||
self.wiring_error = input.overvoltage | input.undervoltage;
|
||||
if self.wiring_error {
|
||||
self.temperature = 0.0;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use super::Actor;
|
||||
use ethercat_hal::io::temperature_input::TemperatureInput;
|
||||
use std::{future::Future, pin::Pin, time::Instant};
|
||||
|
||||
/// Log the state of a temperature input
|
||||
pub struct TemperatureInputLogger {
|
||||
input: TemperatureInput,
|
||||
}
|
||||
|
||||
impl TemperatureInputLogger {
|
||||
pub fn new(input: TemperatureInput) -> Self {
|
||||
Self { input }
|
||||
}
|
||||
}
|
||||
|
||||
impl Actor for TemperatureInputLogger {
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
let state = (self.input.state)().await;
|
||||
println!("TemperatureInputLogger: {:?}C", state.input.temperature);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ClampingTimeagnosticPidController {
|
||||
// Params
|
||||
/// Proportional gain
|
||||
kp: f64,
|
||||
/// Integral gain
|
||||
ki: f64,
|
||||
/// Derivative gain
|
||||
kd: f64,
|
||||
// State
|
||||
/// Proportional error
|
||||
ep: f64,
|
||||
/// Integral error
|
||||
ei: f64,
|
||||
/// Derivative error
|
||||
ed: f64,
|
||||
|
||||
min_ep: Option<f64>,
|
||||
max_ep: Option<f64>,
|
||||
|
||||
min_ei: Option<f64>,
|
||||
max_ei: Option<f64>,
|
||||
|
||||
min_ed: Option<f64>,
|
||||
max_ed: Option<f64>,
|
||||
|
||||
min_signal: Option<f64>,
|
||||
max_signal: Option<f64>,
|
||||
|
||||
last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ClampingTimeagnosticPidController {
|
||||
pub fn new(
|
||||
kp: f64,
|
||||
ki: f64,
|
||||
kd: f64,
|
||||
min_ep: Option<f64>,
|
||||
max_ep: Option<f64>,
|
||||
min_ei: Option<f64>,
|
||||
max_ei: Option<f64>,
|
||||
min_ed: Option<f64>,
|
||||
max_ed: Option<f64>,
|
||||
min_signal: Option<f64>,
|
||||
max_signal: Option<f64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
last: None,
|
||||
min_ep,
|
||||
max_ep,
|
||||
min_ei,
|
||||
max_ei,
|
||||
min_ed,
|
||||
max_ed,
|
||||
min_signal,
|
||||
max_signal,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn simple_new(kp: f64, ki: f64, kd: f64) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
min_ep: None,
|
||||
max_ep: None,
|
||||
min_ei: None,
|
||||
max_ei: None,
|
||||
min_ed: None,
|
||||
max_ed: None,
|
||||
last: None,
|
||||
min_signal: None,
|
||||
max_signal: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_kp(&self) -> f64 {
|
||||
self.kp
|
||||
}
|
||||
|
||||
pub fn get_ki(&self) -> f64 {
|
||||
self.ki
|
||||
}
|
||||
|
||||
pub fn get_kd(&self) -> f64 {
|
||||
self.kd
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
|
||||
self.reset();
|
||||
self.kp = kp;
|
||||
self.ki = ki;
|
||||
self.kd = kd;
|
||||
}
|
||||
|
||||
pub fn optional_clamp(value: f64, min: Option<f64>, max: Option<f64>) -> f64 {
|
||||
match (min, max) {
|
||||
(Some(min), Some(max)) => value.clamp(min, max),
|
||||
(Some(min), None) => value.max(min),
|
||||
(None, Some(max)) => value.min(max),
|
||||
(None, None) => value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
|
||||
let signal = match self.last {
|
||||
// First update
|
||||
None => {
|
||||
// Calculate error
|
||||
let ep = error;
|
||||
|
||||
// Calculate signal
|
||||
let signal = self.kp * ep;
|
||||
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
|
||||
signal,
|
||||
self.min_signal,
|
||||
self.max_signal,
|
||||
);
|
||||
|
||||
// Set values
|
||||
self.ep =
|
||||
ClampingTimeagnosticPidController::optional_clamp(ep, self.min_ep, self.max_ep);
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
clamped_signal
|
||||
}
|
||||
// Subsequent updates
|
||||
Some(last) => {
|
||||
// Calculate the time delta in seconds
|
||||
let dt = t.duration_since(last).as_secs_f64();
|
||||
|
||||
// Calculate errors
|
||||
let ep = ClampingTimeagnosticPidController::optional_clamp(
|
||||
error,
|
||||
self.min_ep,
|
||||
self.max_ep,
|
||||
);
|
||||
|
||||
let ei = ClampingTimeagnosticPidController::optional_clamp(
|
||||
self.ei + ep * dt,
|
||||
self.min_ei,
|
||||
self.max_ei,
|
||||
);
|
||||
|
||||
let ed = ClampingTimeagnosticPidController::optional_clamp(
|
||||
(ep - self.ep) / dt,
|
||||
self.min_ed,
|
||||
self.max_ed,
|
||||
);
|
||||
|
||||
// Make factors timeagnostic
|
||||
let kp = self.kp * dt;
|
||||
let ki = self.ki * dt;
|
||||
let kd = self.kd * dt;
|
||||
|
||||
// Calculate signal
|
||||
let signal = kp * ep + ki * ei + kd * ed;
|
||||
let clamped_signal = ClampingTimeagnosticPidController::optional_clamp(
|
||||
signal,
|
||||
self.min_signal,
|
||||
self.max_signal,
|
||||
);
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = ei;
|
||||
self.ed = ed;
|
||||
self.last = Some(t);
|
||||
|
||||
clamped_signal
|
||||
}
|
||||
};
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ep = 0.0;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::controllers::clamping_timeagnostic_pid::ClampingTimeagnosticPidController;
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_both_bounds() {
|
||||
let val = 5.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(1.0), Some(4.0));
|
||||
assert_eq!(clamped, 4.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(6.0), Some(10.0));
|
||||
assert_eq!(clamped, 6.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(2.0), Some(6.0));
|
||||
assert_eq!(clamped, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_only_min() {
|
||||
let val = 3.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(5.0), None);
|
||||
assert_eq!(clamped, 5.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, Some(2.0), None);
|
||||
assert_eq!(clamped, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_only_max() {
|
||||
let val = 7.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, Some(6.0));
|
||||
assert_eq!(clamped, 6.0);
|
||||
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, Some(8.0));
|
||||
assert_eq!(clamped, 7.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_optional_clamp_with_no_bounds() {
|
||||
let val = 42.0;
|
||||
let clamped = ClampingTimeagnosticPidController::optional_clamp(val, None, None);
|
||||
assert_eq!(clamped, 42.0);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod clamping_timeagnostic_pid;
|
||||
pub mod first_degree_motion;
|
||||
pub mod pid;
|
||||
pub mod second_degree_motion;
|
||||
pub mod timeagnostic_pid;
|
||||
@@ -9,7 +9,6 @@ pub struct PidController {
|
||||
ki: f64,
|
||||
/// Derivative gain
|
||||
kd: f64,
|
||||
|
||||
// State
|
||||
/// Proportional error
|
||||
ep: f64,
|
||||
@@ -34,6 +33,25 @@ impl PidController {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(&mut self, ki: f64, kp: f64, kd: f64) {
|
||||
self.reset();
|
||||
self.kp = kp;
|
||||
self.ki = ki;
|
||||
self.kd = kd;
|
||||
}
|
||||
|
||||
pub fn get_kp(&self) -> f64 {
|
||||
self.kp
|
||||
}
|
||||
|
||||
pub fn get_ki(&self) -> f64 {
|
||||
self.ki
|
||||
}
|
||||
|
||||
pub fn get_kd(&self) -> f64 {
|
||||
self.kd
|
||||
}
|
||||
|
||||
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
|
||||
let signal = match self.last {
|
||||
// First update
|
||||
@@ -47,6 +65,7 @@ impl PidController {
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = 0.0;
|
||||
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TimeagnosticPidController {
|
||||
// Params
|
||||
/// Proportional gain
|
||||
kp: f64,
|
||||
/// Integral gain
|
||||
ki: f64,
|
||||
/// Derivative gain
|
||||
kd: f64,
|
||||
|
||||
// State
|
||||
/// Proportional error
|
||||
ep: f64,
|
||||
/// Integral error
|
||||
ei: f64,
|
||||
/// Derivative error
|
||||
ed: f64,
|
||||
|
||||
last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl TimeagnosticPidController {
|
||||
pub fn new(kp: f64, ki: f64, kd: f64) -> Self {
|
||||
Self {
|
||||
kp,
|
||||
ki,
|
||||
kd,
|
||||
ep: 0.0,
|
||||
ei: 0.0,
|
||||
ed: 0.0,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, error: f64, t: Instant) -> f64 {
|
||||
let signal = match self.last {
|
||||
// First update
|
||||
None => {
|
||||
// Calculate error
|
||||
let ep = error;
|
||||
|
||||
// Calculate signal
|
||||
let signal = self.kp * ep;
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = Some(t);
|
||||
|
||||
signal
|
||||
}
|
||||
// Subsequent updates
|
||||
Some(last) => {
|
||||
// Calculate the time delta in seconds
|
||||
let dt = t.duration_since(last).as_secs_f64();
|
||||
|
||||
// Calculate errors
|
||||
let ep = error;
|
||||
let ei = self.ei + ep * dt;
|
||||
let ed = (ep - self.ep) / dt;
|
||||
|
||||
// Make factors timeagnostic
|
||||
let kp = self.kp / dt;
|
||||
let ki = self.ki / dt;
|
||||
let kd = self.kd / dt;
|
||||
|
||||
// Calculate signal
|
||||
let signal = kp * ep + ki * ei + kd * ed;
|
||||
|
||||
// Set values
|
||||
self.ep = ep;
|
||||
self.ei = ei;
|
||||
self.ed = ed;
|
||||
self.last = Some(t);
|
||||
|
||||
signal
|
||||
}
|
||||
};
|
||||
|
||||
signal
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.ep = 0.0;
|
||||
self.ei = 0.0;
|
||||
self.ed = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,108 @@
|
||||
pub fn xor_u128_to_u64(value: u128) -> u64 {
|
||||
(value as u64) ^ ((value >> 64) as u64)
|
||||
/// Folds bytes using XOR to produce a u128 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u128.
|
||||
/// If there are fewer than 16 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u128(bytes: &[u8]) -> u128 {
|
||||
let mut result = 0u128;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 16) * 8;
|
||||
result ^= (byte as u128) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u32(value: u128) -> u32 {
|
||||
(value as u32) ^ ((value >> 32) as u32) ^ ((value >> 64) as u32) ^ ((value >> 96) as u32)
|
||||
/// Folds bytes using XOR to produce a u64 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u64.
|
||||
/// If there are fewer than 8 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u64(bytes: &[u8]) -> u64 {
|
||||
let mut result = 0u64;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 8) * 8;
|
||||
result ^= (byte as u64) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u16(value: u128) -> u16 {
|
||||
value
|
||||
.to_le_bytes()
|
||||
.iter()
|
||||
.fold(0, |buf, &byte| buf ^ byte as u16)
|
||||
/// Folds bytes using XOR to produce a u32 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u32.
|
||||
/// If there are fewer than 4 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u32(bytes: &[u8]) -> u32 {
|
||||
let mut result = 0u32;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 4) * 8;
|
||||
result ^= (byte as u32) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn xor_u128_to_u8(value: u128) -> u8 {
|
||||
value.to_le_bytes().iter().fold(0, |buf, &byte| buf ^ byte)
|
||||
/// Folds bytes using XOR to produce a u16 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them together in chunks to produce a u16.
|
||||
/// If there are fewer than 2 bytes, the remaining bytes are padded with zeros.
|
||||
pub fn byte_folding_u16(bytes: &[u8]) -> u16 {
|
||||
let mut result = 0u16;
|
||||
for (i, &byte) in bytes.iter().enumerate() {
|
||||
let shift = (i % 2) * 8;
|
||||
result ^= (byte as u16) << shift;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// hash algorithm djb2
|
||||
pub fn hashing(input_to_hash: &str) -> u128 {
|
||||
let mut hash: u128 = 5381;
|
||||
let chars = input_to_hash.chars();
|
||||
/// Folds bytes using XOR to produce a u8 value.
|
||||
///
|
||||
/// Takes a slice of bytes and XORs them all together to produce a u8.
|
||||
pub fn byte_folding_u8(bytes: &[u8]) -> u8 {
|
||||
bytes.iter().fold(0u8, |acc, &byte| acc ^ byte)
|
||||
}
|
||||
|
||||
for ch in chars {
|
||||
hash = ((hash << 5) + hash) + (ch as u128);
|
||||
/// Computes a djb2 hash of the input bytes.
|
||||
///
|
||||
/// The djb2 algorithm is a simple non-cryptographic hash function created by
|
||||
/// Daniel J. Bernstein. It uses the formula: `hash = hash * 33 + byte` for each byte.
|
||||
///
|
||||
/// This implementation uses the traditional 32-bit hash value and uses wrapping
|
||||
/// arithmetic to handle potential overflows gracefully.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bytes` - A slice of bytes to hash
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A 32-bit hash value as a `u32`
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use control_core::helpers::hashing::hash_djb2;
|
||||
///
|
||||
/// let hash = hash_djb2(b"hello world");
|
||||
/// assert_ne!(hash, 0);
|
||||
/// ```
|
||||
///
|
||||
/// # Algorithm Details
|
||||
///
|
||||
/// - Initial hash value: 5381 (djb2 magic number)
|
||||
/// - For each byte: `hash = hash * 33 + byte`
|
||||
/// - Uses wrapping arithmetic to prevent overflow panics
|
||||
pub fn hash_djb2(bytes: &[u8]) -> u32 {
|
||||
let mut hash: u32 = 5381;
|
||||
|
||||
for byte in bytes {
|
||||
hash = hash.wrapping_mul(33).wrapping_add(*byte as u32);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hash() {
|
||||
let test_case = "/dev/ttyUSB1";
|
||||
let hash = hashing(test_case);
|
||||
let hash_2bytes = xor_u128_to_u16(hash);
|
||||
let hash_u32 = hash_djb2(test_case.as_bytes());
|
||||
let hash_u16 = byte_folding_u16(&hash_u32.to_le_bytes());
|
||||
tracing::info!("{}", test_case);
|
||||
assert_eq!(test_case, "/dev/ttyUSB1");
|
||||
assert!(hash == 8977446972540388683742);
|
||||
assert!(hash_2bytes == 165);
|
||||
println!("{:?}", hash);
|
||||
assert_eq!(hash_u32, 266121182);
|
||||
assert_eq!(hash_u16, 40962);
|
||||
}
|
||||
@@ -1,4 +1,26 @@
|
||||
/// Normalizes a value from a given range [min, max] to the range [0, 1].
|
||||
///
|
||||
/// This function maps a value from an arbitrary range to the standard
|
||||
/// normalized range [0, 1]. Values o/// Plot for Steepness = -1:ge are clamped
|
||||
/// to the boundaries (0.0 or 1.0).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - The input value to normalize
|
||||
/// * `min` - The minimum value of the input range
|
||||
/// * `max` - The maximum value of the input range
|
||||
///
|
||||
/// # Returns
|
||||
/// * Normalized value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Panics
|
||||
/// * Panics if `min >= max`
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::normalize;
|
||||
/// let normalized = normalize(5.0, 0.0, 10.0); // Returns 0.5
|
||||
/// let clamped = normalize(-2.0, 0.0, 10.0); // Returns 0.0 (clamped)
|
||||
/// ```
|
||||
pub fn normalize<T>(value: T, min: T, max: T) -> f64
|
||||
where
|
||||
T: Into<f64>,
|
||||
@@ -19,6 +41,24 @@ where
|
||||
}
|
||||
|
||||
/// Scales a normalized value from the range [0, 1] to a new range [new_min, new_max].
|
||||
///
|
||||
/// This function takes a normalized value (typically from 0 to 1) and maps it
|
||||
/// to a new target range. It's the inverse operation of normalization.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `normalized_value` - Input value in range [0.0, 1.0]
|
||||
/// * `new_min` - The minimum value of the target range
|
||||
/// * `new_max` - The maximum value of the target range
|
||||
///
|
||||
/// # Returns
|
||||
/// * Scaled value in the range [new_min, new_max]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::scale;
|
||||
/// let scaled = scale(0.5, 0.0, 100.0); // Returns 50.0
|
||||
/// let negative_range = scale(0.25, -10.0, 10.0); // Returns -5.0
|
||||
/// ```
|
||||
pub fn scale<T>(normalized_value: f64, new_min: T, new_max: T) -> T
|
||||
where
|
||||
T: Into<f64> + From<f64>,
|
||||
@@ -31,24 +71,395 @@ where
|
||||
T::from(result)
|
||||
}
|
||||
|
||||
/// Transforma normalized value from the range [0, 1] to a [0, 1] range using an normalized exponential function.
|
||||
pub fn interpolate_exponential(normalized_value: f64, a: f64) -> f64 {
|
||||
if a.abs() < 1e-10 {
|
||||
/// Performs hinge interpolation creating a piecewise linear function.
|
||||
///
|
||||
/// This function creates a piecewise linear curve that:
|
||||
/// - For values < x: linearly interpolates from (0, 0) to (x, y)
|
||||
/// - For values > x: linearly interpolates from (x, y) to (1, 1)
|
||||
/// - For value == x: returns y
|
||||
///
|
||||
/// The function assumes the input value is normalized to the range [0, 1],
|
||||
/// and creates a "hinge" or "elbow" shape at the point (x, y).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - The input value (typically in range [0, 1])
|
||||
/// * `x` - The x-coordinate of the hinge point (typically in range [0, 1])
|
||||
/// * `y` - The y-coordinate of the hinge point (typically in range [0, 1])
|
||||
///
|
||||
/// # Returns
|
||||
/// * Interpolated value based on the hinge function
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_hinge;
|
||||
/// // Create a hinge at (0.3, 0.7)
|
||||
/// let result1 = interpolate_hinge(0.15, 0.3, 0.7); // Returns 0.35 (halfway to hinge)
|
||||
/// let result2 = interpolate_hinge(0.3, 0.3, 0.7); // Returns 0.7 (at hinge)
|
||||
/// let result3 = interpolate_hinge(0.65, 0.3, 0.7); // Returns 0.85 (halfway from hinge to 1)
|
||||
/// ```
|
||||
/// ```ignore
|
||||
/// Hinge at (0.8, 0.2)
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⠤⠤⠒⠒⠊⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡠⠤⠒⠒⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⣀⣀⠤⠤⠔⠒⠉⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠉⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_hinge<T>(value: T, x: T, y: T) -> f64
|
||||
where
|
||||
T: Into<f64>,
|
||||
{
|
||||
let value_f64: f64 = value.into();
|
||||
let x_f64: f64 = x.into();
|
||||
let y_f64: f64 = y.into();
|
||||
|
||||
// If we are under X we scale linearly from 0 to y
|
||||
// If we are over X we scale linearly from y to 1
|
||||
if value_f64 < x_f64 {
|
||||
return normalize(value_f64, 0.0, x_f64) * y_f64;
|
||||
} else if value_f64 > x_f64 {
|
||||
return normalize(value_f64, x_f64, 1.0) * (1.0 - y_f64) + y_f64;
|
||||
} else {
|
||||
return y_f64;
|
||||
}
|
||||
}
|
||||
|
||||
/// Transforms a normalized value from the range [0, 1] to a [0, 1] range using a normalized exponential function.
|
||||
///
|
||||
/// This function creates smooth curves that are either convex (fast start, slow end)
|
||||
/// or concave (slow start, fast end) depending on the steepness parameter sign.
|
||||
/// The curve always passes through (0,0) and (1,1).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `normalized_value` - Input value in range [0.0, 1.0]
|
||||
/// * `steepness` - Controls the curve shape:
|
||||
/// - `steepness = 0.0`: Linear interpolation (y = x)
|
||||
/// - `steepness > 0.0`: Convex curve (slow start → fast end)
|
||||
/// - `steepness < 0.0`: Concave curve (fast start → slow end)
|
||||
/// - Typical range: -5.0 to 5.0 for most applications
|
||||
///
|
||||
/// # Returns
|
||||
/// * Transformed value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_exponential;
|
||||
/// let convex = interpolate_exponential(0.5, 2.0); // Slow start, fast end
|
||||
/// let concave = interpolate_exponential(0.5, -2.0); // Fast start, slow end
|
||||
/// let linear = interpolate_exponential(0.5, 0.0); // Linear (returns 0.5)
|
||||
/// ```
|
||||
///
|
||||
/// # Plots
|
||||
/// Steepness = -3
|
||||
/// Steepness = -3
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠤⠔⠒⠒⠉⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠔⠊⠉⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⡜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⢰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⢀⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢤⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ -0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = -1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠒⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠁⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠒⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⢀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⢀⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⢀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡲⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ -0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 0
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢂⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠃⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⢀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢂⣀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 3
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡎⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠔⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⣀⣀⠤⠤⠒⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠉⠉⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_exponential(normalized_value: f64, steepness: f64) -> f64 {
|
||||
if steepness.abs() < 1e-10 {
|
||||
// When a is very close to zero, the function becomes linear
|
||||
return normalized_value;
|
||||
}
|
||||
let numerator = a.exp().powf(normalized_value) - 1.0;
|
||||
let denominator = a.exp() - 1.0;
|
||||
let numerator = steepness.exp().powf(normalized_value) - 1.0;
|
||||
let denominator = steepness.exp() - 1.0;
|
||||
|
||||
clip(numerator / denominator)
|
||||
}
|
||||
|
||||
/// A piecewise exponential interpolation function that creates smooth S-curves
|
||||
/// through the points (0,0), (0.5,0.5), and (1,1).
|
||||
///
|
||||
/// The function combines two normalized exponential segments:
|
||||
/// - First half (0 to 0.5): inverted exponential (concave, fast start → slow end)
|
||||
/// - Second half (0.5 to 1): standard exponential (convex, slow start → fast end)
|
||||
///
|
||||
/// This creates an S-like curve that's concave then convex, similar to
|
||||
/// a smoothed step function or sigmoid-like transition.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `x` - Input value, should be in range [0.0, 1.0]
|
||||
/// * `steepness` - Controls how steep the S-curve is
|
||||
/// - `steepness = 0.0`: Linear interpolation (y = x)
|
||||
/// - `steepness > 0.0`: More pronounced S-curve
|
||||
/// - Higher values create sharper transitions at the boundaries
|
||||
/// - Typical range: 0.5 to 5.0 for most applications
|
||||
///
|
||||
/// # Returns
|
||||
/// * Interpolated y value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::interpolate_inflected_exponential;
|
||||
/// let y = interpolate_inflected_exponential(0.25, 2.0); // Gentle S-curve
|
||||
/// let y = interpolate_inflected_exponential(0.75, 5.0); // Sharp S-curve
|
||||
/// ```
|
||||
///
|
||||
/// # Plots
|
||||
/// Steepness = 0.5
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠜⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠜⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⡔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢀⠤⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡢⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 1
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠒⠁⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⡠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⡰⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⡰⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⡜⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡲⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 2
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡸⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠉⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡠⠤⠒⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⣀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡞⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
///
|
||||
/// Steepness = 5
|
||||
/// ```ignore
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⠁ 1.0
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠇⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡜⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠔⠉⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⡠⠤⠤⠤⠤⠤⠤⠤⠒⠒⠊⠉⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⠀⠀⠀⠀⢀⠔⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡁⠀⠀⢠⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠄⠀⢠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠂⢀⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⣁⠎⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⢼⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
||||
/// ⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁⠈⠀⠁ 0.0
|
||||
/// 0.0 1.0
|
||||
/// ```
|
||||
pub fn interpolate_inflected_exponential(x: f64, steepness: f64) -> f64 {
|
||||
debug_assert!((0.0..=1.0).contains(&x), "x must be in range [0.0, 1.0]");
|
||||
debug_assert!(steepness >= 0.0, "steepness must be non-negative");
|
||||
|
||||
if steepness == 0.0 {
|
||||
// Linear case
|
||||
return x;
|
||||
}
|
||||
|
||||
if x <= 0.5 {
|
||||
// First segment: inverted exponential (concave)
|
||||
// Maps [0, 0.5] → [0, 0.5] with concave shape
|
||||
let x_norm = 2.0 * x; // Scale to [0, 1]
|
||||
|
||||
// Use inverted exponential interpolation
|
||||
let result = 1.0 - interpolate_exponential(1.0 - x_norm, steepness);
|
||||
0.5 * result
|
||||
} else {
|
||||
// Second segment: standard exponential (convex)
|
||||
// Maps [0.5, 1] → [0.5, 1] with convex shape
|
||||
let x_norm = 2.0 * (x - 0.5); // Scale to [0, 1]
|
||||
|
||||
// Use standard exponential interpolation
|
||||
let result = interpolate_exponential(x_norm, steepness);
|
||||
0.5 + 0.5 * result
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverts a value from the range [0, 1] to [1, 0].
|
||||
///
|
||||
/// This function flips a normalized value, where 0 becomes 1 and 1 becomes 0.
|
||||
/// Values outside [0, 1] are first clamped to this range before inversion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - Input value (will be clamped to [0.0, 1.0])
|
||||
///
|
||||
/// # Returns
|
||||
/// * Inverted value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::invert;
|
||||
/// let inverted = invert(0.3); // Returns 0.7
|
||||
/// let boundary = invert(0.0); // Returns 1.0
|
||||
/// let clamped = invert(1.5); // Returns 0.0 (input clamped to 1.0 first)
|
||||
/// ```
|
||||
pub fn invert(value: f64) -> f64 {
|
||||
let value = clip(value);
|
||||
1.0 - value
|
||||
}
|
||||
|
||||
/// Clips a value to the range [0, 1].
|
||||
///
|
||||
/// This function ensures that any input value is constrained to the
|
||||
/// normalized range [0, 1] by clamping values below 0 to 0 and
|
||||
/// values above 1 to 1.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `value` - Input value to clip
|
||||
///
|
||||
/// # Returns
|
||||
/// * Clipped value in range [0.0, 1.0]
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// use control_core::helpers::interpolation::clip;
|
||||
/// let normal = clip(0.5); // Returns 0.5
|
||||
/// let low = clip(-0.3); // Returns 0.0
|
||||
/// let high = clip(1.7); // Returns 1.0
|
||||
/// ```
|
||||
pub fn clip(value: f64) -> f64 {
|
||||
if value < 0.0 {
|
||||
return 0.0;
|
||||
@@ -178,6 +589,289 @@ mod tests {
|
||||
assert_relative_eq!(clip(1.5), 1.0, epsilon = EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_inflected_exponential() {
|
||||
// Test boundary points (0,0), (0.5,0.5), and (1,1) for various steepness values
|
||||
for steepness in [0.0, 0.5, 1.0, 2.0, 5.0].iter() {
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(0.0, *steepness),
|
||||
0.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(0.5, *steepness),
|
||||
0.5,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(1.0, *steepness),
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Test linear case (steepness = 0)
|
||||
for x in [0.0, 0.25, 0.5, 0.75, 1.0].iter() {
|
||||
assert_relative_eq!(
|
||||
interpolate_inflected_exponential(*x, 0.0),
|
||||
*x,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
}
|
||||
|
||||
// Test monotonicity - function should be strictly increasing
|
||||
for steepness in [0.5, 1.0, 2.0, 5.0].iter() {
|
||||
let mut prev = interpolate_inflected_exponential(0.0, *steepness);
|
||||
for i in 1..=20 {
|
||||
let x = i as f64 / 20.0;
|
||||
let current = interpolate_inflected_exponential(x, *steepness);
|
||||
assert!(
|
||||
current >= prev,
|
||||
"Failed monotonicity test at x={}, steepness={}, prev={}, current={}",
|
||||
x,
|
||||
steepness,
|
||||
prev,
|
||||
current
|
||||
);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
|
||||
// Test S-curve property: first half should be concave, second half convex
|
||||
let steepness = 2.0;
|
||||
|
||||
// First half: check concave property (derivative decreasing)
|
||||
let x1 = 0.1;
|
||||
let x2 = 0.2;
|
||||
let x3 = 0.3;
|
||||
let y1 = interpolate_inflected_exponential(x1, steepness);
|
||||
let y2 = interpolate_inflected_exponential(x2, steepness);
|
||||
let y3 = interpolate_inflected_exponential(x3, steepness);
|
||||
|
||||
let slope1 = (y2 - y1) / (x2 - x1);
|
||||
let slope2 = (y3 - y2) / (x3 - x2);
|
||||
assert!(
|
||||
slope1 > slope2,
|
||||
"First half should be concave (decreasing slope)"
|
||||
);
|
||||
|
||||
// Second half: check convex property (derivative increasing)
|
||||
let x1 = 0.7;
|
||||
let x2 = 0.8;
|
||||
let x3 = 0.9;
|
||||
let y1 = interpolate_inflected_exponential(x1, steepness);
|
||||
let y2 = interpolate_inflected_exponential(x2, steepness);
|
||||
let y3 = interpolate_inflected_exponential(x3, steepness);
|
||||
|
||||
let slope1 = (y2 - y1) / (x2 - x1);
|
||||
let slope2 = (y3 - y2) / (x3 - x2);
|
||||
assert!(
|
||||
slope1 < slope2,
|
||||
"Second half should be convex (increasing slope)"
|
||||
);
|
||||
|
||||
// Test that output is always in [0, 1] range
|
||||
for steepness in [0.0, 1.0, 5.0, 10.0].iter() {
|
||||
for i in 0..=100 {
|
||||
let x = i as f64 / 100.0;
|
||||
let y = interpolate_inflected_exponential(x, *steepness);
|
||||
assert!(
|
||||
y >= 0.0 && y <= 1.0,
|
||||
"Output {} out of range [0,1] for x={}, steepness={}",
|
||||
y,
|
||||
x,
|
||||
steepness
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_hinge() {
|
||||
// Test basic functionality with hinge at (0.3, 0.7)
|
||||
let x_hinge = 0.3;
|
||||
let y_hinge = 0.7;
|
||||
|
||||
// Test exact hinge point
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(x_hinge, x_hinge, y_hinge),
|
||||
y_hinge,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test left segment (value < x): should interpolate from (0,0) to (x,y)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.0, x_hinge, y_hinge),
|
||||
0.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.15, x_hinge, y_hinge),
|
||||
0.35, // 0.15/0.3 * 0.7 = 0.5 * 0.7 = 0.35
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test right segment (value > x): should interpolate from (x,y) to (1,1)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(1.0, x_hinge, y_hinge),
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.65, x_hinge, y_hinge),
|
||||
0.85, // y + ((0.65-0.3)/(1.0-0.3)) * (1.0-0.7) = 0.7 + 0.5 * 0.3 = 0.85
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test with different hinge points
|
||||
// Hinge at (0.5, 0.5) - should be linear
|
||||
assert_relative_eq!(interpolate_hinge(0.25, 0.5, 0.5), 0.25, epsilon = EPSILON);
|
||||
assert_relative_eq!(interpolate_hinge(0.75, 0.5, 0.5), 0.75, epsilon = EPSILON);
|
||||
|
||||
// Hinge at (0.2, 0.9) - steep left, shallow right
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.1, 0.2, 0.9),
|
||||
0.45, // 0.1/0.2 * 0.9 = 0.5 * 0.9 = 0.45
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.6, 0.2, 0.9),
|
||||
0.95, // 0.9 + ((0.6-0.2)/(1.0-0.2)) * (1.0-0.9) = 0.9 + 0.5 * 0.1 = 0.95
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test edge cases
|
||||
// Hinge at (0, 0)
|
||||
assert_relative_eq!(interpolate_hinge(0.0, 0.0, 0.0), 0.0, epsilon = EPSILON);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5, 0.0, 0.0),
|
||||
0.5, // Should interpolate from (0,0) to (1,1)
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Hinge at (1, 1)
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5, 1.0, 1.0),
|
||||
0.5, // Should interpolate from (0,0) to (1,1)
|
||||
epsilon = EPSILON
|
||||
);
|
||||
assert_relative_eq!(interpolate_hinge(1.0, 1.0, 1.0), 1.0, epsilon = EPSILON);
|
||||
|
||||
// Test with different numeric types
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(0.5f32, 0.3f32, 0.7f32),
|
||||
0.7857142857142857, // 0.7 + ((0.5-0.3)/(1.0-0.3)) * (1.0-0.7) = 0.7 + (2/7) * 0.3
|
||||
epsilon = 1e-7 // Relaxed epsilon for f32 precision
|
||||
);
|
||||
assert_relative_eq!(
|
||||
interpolate_hinge(1u8, 0u8, 1u8), // 1.0, 0.0, 1.0 - should return 1.0
|
||||
1.0,
|
||||
epsilon = EPSILON
|
||||
);
|
||||
|
||||
// Test monotonicity - function should be non-decreasing
|
||||
let test_x = 0.4;
|
||||
let test_y = 0.6;
|
||||
let mut prev = interpolate_hinge(0.0, test_x, test_y);
|
||||
|
||||
for i in 1..=20 {
|
||||
let value = i as f64 / 20.0;
|
||||
let current = interpolate_hinge(value, test_x, test_y);
|
||||
assert!(
|
||||
current >= prev,
|
||||
"Failed monotonicity test at value={}, x={}, y={}, prev={}, current={}",
|
||||
value,
|
||||
test_x,
|
||||
test_y,
|
||||
prev,
|
||||
current
|
||||
);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_exponential_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
println!("\n=== Exponential Interpolation Examples ===");
|
||||
|
||||
// Test different steepness values (both positive and negative)
|
||||
let steepness_values = [-3.0, -1.0, 0.0, 1.0, 3.0];
|
||||
|
||||
for steepness in steepness_values.iter() {
|
||||
println!("\nSteepness = {}", steepness);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_exponential(x, *steepness);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_hinge_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
println!("\n=== hinge Interpolation Examples ===");
|
||||
|
||||
// Test different hinge points
|
||||
let hinges = [(0.3, 0.7), (0.5, 0.5), (0.2, 0.9), (0.8, 0.2)];
|
||||
|
||||
for &(x_hinge, y_hinge) in hinges.iter() {
|
||||
println!("\nHinge at ({}, {})", x_hinge, y_hinge);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_hinge(x, x_hinge, y_hinge);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_interpolate_inflected_exponential_graph() {
|
||||
use textplots::{Chart, Plot, Shape};
|
||||
|
||||
// Test different steepness values
|
||||
let steepness_values = [0.5, 1.0, 2.0, 5.0];
|
||||
|
||||
for steepness in steepness_values.iter() {
|
||||
println!("\nSteepness = {}", steepness);
|
||||
|
||||
// Generate data points
|
||||
let points: Vec<(f32, f32)> = (0..=50)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = interpolate_inflected_exponential(x, *steepness);
|
||||
(x as f32, y as f32)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Create and display chart
|
||||
Chart::new(60, 60, 0.0, 1.0)
|
||||
.lineplot(&Shape::Lines(&points))
|
||||
.display();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn test_normalize_invalid_range() {
|
||||
|
||||
@@ -1,4 +1,38 @@
|
||||
pub fn retry<T, E, F>(n: usize, mut f: F) -> Result<T, E>
|
||||
use std::future::Future;
|
||||
|
||||
/// Retries a fallible operation up to `n` times synchronously.
|
||||
///
|
||||
/// This function executes the provided closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - All retry attempts are exhausted
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
|
||||
/// * `f` - A mutable closure that returns a `Result<T, E>`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt if all retries are exhausted.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_n_times;
|
||||
///
|
||||
/// let mut attempt = 0;
|
||||
/// let result = retry_n_times(3, || {
|
||||
/// attempt += 1;
|
||||
/// if attempt < 3 {
|
||||
/// Err("failed")
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// });
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// ```
|
||||
pub fn retry_n_times<T, E, F>(n: usize, mut f: F) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Result<T, E>,
|
||||
{
|
||||
@@ -11,3 +45,383 @@ where
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// Retries a fallible async operation up to `n` times.
|
||||
///
|
||||
/// This function executes the provided async closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - All retry attempts are exhausted
|
||||
///
|
||||
/// Each attempt is awaited before proceeding to the next retry or returning.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `n` - The maximum number of retry attempts (0 means only one attempt)
|
||||
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt if all retries are exhausted.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_n_times_async;
|
||||
/// use std::sync::Arc;
|
||||
/// use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
///
|
||||
/// async fn example() {
|
||||
/// let attempt = Arc::new(AtomicUsize::new(0));
|
||||
/// let attempt_clone = attempt.clone();
|
||||
/// let result = retry_n_times_async(3, move || {
|
||||
/// let attempt = attempt_clone.clone();
|
||||
/// async move {
|
||||
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
/// if current < 3 {
|
||||
/// Err("failed")
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// }
|
||||
/// }).await;
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn retry_n_times_async<T, E, F, Fut>(n: usize, mut f: F) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
{
|
||||
let mut last_err: Option<E> = None;
|
||||
for _ in 0..=n {
|
||||
match f().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => last_err = Some(err),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap())
|
||||
}
|
||||
|
||||
/// Retries a fallible operation until a closure decides to stop retrying.
|
||||
///
|
||||
/// This function executes the provided closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - The retry condition returns false
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `f` - A mutable closure that returns a `Result<T, E>`
|
||||
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt when retries stop.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_conditionally;
|
||||
///
|
||||
/// let mut attempt = 0;
|
||||
/// let result = retry_conditionally(
|
||||
/// || {
|
||||
/// attempt += 1;
|
||||
/// if attempt < 3 {
|
||||
/// Err(format!("failed attempt {}", attempt))
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// },
|
||||
/// |err| {
|
||||
/// // Retry up to 3 times
|
||||
/// !err.contains("attempt 3")
|
||||
/// }
|
||||
/// );
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// ```
|
||||
pub fn retry_conditionally<T, E, F, R>(mut f: F, mut should_retry: R) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Result<T, E>,
|
||||
R: FnMut(&E) -> bool,
|
||||
{
|
||||
loop {
|
||||
match f() {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
if !should_retry(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retries a fallible async operation until a closure decides to stop retrying.
|
||||
///
|
||||
/// This function executes the provided async closure `f` repeatedly until either:
|
||||
/// - The operation succeeds (returns `Ok(T)`)
|
||||
/// - The retry condition returns false
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `f` - A mutable closure that returns a `Future<Output = Result<T, E>>`
|
||||
/// * `should_retry` - A closure that takes `&E` and returns `bool` to decide if retry should continue
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(T)` if any attempt succeeds, or `Err(E)` containing the error
|
||||
/// from the last failed attempt when retries stop.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```rust
|
||||
/// use control_core::helpers::retry::retry_conditionally_async;
|
||||
/// use std::sync::Arc;
|
||||
/// use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
///
|
||||
/// async fn example() {
|
||||
/// let attempt = Arc::new(AtomicUsize::new(0));
|
||||
/// let attempt_clone = attempt.clone();
|
||||
/// let result = retry_conditionally_async(
|
||||
/// move || {
|
||||
/// let attempt = attempt_clone.clone();
|
||||
/// async move {
|
||||
/// let current = attempt.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
/// if current < 3 {
|
||||
/// Err(format!("failed attempt {}", current))
|
||||
/// } else {
|
||||
/// Ok("success")
|
||||
/// }
|
||||
/// }
|
||||
/// },
|
||||
/// |err| {
|
||||
/// // Retry up to 3 times
|
||||
/// !err.contains("attempt 3")
|
||||
/// }
|
||||
/// ).await;
|
||||
/// assert_eq!(result, Ok("success"));
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn retry_conditionally_async<T, E, F, Fut, R>(
|
||||
mut f: F,
|
||||
mut should_retry: R,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
R: FnMut(&E) -> bool,
|
||||
{
|
||||
loop {
|
||||
match f().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(err) => {
|
||||
if !should_retry(&err) {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
// Simple blocking executor for async functions in tests
|
||||
fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
use std::thread;
|
||||
|
||||
struct DummyWaker;
|
||||
impl std::task::Wake for DummyWaker {
|
||||
fn wake(self: Arc<Self>) {}
|
||||
}
|
||||
|
||||
let waker = Waker::from(Arc::new(DummyWaker));
|
||||
let mut context = Context::from_waker(&waker);
|
||||
let mut future = Box::pin(future);
|
||||
|
||||
loop {
|
||||
match future.as_mut().poll(&mut context) {
|
||||
Poll::Ready(result) => return result,
|
||||
Poll::Pending => {
|
||||
thread::yield_now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_success_first_attempt() {
|
||||
let result = retry_n_times(3, || Ok::<i32, &str>(42));
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_success_after_failures() {
|
||||
let mut attempt = 0;
|
||||
let result = retry_n_times(3, || {
|
||||
attempt += 1;
|
||||
if attempt < 3 { Err("failed") } else { Ok(42) }
|
||||
});
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(attempt, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_all_attempts_fail() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, &str> = retry_n_times(2, || {
|
||||
attempt += 1;
|
||||
Err("always fails")
|
||||
});
|
||||
assert_eq!(result, Err("always fails"));
|
||||
assert_eq!(attempt, 3); // 0..=2 means 3 attempts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_zero_retries() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, &str> = retry_n_times(0, || {
|
||||
attempt += 1;
|
||||
Err("failed")
|
||||
});
|
||||
assert_eq!(result, Err("failed"));
|
||||
assert_eq!(attempt, 1); // Only one attempt
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_first_attempt() {
|
||||
let result = block_on(retry_n_times_async(3, || async { Ok::<i32, &str>(42) }));
|
||||
assert_eq!(result, Ok(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_after_failures() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result = block_on(retry_n_times_async(3, move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
if *a < 3 { Err("failed") } else { Ok(42) }
|
||||
}
|
||||
}));
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(*attempt.borrow(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_all_attempts_fail() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, &str> = block_on(retry_n_times_async(2, move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
Err("always fails")
|
||||
}
|
||||
}));
|
||||
assert_eq!(result, Err("always fails"));
|
||||
assert_eq!(*attempt.borrow(), 3); // 0..=2 means 3 attempts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_and_success_with_condition() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, String> = retry_conditionally(
|
||||
|| {
|
||||
attempt += 1;
|
||||
if attempt < 3 {
|
||||
Err(format!("failed attempt {}", attempt))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
},
|
||||
|err| {
|
||||
// Retry as long as it's not the 3rd attempt
|
||||
!err.contains("attempt 3")
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(attempt, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_and_stop_early() {
|
||||
let mut attempt = 0;
|
||||
let result: Result<i32, String> = retry_conditionally(
|
||||
|| {
|
||||
attempt += 1;
|
||||
Err(format!("failed attempt {}", attempt))
|
||||
},
|
||||
|_err| {
|
||||
// Stop after first failure
|
||||
false
|
||||
},
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(attempt, 1);
|
||||
assert_eq!(result.unwrap_err(), "failed attempt 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_success_with_condition() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, String> = block_on(retry_conditionally_async(
|
||||
move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
if *a < 3 {
|
||||
Err(format!("failed attempt {}", *a))
|
||||
} else {
|
||||
Ok(42)
|
||||
}
|
||||
}
|
||||
},
|
||||
|err| {
|
||||
// Retry as long as it's not the 3rd attempt
|
||||
!err.contains("attempt 3")
|
||||
},
|
||||
));
|
||||
|
||||
assert_eq!(result, Ok(42));
|
||||
assert_eq!(*attempt.borrow(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_async_stop_early() {
|
||||
let attempt = Rc::new(RefCell::new(0));
|
||||
let attempt_clone = attempt.clone();
|
||||
let result: Result<i32, String> = block_on(retry_conditionally_async(
|
||||
move || {
|
||||
let attempt = attempt_clone.clone();
|
||||
async move {
|
||||
let mut a = attempt.borrow_mut();
|
||||
*a += 1;
|
||||
Err(format!("failed attempt {}", *a))
|
||||
}
|
||||
},
|
||||
|_err| {
|
||||
// Stop after first failure
|
||||
false
|
||||
},
|
||||
));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(*attempt.borrow(), 1);
|
||||
assert_eq!(result.unwrap_err(), "failed attempt 1");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod actors;
|
||||
pub mod controllers;
|
||||
pub mod converters;
|
||||
pub mod ethercat;
|
||||
pub mod helpers;
|
||||
pub mod machines;
|
||||
pub mod modbus;
|
||||
pub mod realtime;
|
||||
pub mod rest;
|
||||
pub mod serial;
|
||||
pub mod socketio;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::actors::Actor;
|
||||
use crate::machines::new::MachineAct;
|
||||
use api::MachineApi;
|
||||
use new::MachineNewTrait;
|
||||
use std::any::Any;
|
||||
@@ -10,4 +10,4 @@ pub mod manager_iter;
|
||||
pub mod new;
|
||||
pub mod registry;
|
||||
|
||||
pub trait Machine: MachineNewTrait + MachineApi + Actor + Any + Debug {}
|
||||
pub trait Machine: MachineNewTrait + MachineApi + MachineAct + Any + Send + Sync + Debug {}
|
||||
@@ -8,7 +8,7 @@ use ethercat_hal::{
|
||||
use ethercrab::{SubDevice, SubDeviceRef};
|
||||
use smol::{channel::Sender, lock::RwLock};
|
||||
use socketioxide::extract::SocketRef;
|
||||
use std::sync::Arc;
|
||||
use std::{pin::Pin, sync::Arc, time::Instant};
|
||||
|
||||
use crate::socketio::event::GenericEvent;
|
||||
|
||||
@@ -20,6 +20,10 @@ pub trait MachineNewTrait {
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
pub trait MachineAct {
|
||||
fn act(&mut self, now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
|
||||
}
|
||||
|
||||
pub struct MachineNewParams<
|
||||
'maindevice,
|
||||
'subdevices,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
pub mod modbus_serial_interface;
|
||||
|
||||
use anyhow::Error;
|
||||
use crc::{CRC_16_MODBUS, Crc};
|
||||
use serial;
|
||||
use std::time::Duration;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
||||
pub enum ParityType {
|
||||
Even,
|
||||
Odd,
|
||||
@@ -22,6 +25,58 @@ pub enum ModbusFunctionCode {
|
||||
DiagnoseFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum ModbusExceptionCode {
|
||||
None = 0,
|
||||
IllegalFunction = 1,
|
||||
IllegalDataAddress = 2,
|
||||
IllegalDataValue = 3,
|
||||
SlaveDeviceFailure = 4,
|
||||
Acknowledge = 5,
|
||||
SlaveDeviceBusy = 6,
|
||||
MemoryParityError = 8,
|
||||
GatewayPathUnavailable = 10,
|
||||
GatewayTargetDeviceFailedToRespond = 11,
|
||||
Unknown(u8), // For any unknown exception codes
|
||||
}
|
||||
|
||||
impl From<ModbusExceptionCode> for u8 {
|
||||
fn from(code: ModbusExceptionCode) -> Self {
|
||||
match code {
|
||||
ModbusExceptionCode::None => 0,
|
||||
ModbusExceptionCode::IllegalFunction => 1,
|
||||
ModbusExceptionCode::IllegalDataAddress => 2,
|
||||
ModbusExceptionCode::IllegalDataValue => 3,
|
||||
ModbusExceptionCode::SlaveDeviceFailure => 4,
|
||||
ModbusExceptionCode::Acknowledge => 5,
|
||||
ModbusExceptionCode::SlaveDeviceBusy => 6,
|
||||
ModbusExceptionCode::MemoryParityError => 8,
|
||||
ModbusExceptionCode::GatewayPathUnavailable => 10,
|
||||
ModbusExceptionCode::GatewayTargetDeviceFailedToRespond => 11,
|
||||
ModbusExceptionCode::Unknown(code) => code,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u8> for ModbusExceptionCode {
|
||||
fn from(value: u8) -> Self {
|
||||
match value {
|
||||
0 => ModbusExceptionCode::None,
|
||||
1 => ModbusExceptionCode::IllegalFunction,
|
||||
2 => ModbusExceptionCode::IllegalDataAddress,
|
||||
3 => ModbusExceptionCode::IllegalDataValue,
|
||||
4 => ModbusExceptionCode::SlaveDeviceFailure,
|
||||
5 => ModbusExceptionCode::Acknowledge,
|
||||
6 => ModbusExceptionCode::SlaveDeviceBusy,
|
||||
8 => ModbusExceptionCode::MemoryParityError,
|
||||
10 => ModbusExceptionCode::GatewayPathUnavailable,
|
||||
11 => ModbusExceptionCode::GatewayTargetDeviceFailedToRespond,
|
||||
other => ModbusExceptionCode::Unknown(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModbusRequest {
|
||||
pub slave_id: u8,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
use crate::modbus::{ModbusRequest, ModbusResponse};
|
||||
use ethercat_hal::io::serial_interface::{SerialEncoding, SerialInterface};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RequestMetaData {
|
||||
priority: u32,
|
||||
ignored_times: u32,
|
||||
/// This is used when a machine for example takes 4ms to process the request
|
||||
/// and is added ontop of the standard waiting time for a serial transfer
|
||||
extra_delay: Option<u32>,
|
||||
no_response_expected: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum State {
|
||||
/// WaitingForResponse is set after sending a request through the serial_interface
|
||||
WaitingForResponse,
|
||||
/// After Sending a Request we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has transmit accepted toggled
|
||||
/// Then we can set state = ReadyToSend
|
||||
WaitingForRequestAccept,
|
||||
/// After Receiving a Response we need to wait atleast one ethercat cycle
|
||||
/// After one Cycle we check if el6021 status has received accepted toggled
|
||||
WaitingForReceiveAccept,
|
||||
/// ReadyToSend is set after receiving the response from the serial_interface
|
||||
ReadyToSend,
|
||||
/// Initial State
|
||||
Uninitialized,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ModbusSerialInterface {
|
||||
serial_interface: SerialInterface,
|
||||
baudrate: Option<u32>,
|
||||
encoding: Option<SerialEncoding>,
|
||||
response: Option<ModbusResponse>,
|
||||
state: State,
|
||||
last_message_size: usize,
|
||||
last_message_delay: u32,
|
||||
pub last_message_id: u32,
|
||||
no_response_expected: bool,
|
||||
request_map: HashMap<u32, ModbusRequest>,
|
||||
request_metadata_map: HashMap<u32, RequestMetaData>,
|
||||
last_ts: Instant,
|
||||
}
|
||||
|
||||
impl ModbusSerialInterface {
|
||||
pub fn new(serial_interface: SerialInterface) -> Self {
|
||||
Self {
|
||||
serial_interface,
|
||||
baudrate: None,
|
||||
encoding: None,
|
||||
request_map: HashMap::new(),
|
||||
request_metadata_map: HashMap::new(),
|
||||
response: None,
|
||||
last_message_size: 0,
|
||||
last_message_delay: 0,
|
||||
state: State::Uninitialized,
|
||||
last_ts: Instant::now(),
|
||||
last_message_id: 0,
|
||||
no_response_expected: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
!matches!(self.state, State::Uninitialized)
|
||||
}
|
||||
|
||||
pub fn add_request(
|
||||
&mut self,
|
||||
request_id: u32,
|
||||
priority: u32,
|
||||
request: ModbusRequest,
|
||||
no_response_expected: bool,
|
||||
delay: Option<u32>,
|
||||
) {
|
||||
self.request_map.insert(request_id, request);
|
||||
self.request_metadata_map
|
||||
.entry(request_id)
|
||||
.or_insert_with(|| RequestMetaData {
|
||||
priority,
|
||||
ignored_times: 0,
|
||||
extra_delay: delay,
|
||||
no_response_expected,
|
||||
});
|
||||
}
|
||||
|
||||
/// This is used internally to read the receive buffer of the el6021
|
||||
async fn read_modbus_response(&mut self) -> Result<ModbusResponse, anyhow::Error> {
|
||||
let raw_response = (self.serial_interface.read_message)()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let response = ModbusResponse::try_from(raw_response)?;
|
||||
self.last_message_size = response.data.len() + 4;
|
||||
self.state = State::WaitingForReceiveAccept;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// This is used internally to fill the write buffer of the el6021 with the modbus request
|
||||
/// Decides what requests to send first by finding the one with the highest priority
|
||||
/// For example Highest Priority requests: ResetInverter StopMotor
|
||||
async fn send_modbus_request(&mut self) {
|
||||
if self.request_map.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (highest_id, delay, no_response_expected) = self
|
||||
.request_metadata_map
|
||||
.iter_mut()
|
||||
.map(|(key, value)| {
|
||||
let effective_priority = value.priority + value.ignored_times;
|
||||
(
|
||||
*key,
|
||||
effective_priority,
|
||||
value.extra_delay.unwrap_or(0),
|
||||
value.no_response_expected,
|
||||
)
|
||||
})
|
||||
.max_by_key(|(_, priority, _, _)| *priority)
|
||||
.map(|(key, _, delay, no_response)| (key, delay, no_response))
|
||||
.unwrap(); // Safe because we checked is_empty() above
|
||||
|
||||
let request = &self.request_map[&highest_id];
|
||||
let modbus_request: Vec<u8> = request.clone().into();
|
||||
|
||||
if let Err(_) = (self.serial_interface.write_message)(modbus_request.clone()).await {
|
||||
tracing::error!("ERROR: serial_interface.write_message has failed");
|
||||
} else {
|
||||
self.last_message_delay = delay;
|
||||
self.last_message_id = highest_id;
|
||||
self.no_response_expected = no_response_expected;
|
||||
}
|
||||
|
||||
self.state = State::WaitingForRequestAccept;
|
||||
self.last_message_size = modbus_request.len();
|
||||
}
|
||||
|
||||
pub async fn initialize_communication_settings(&mut self) -> bool {
|
||||
let baudrate = (self.serial_interface.get_baudrate)().await;
|
||||
let encoding = (self.serial_interface.get_serial_encoding)().await;
|
||||
|
||||
match (baudrate, encoding) {
|
||||
(Some(b), Some(e)) => {
|
||||
self.baudrate = Some(b);
|
||||
self.encoding = Some(e);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(&mut self) -> bool {
|
||||
if matches!(self.state, State::Uninitialized) {
|
||||
let success = (self.serial_interface.initialize)().await;
|
||||
if success {
|
||||
self.state = State::ReadyToSend;
|
||||
self.initialize_communication_settings().await;
|
||||
}
|
||||
success
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_ignored_times(&mut self) {
|
||||
for metadata in self.request_metadata_map.values_mut() {
|
||||
metadata.ignored_times += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Modbus RTU has silent time between frames that needs to be adhered to, if you send before silent_time is over between frames, then there will be lost frames
|
||||
/// This silent time is needed to identify the start and end of messages
|
||||
/// This function also takes into account the time that the slave we are talking to needs to process our request
|
||||
/// bits: amount of bits sent per byte -> for a 8n1 coding: 8 data bits, 0 parity, 1 stop bit (1 start,1 stop) -> 10 bits
|
||||
/// machine_operation_delay_nano: Delay for the given operation in nanoseconds as specified by the slaves datasheet (example: mitsubishi csfr84 has 12ms for read write in RAM)
|
||||
/// baudrate: bits per second
|
||||
/// message_size: size of original message in bytes
|
||||
pub fn calculate_modbus_rtu_timeout(
|
||||
&self,
|
||||
machine_operation_delay: Duration,
|
||||
message_size: usize,
|
||||
) -> Option<Duration> {
|
||||
let baudrate = self.baudrate?;
|
||||
let encoding = self.encoding?;
|
||||
|
||||
let nanoseconds_per_bit = 1_000_000_000 / baudrate as u64;
|
||||
let nanoseconds_per_byte = encoding.total_bits() as u64 * nanoseconds_per_bit;
|
||||
|
||||
let transmission_timeout = nanoseconds_per_byte * message_size as u64;
|
||||
let silent_time = (nanoseconds_per_byte * 35) / 10; // silent_time is 3.5x of character length
|
||||
|
||||
let total_timeout =
|
||||
transmission_timeout + machine_operation_delay.as_nanos() as u64 + silent_time;
|
||||
Some(Duration::from_nanos(total_timeout))
|
||||
}
|
||||
|
||||
pub fn get_response(&self) -> Option<&ModbusResponse> {
|
||||
self.response.as_ref()
|
||||
}
|
||||
|
||||
pub async fn act(&mut self, now_ts: Instant) {
|
||||
let elapsed = now_ts.duration_since(self.last_ts);
|
||||
|
||||
let timeout = self.calculate_modbus_rtu_timeout(
|
||||
Duration::from_nanos(self.last_message_delay as u64),
|
||||
self.last_message_size,
|
||||
);
|
||||
|
||||
let Some(timeout) = timeout else { return };
|
||||
|
||||
// Check if we need to wait for timeout (except for specific states)
|
||||
if !matches!(
|
||||
self.state,
|
||||
State::Uninitialized | State::WaitingForReceiveAccept | State::WaitingForRequestAccept
|
||||
) && elapsed < timeout
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.last_ts = now_ts;
|
||||
self.response = None;
|
||||
|
||||
match self.state {
|
||||
State::WaitingForResponse => match self.read_modbus_response().await {
|
||||
Ok(response) => {
|
||||
self.response = Some(response);
|
||||
}
|
||||
Err(_) => {
|
||||
self.response = None;
|
||||
if self.no_response_expected {
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
}
|
||||
},
|
||||
State::ReadyToSend => {
|
||||
self.send_modbus_request().await;
|
||||
|
||||
// Remove the sent request
|
||||
self.request_map.remove(&self.last_message_id);
|
||||
self.request_metadata_map.remove(&self.last_message_id);
|
||||
self.increment_ignored_times();
|
||||
}
|
||||
State::WaitingForReceiveAccept => {
|
||||
// Waste at least one Ethercat Cycle here to ensure that request/response stay in sync
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
State::WaitingForRequestAccept => {
|
||||
// An empty vec is used to check if we are finished with writing the message
|
||||
// This is to keep the Serialinterface more simple
|
||||
match (self.serial_interface.write_message)(vec![]).await {
|
||||
Ok(finished) if finished => {
|
||||
self.state = State::WaitingForResponse;
|
||||
}
|
||||
Err(_) => {
|
||||
self.state = State::ReadyToSend;
|
||||
}
|
||||
_ => {} // Still waiting
|
||||
}
|
||||
}
|
||||
State::Uninitialized => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(unix)]
|
||||
/// Makes the current thread a real-time thread using the PREEMPT_RT capabilities of Linux.
|
||||
///
|
||||
/// This function configures the current thread with real-time scheduling properties by:
|
||||
///
|
||||
/// 1. Setting the scheduling policy to SCHED_FIFO (First-In-First-Out), which is a real-time
|
||||
/// scheduling policy that allows the thread to run until it voluntarily yields, blocks on I/O,
|
||||
/// or is preempted by a higher-priority real-time thread.
|
||||
///
|
||||
/// 2. Setting the real-time priority to 80 (on a scale of 1-99, where higher values indicate
|
||||
/// higher priority). This priority level is chosen to be higher than most IRQ handlers
|
||||
/// (typically priority 50) but lower than critical kernel tasks (priority 99).
|
||||
///
|
||||
/// When running on a PREEMPT_RT patched Linux kernel, this combination provides deterministic
|
||||
/// scheduling with minimal latency, which is essential for real-time applications.
|
||||
///
|
||||
/// # Requirements
|
||||
///
|
||||
/// - The program must run with appropriate permissions to set real-time priorities.
|
||||
/// This typically means either:
|
||||
/// - Running as root
|
||||
/// - Configuring `/etc/security/limits.conf` to allow the user to set real-time priorities
|
||||
///
|
||||
/// - The system should be running a Linux kernel with PREEMPT_RT patch applied for
|
||||
/// optimal real-time performance.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() {
|
||||
/// // Lock memory to prevent page faults
|
||||
/// lock_memory();
|
||||
///
|
||||
/// // Create a thread and make it real-time
|
||||
/// std::thread::spawn(|| {
|
||||
/// set_realtime_priority();
|
||||
///
|
||||
/// // Real-time code here
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// - `Ok(())` if the real-time priority was successfully set
|
||||
/// - `Err` with the error message if setting the priority failed
|
||||
#[cfg(unix)]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
use anyhow::anyhow;
|
||||
use libc::{SCHED_FIFO, pthread_setschedparam, sched_param};
|
||||
use std::mem;
|
||||
|
||||
unsafe {
|
||||
// Get the pthread_t of the current thread using pthread_self()
|
||||
let pthread_self = libc::pthread_self();
|
||||
|
||||
// Set up the scheduling parameters
|
||||
let mut param: sched_param = mem::zeroed();
|
||||
param.sched_priority = 95;
|
||||
|
||||
// Set the thread to use SCHED_FIFO scheduling policy with our priority
|
||||
let result = pthread_setschedparam(pthread_self, SCHED_FIFO, ¶m);
|
||||
|
||||
if result != 0 {
|
||||
error!(
|
||||
"Failed to set real-time scheduling policy for thread \"{:?}\": {}",
|
||||
std::thread::current().name().unwrap_or("unknown"),
|
||||
std::io::Error::last_os_error()
|
||||
);
|
||||
return Err(anyhow!(
|
||||
"Failed to set real-time priority: {}",
|
||||
std::io::Error::last_os_error()
|
||||
));
|
||||
} else {
|
||||
debug!(
|
||||
"Successfully set real-time priority for the thread \"{:?}\"",
|
||||
std::thread::current().name().unwrap_or("unknown")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn set_realtime_priority() -> Result<(), anyhow::Error> {
|
||||
log::error!("This platform does not support realtime threads");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locks all current and future memory pages of the process into RAM to prevent page faults.
|
||||
///
|
||||
/// # When to call this function
|
||||
///
|
||||
/// This function should be called from the main thread, as early as possible in your program's
|
||||
/// execution, ideally before creating any threads and before allocating large amounts of memory.
|
||||
///
|
||||
/// Important: You only need to call this function ONCE per process, not in each thread.
|
||||
/// The memory locking applies to the entire process, including:
|
||||
/// - The main thread
|
||||
/// - All threads created after the call
|
||||
///
|
||||
/// This happens because all threads in a process share the same virtual memory space, and
|
||||
/// `mlockall()` operates on the entire process's memory space, not just the calling thread.
|
||||
///
|
||||
/// # Example: Correct usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
/// // Call early in main, before creating threads
|
||||
/// lock_memory()?;
|
||||
///
|
||||
/// // Now create real-time threads...
|
||||
/// std::thread::spawn(|| {
|
||||
/// set_realtime_priority();
|
||||
/// // Real-time code here
|
||||
/// });
|
||||
///
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Example: Incorrect usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// fn main() {
|
||||
/// // Don't do this - calling in each thread is unnecessary
|
||||
/// std::thread::spawn(|| {
|
||||
/// lock_memory().unwrap(); // Redundant if already called in main
|
||||
/// set_realtime_priority();
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # How it works
|
||||
///
|
||||
/// [Rest of documentation as before...]
|
||||
#[cfg(unix)]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use libc::{MCL_FUTURE, mlockall};
|
||||
|
||||
unsafe {
|
||||
if mlockall(MCL_FUTURE) != 0 {
|
||||
error!("Failed to lock memory: {}", std::io::Error::last_os_error());
|
||||
return Err(
|
||||
format!("Failed to lock memory: {}", std::io::Error::last_os_error()).into(),
|
||||
);
|
||||
} else {
|
||||
debug!("Successfully locked memory for the process");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn lock_memory() -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::error!("This platform does not support memory locking");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_core_affinity_first_core(core_id: usize) -> Result<(), anyhow::Error> {
|
||||
let cores = core_affinity::get_core_ids().unwrap_or_default();
|
||||
let core = cores.get(core_id);
|
||||
if let Some(core) = core {
|
||||
core_affinity::set_for_current(*core);
|
||||
debug!(
|
||||
"Set core affinity of thread \"{:?}\" to core {:?}",
|
||||
std::thread::current().name().unwrap_or("unknown"),
|
||||
core
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
error!("No cores available to set affinity");
|
||||
Err(anyhow::anyhow!("No cores available to set affinity"))
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,22 @@ impl Namespace {
|
||||
self.sockets.retain(|s| s.id != socket.id);
|
||||
}
|
||||
|
||||
/// Disconnects all clients in the namespace.
|
||||
///
|
||||
/// This will forcefully disconnect all sockets and clear the socket list.
|
||||
#[instrument(skip_all)]
|
||||
pub fn disconnect_all(&mut self) {
|
||||
tracing::info!("Disconnecting {} sockets in namespace", self.sockets.len());
|
||||
|
||||
// Disconnect each socket
|
||||
for socket in &self.sockets {
|
||||
let _ = socket.clone().disconnect(); // Ignore errors if socket is already disconnected
|
||||
}
|
||||
|
||||
// Clear the socket list
|
||||
self.sockets.clear();
|
||||
}
|
||||
|
||||
/// Re-emits cached events to a specific socket.
|
||||
///
|
||||
/// This is typically used when a socket reconnects or joins an existing namespace
|
||||
@@ -153,6 +169,12 @@ impl Namespace {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Namespace {
|
||||
fn drop(&mut self) {
|
||||
self.disconnect_all();
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NamespaceBufferCacheKey {
|
||||
fn to_key(&self) -> String;
|
||||
}
|
||||
@@ -171,7 +193,7 @@ pub trait CacheableEvents<Events> {
|
||||
|
||||
pub type CacheFn = Box<dyn Fn(&mut Vec<Arc<GenericEvent>>, &Arc<GenericEvent>) -> ()>;
|
||||
|
||||
/// [BufferFn] that stores the last n events
|
||||
/// [`BufferFn`] that stores the last n events
|
||||
pub fn cache_n_events(n: usize) -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
if events.len() >= n {
|
||||
@@ -181,12 +203,32 @@ pub fn cache_n_events(n: usize) -> CacheFn {
|
||||
})
|
||||
}
|
||||
|
||||
/// [BufferFn] that stores only one event
|
||||
/// [`BufferFn`] that stores only one event
|
||||
pub fn cache_one_event() -> CacheFn {
|
||||
cache_n_events(1)
|
||||
}
|
||||
|
||||
/// [BufferFn] that stores events for a certain duration
|
||||
/// [`BufferFn`] that stores first and last event
|
||||
///
|
||||
/// The primary use case of this function is to cache both the default state of a machine, which should be emitted first,
|
||||
/// and the last event, which is the most recent state of the machine.
|
||||
pub fn cache_first_and_last_event() -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
// if the events length 0 or 1, we just push the event
|
||||
if events.is_empty() || events.len() == 1 {
|
||||
events.push(event.clone());
|
||||
return;
|
||||
}
|
||||
// if the event length is 2 we remove the last event and append a new one
|
||||
if events.len() == 2 {
|
||||
events.remove(1);
|
||||
events.push(event.clone());
|
||||
return;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// [`BufferFn`] that stores events for a certain duration
|
||||
pub fn cache_duration(duration: Duration, bucket_size: Duration) -> CacheFn {
|
||||
Box::new(move |events, event| {
|
||||
// Use event.ts instead of system time
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Environment Setup
|
||||
|
||||
Working on this repository requires you to use cargo and npm. In the following document the installation process will be shown
|
||||
|
||||
## Ubuntu/Debian Installation
|
||||
|
||||
```bash
|
||||
# Just press enter when prompted
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
apt install npm
|
||||
apt install nodejs
|
||||
# assuming you dont have git already, Git setup not included in here
|
||||
apt install git
|
||||
git clone git@github.com:qitechgmbh/control.git
|
||||
cd control
|
||||
cd electron
|
||||
npm install
|
||||
```
|
||||
|
||||
# Running Backend and Frontend Linux
|
||||
|
||||
## Backend
|
||||
|
||||
To Compile backend code and run it on Linux:
|
||||
|
||||
```bash
|
||||
./cargo_run_linux.sh
|
||||
```
|
||||
|
||||
The script sets capabilities on the compiled binary like raw socket access.
|
||||
Here it is required that you are connected to atleast one machine that is communicated with over ethercat or usb/serial.
|
||||
The Most minimal working setup would be to connect over ethernet to an ek1100 beckhoff terminal.
|
||||
|
||||
## Mock-Machine
|
||||
|
||||
The Mock-Machine can be used to test code that does not require an actual machine connection.
|
||||
Like Frontend Code for example.
|
||||
|
||||
Mock machines can be used if the compile feature mock-machine is enabled.
|
||||
|
||||
```bash
|
||||
# in root git folder
|
||||
./cargo_run_linux.sh mock-machine
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
To run the Frontend Code:
|
||||
|
||||
```bash
|
||||
# need to be in the electron folder and an additional terminal
|
||||
cd electron
|
||||
npm run start
|
||||
```
|
||||
|
||||
# Contributing
|
||||
|
||||
Generally you contribute to the codebase by:
|
||||
|
||||
1. Opening or Choosing an existing issue (Bug,Feature,Task etc)
|
||||
2. Work on it locally
|
||||
3. Commit your changes locally
|
||||
4. Before pushing changes, if there were changes on the master branch, rebase your branch like so:
|
||||
|
||||
```bash
|
||||
git fetch
|
||||
git rebase origin/master
|
||||
```
|
||||
|
||||
5. Push changes
|
||||
|
||||
```bash
|
||||
# IF you needed to rebase
|
||||
git push --force-with-lease
|
||||
# ELSE you just push
|
||||
git push
|
||||
```
|
||||
|
||||
6. Open a Pull Request on Github and link the issue by writing fix #issue_number, after the pull request is merged into master the branch is closed automatically
|
||||
7. Request a Review and hope for the best :)
|
||||
|
||||
# Recommended Editor Setup
|
||||
|
||||
We recommend you to use an Editor with rust-analyzer support like VSCode to speed up development and detect errors before compiling.
|
||||
+144
-75
@@ -11,6 +11,8 @@ A functionality can be virtually wired to any device that matches the IO type. E
|
||||
|
||||
Functionalities of different machines should not be wired to the same devices because of possibly conflicting configuration and machine identification issues (which devices belong to what machine).
|
||||
|
||||
The IO types provide direct synchronous methods for interacting with hardware, eliminating the need for actor patterns while maintaining clean abstractions.
|
||||
|
||||
# Implemented IO types
|
||||
|
||||
- Digital Output (DO)
|
||||
@@ -35,13 +37,13 @@ pub enum EL2002Port {
|
||||
|
||||
Then the device must implement the respective device trait like `DigitalOutputDevice`.
|
||||
|
||||
The `digital_output_write` is used to set the desired level of the output of a given port.
|
||||
The `set_output` method is used to set the desired level of the output of a given port.
|
||||
|
||||
The `digital_output_state` is used to read the current state of the output of a given port.
|
||||
The `get_output` method is used to read the current state of the output of a given port.
|
||||
|
||||
```rust
|
||||
impl DigitalOutputDevice<EL2002Port> for EL2002 {
|
||||
fn digital_output_write(&mut self, port: EL2002Port, value: DigitalOutputOutput) {
|
||||
fn set_output(&mut self, port: EL2002Port, value: DigitalOutputOutput) {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_mut().expect(&expect_text).value = value.into(),
|
||||
@@ -49,84 +51,95 @@ impl DigitalOutputDevice<EL2002Port> for EL2002 {
|
||||
}
|
||||
}
|
||||
|
||||
fn digital_output_state(&self, port: EL2002Port) -> DigitalOutputState {
|
||||
fn get_output(&self, port: EL2002Port) -> DigitalOutputOutput {
|
||||
let expect_text = "All channels should be Some(_)";
|
||||
DigitalOutputState {
|
||||
output: DigitalOutputOutput(match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_ref().expect(&expect_text).value,
|
||||
EL2002Port::DO2 => self.rxpdo.channel2.as_ref().expect(&expect_text).value,
|
||||
}),
|
||||
}
|
||||
DigitalOutputOutput(match port {
|
||||
EL2002Port::DO1 => self.rxpdo.channel1.as_ref().expect(&expect_text).value,
|
||||
EL2002Port::DO2 => self.rxpdo.channel2.as_ref().expect(&expect_text).value,
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementing for an `Actor`
|
||||
## Using IO types in machine logic
|
||||
|
||||
The `act` function of an `Actor` will be called after the inputs are read from the device and before the outputs are written for the next EtherCAT frame.
|
||||
IO types now provide direct synchronous methods for reading and writing values, making them easy to use in machine logic without async/await patterns.
|
||||
|
||||
The actor needs to be constructed with the `DigitalOutput` and hold it in its struct.
|
||||
```rust
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalOutputToggler {
|
||||
output: DigitalOutput,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Implementing the `act` function which will toggle the output every cycle.
|
||||
|
||||
The `state` and `write` values are callbacks and must be wrapped with parentheses to be called.
|
||||
```rust
|
||||
fn act(&mut self, _now: Instant) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
|
||||
Box::pin(async move {
|
||||
{
|
||||
let state = (self.output.state)().await;
|
||||
match state.output.into() {
|
||||
true => (self.output.write)(false.into()).await,
|
||||
false => (self.output.write)(true.into()).await,
|
||||
}
|
||||
}
|
||||
})
|
||||
impl DigitalOutputToggler {
|
||||
pub fn new(output: DigitalOutput) -> Self {
|
||||
Self { output }
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
let current_state = self.output.get();
|
||||
self.output.set(!current_state);
|
||||
}
|
||||
|
||||
pub fn turn_on(&mut self) {
|
||||
self.output.set(true);
|
||||
}
|
||||
|
||||
pub fn turn_off(&mut self) {
|
||||
self.output.set(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Getting `DigitalOutput` from a `Device`
|
||||
With the `new` function of the `DigitalOutput` struct, you can create a new `DigitalOutput` instance from a device and its port. We can then provide this `DigitalOutput` instance to the `Actor` struct.
|
||||
With the `new` function of the `DigitalOutput` struct, you can create a new `DigitalOutput` instance from a device and its port. The IO type provides direct synchronous methods for interaction.
|
||||
|
||||
```rust
|
||||
let el2002 = EL2002::new();
|
||||
let el2002 = Arc::new(RwLock::new(EL2002::new()));
|
||||
let digital_output = DigitalOutput::new(el2002, EL2002Port::DO1);
|
||||
let actor = DigitalOutputToggler::new(digital_output);
|
||||
let mut toggler = DigitalOutputToggler::new(digital_output);
|
||||
|
||||
// Direct usage
|
||||
toggler.turn_on();
|
||||
toggler.toggle();
|
||||
```
|
||||
|
||||
# Creating a new IO type
|
||||
To create a new IO type, we must know if it's writable (output) or readable (input), or combined (input/output) type.
|
||||
|
||||
We first define the `DigitalOutput` struct with the `write` and `state` fields. Every IO needs a `state` field but only writable types need a `write` field.
|
||||
|
||||
The `state` field is an async callback which returns the `DigitalOutputState` struct.
|
||||
|
||||
The `write` field is an async callback which takes the value.
|
||||
For output types, we define the struct with sync closures for setting and getting values:
|
||||
|
||||
```rust
|
||||
pub struct DigitalOutput {
|
||||
pub write: Box<dyn Fn(DigitalOutputOutput) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>,
|
||||
pub state:
|
||||
Box<dyn Fn() -> Pin<Box<dyn Future<Output = DigitalOutputState> + Send>> + Send + Sync>,
|
||||
set_output: Box<dyn Fn(DigitalOutputOutput) -> () + Send + Sync>,
|
||||
get_output: Box<dyn Fn() -> DigitalOutputOutput + Send + Sync>,
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to implement the `DigitalOutputState` and `DigitalOutputOutput` structs.
|
||||
For input types, we only need the getter:
|
||||
|
||||
The `DigitalOutputState` contains the timestamps of the output (and inputs if applicable) and the `DigitalOutputOutput` struct (and the input struct if applicable). This is the return type of `DigitalOutput::state`.
|
||||
|
||||
The `DigitalOutputOutput` is both contained in the `DigitalOutputState` and is the input type of the `DigitalOutput::write` function. It implements the `From` trait in both directions to `bool` to allow easy access to the inner value on this simple type (as seen in the `act` example above).
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DigitalOutputState {
|
||||
pub output: DigitalOutputOutput,
|
||||
pub struct DigitalInput {
|
||||
get_input: Box<dyn Fn() -> Result<DigitalInputInput, anyhow::Error> + Send + Sync>,
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output types, we have separate getters for input and output:
|
||||
|
||||
```rust
|
||||
pub struct StepperVelocityEL70x1 {
|
||||
set_output: Box<dyn Fn(StepperVelocityEL70x1Output) -> Result<(), Error> + Send + Sync>,
|
||||
get_output: Box<dyn Fn() -> Result<StepperVelocityEL70x1Output, Error> + Send + Sync>,
|
||||
get_input: Box<dyn Fn() -> Result<StepperVelocityEL70x1Input, Error> + Send + Sync>,
|
||||
// ... additional closures as needed
|
||||
}
|
||||
```
|
||||
|
||||
Next, we need to implement the input and output structs separately.
|
||||
|
||||
For a digital output, we only need the output struct:
|
||||
|
||||
```rust
|
||||
/// Output value
|
||||
/// true: high
|
||||
/// false: low
|
||||
@@ -146,8 +159,35 @@ impl From<DigitalOutputOutput> for bool {
|
||||
}
|
||||
```
|
||||
|
||||
Now we need to implement the constructor for a `DigitalOutput` instance. The `new` function takes a device and a port and returns a `DigitalOutput` instance.
|
||||
We need to construct the async closures which is a bit of type madness since we also need to move the device `Arc` into them.
|
||||
For input types, we define input structs:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DigitalInputInput {
|
||||
pub value: bool,
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output types, we define both structs separately, avoiding the need for state containers.
|
||||
|
||||
```rust
|
||||
pub struct DigitalOutputOutput(pub bool);
|
||||
|
||||
impl From<bool> for DigitalOutputOutput {
|
||||
fn from(value: bool) -> Self {
|
||||
DigitalOutputOutput(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DigitalOutputOutput> for bool {
|
||||
fn from(value: DigitalOutputOutput) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now we implement the constructor for the IO type. The `new` function takes a device and port and returns the IO instance with sync closures:
|
||||
|
||||
```rust
|
||||
impl DigitalOutput {
|
||||
pub fn new<PORT>(
|
||||
@@ -157,46 +197,75 @@ impl DigitalOutput {
|
||||
where
|
||||
PORT: Clone + Send + Sync + 'static,
|
||||
{
|
||||
// build async write closure
|
||||
// build sync write closure
|
||||
let port1 = port.clone();
|
||||
let device1 = device.clone();
|
||||
let write = Box::new(
|
||||
move |value: DigitalOutputOutput| -> Pin<Box<dyn Future<Output = ()> + Send>> {
|
||||
let device_clone = device1.clone();
|
||||
let port_clone = port1.clone();
|
||||
Box::pin(async move {
|
||||
let mut device = device_clone.write().await;
|
||||
device.digital_output_write(port_clone, value);
|
||||
})
|
||||
},
|
||||
);
|
||||
let set_output = Box::new(move |value: DigitalOutputOutput| {
|
||||
let mut device = block_on(device1.write());
|
||||
device.set_output(port1.clone(), value);
|
||||
});
|
||||
|
||||
// build async get closure
|
||||
// build sync get closure
|
||||
let port2 = port.clone();
|
||||
let device2 = device.clone();
|
||||
let state = Box::new(
|
||||
move || -> Pin<Box<dyn Future<Output = DigitalOutputState> + Send>> {
|
||||
let device2 = device2.clone();
|
||||
let port_clone = port2.clone();
|
||||
Box::pin(async move {
|
||||
let device = device2.read().await;
|
||||
device.digital_output_state(port_clone)
|
||||
})
|
||||
},
|
||||
);
|
||||
DigitalOutput { write, state }
|
||||
let get_output = Box::new(move || -> DigitalOutputOutput {
|
||||
let device = block_on(device2.read());
|
||||
device.get_output(port2.clone())
|
||||
});
|
||||
|
||||
DigitalOutput {
|
||||
set_output,
|
||||
get_output,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the digital output value
|
||||
pub fn set(&self, enabled: bool) {
|
||||
(self.set_output)(enabled.into());
|
||||
}
|
||||
|
||||
/// Get the current output value
|
||||
pub fn get(&self) -> bool {
|
||||
let output = (self.get_output)();
|
||||
output.into()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Lastly, we can create the `DigitalOutputDevice` trait which needs to be implemented by the device.
|
||||
Lastly, we create the device trait that needs to be implemented by devices. The trait methods are simplified and separated by input/output:
|
||||
|
||||
```rust
|
||||
pub trait DigitalOutputDevice<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn digital_output_write(&mut self, port: PORT, value: DigitalOutputOutput);
|
||||
fn digital_output_state(&self, port: PORT) -> DigitalOutputState;
|
||||
fn set_output(&mut self, port: PORT, value: DigitalOutputOutput);
|
||||
fn get_output(&self, port: PORT) -> DigitalOutputOutput;
|
||||
}
|
||||
```
|
||||
|
||||
For input devices:
|
||||
|
||||
```rust
|
||||
pub trait DigitalInputDevice<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn get_input(&self, port: PORT) -> Result<DigitalInputInput, anyhow::Error>;
|
||||
}
|
||||
```
|
||||
|
||||
For combined input/output devices:
|
||||
|
||||
```rust
|
||||
pub trait StepperVelocityEL70x1Device<PORT>: Send + Sync
|
||||
where
|
||||
PORT: Clone,
|
||||
{
|
||||
fn set_output(&mut self, port: PORT, value: StepperVelocityEL70x1Output) -> Result<(), Error>;
|
||||
fn get_output(&self, port: PORT) -> Result<StepperVelocityEL70x1Output, Error>;
|
||||
fn get_input(&self, port: PORT) -> Result<StepperVelocityEL70x1Input, Error>;
|
||||
// Additional methods as needed for specific device functionality
|
||||
fn get_speed_range(&self, port: PORT) -> SpeedRange;
|
||||
}
|
||||
```
|
||||
+28
-36
@@ -134,46 +134,38 @@ A slave recognizes message data as one message when a **3.5 character long no-da
|
||||
|
||||
---
|
||||
|
||||
# Mitsubishi Inverter settings for Modbus RTU
|
||||
# Inverter settings
|
||||
|
||||
Protocol Selection: set Pr 549 to 1 (Modbus RTU)
|
||||
set 551 to a non zero value (9999)
|
||||
Operation mode selection: set Pr 79 to 0
|
||||
PU communication station number: set Pr 117 to a value of 1-32 (this is also the slave address)
|
||||
PU communication speed: set Pr 118 the same baudrate as the Beckhoff terminal. For example Terminal has 19200 then you need to set 192
|
||||
PU communication stop bit: set Pr 119 according to Beckhoff terminal setting
|
||||
PU communication parity check: set Pr 120 according to Beckhoff terminal setting
|
||||
## Motor Constants
|
||||
|
||||
# el6021 settings needed for Modbus RTU
|
||||
|
||||
Continuous is needed for Modbus RTU
|
||||
8000:04 Enable send fifo data continuous TRUE
|
||||
8000:06 Enable half duplex
|
||||
8000:11 Enable Baud rate 19200 Baud
|
||||
8000:15 Data Frame 8N2
|
||||
|
||||
# Inverter settings relevant for Motor
|
||||
|
||||
## Motor Constant
|
||||
|
||||
The following is a table that shows which Registers need to be set for the safe operation of the Motor.
|
||||
The following is a table that shows which Registers need to be set on the Inverter for the safe operation of the Motor.
|
||||
|
||||
| Parameter number (Pr) | Name | Value |
|
||||
| --------------------- | ---------------------------- | ------------- |
|
||||
| 71 | Applied Motor | 0 |
|
||||
| 80 | Motor Capacity | 15 (1.5 kw) |
|
||||
| 96 | Auto tuning setting/status | 1 |
|
||||
| 1 | Max Frequency | 5000 |
|
||||
| 2 | Min Frequency | 0 |
|
||||
| 9 | Electronic Thermal O/L Relay | 3.30 (Ampere) |
|
||||
| 3 | Base frequency | 5000 (50 Hz) |
|
||||
| 19 | Base frequency voltage | 400 (Volt) |
|
||||
| `71` | Applied Motor | 0 |
|
||||
| `80` | Motor Capacity | 15 (1.5 kw) |
|
||||
| `96` | Auto tuning setting/status | 1 |
|
||||
| `1` | Max Frequency | 5000 |
|
||||
| `2` | Min Frequency | 0 |
|
||||
| `9` | Electronic Thermal O/L Relay | 3.30 (Ampere) |
|
||||
| `3` | Base frequency | 5000 (50 Hz) |
|
||||
| `19` | Base frequency voltage | 400 (Volt) |
|
||||
|
||||
## Settings relevant for Communication/Control
|
||||
## All Settings needed for the Inverter Communication/Operation with Extruder
|
||||
|
||||
| Parameter number (Pr) | Name | Value |
|
||||
| --------------------- | ------------------------------------ | ----- |
|
||||
| 79 | Operation Mode | 0 |
|
||||
| 340 | Communication startup mode selection | 1 |
|
||||
|
||||
The Combination of 79 = 0 and 340 = 1 starts the inverter in the NET operation Mode, which enables us to start/stop the motor.
|
||||
The Following settings are required to get the inverter Communication and Operation working properly
|
||||
| Parameter Number (Pr) | Value | Description |
|
||||
| --------------------- | ----- | -------------------------------------------------------------------------------------------- |
|
||||
| `Pr. 78` | 0 | Allows Reverse Rotation |
|
||||
| `Pr. 79` | 0 | Operation Mode Selection |
|
||||
| `Pr. 117` | 1 | Station Number (Slave Number) |
|
||||
| `Pr. 118` | 192 | Baudrate (192 → 19200 bps) |
|
||||
| `Pr. 119` | 0 | Stop Bit Length |
|
||||
| `Pr. 120` | 2 | Parity Check |
|
||||
| `Pr. 121` | 9999 | PU communication retry count |
|
||||
| `Pr. 122` | 1.0 | Communication Check Time (Time until no communication causes E.PUE fault), 0.1 seconds steps |
|
||||
| `Pr. 338` | 0 | Communication operation command source, 0 -> given through PU |
|
||||
| `Pr. 339` | 0 | Communication speed command source, 0 -> given through PU |
|
||||
| `Pr. 340` | 1 | Startup in Network Mode |
|
||||
| `Pr. 549` | 1 | Protocol Selection (1 = Modbus) |
|
||||
| `Pr. 551` | 9999 | Automatically Detect PU Connector |
|
||||
+16
-15
@@ -6,31 +6,32 @@ This guide will help you set up a NixOS system with QiTech Control and manage up
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Add Home Manager Channel
|
||||
### 1. Install Git
|
||||
|
||||
```bash
|
||||
sudo nix-channel --add https://github.com/nix-community/home-manager/archive/master.tar.gz home-manager
|
||||
sudo nix-channel --update
|
||||
nixos-env -i git
|
||||
```
|
||||
|
||||
### 2. Configure System Files
|
||||
|
||||
Create the three main configuration files:
|
||||
|
||||
- `flake.nix` - Defines inputs, overlays, and system configuration
|
||||
- `configuration.nix` - Main NixOS system configuration
|
||||
- `home.nix` - Home Manager configuration for the qitech user
|
||||
|
||||
### 3. Build and Apply Configuration
|
||||
### 2. Clone the Control Repo
|
||||
|
||||
```bash
|
||||
# Build and switch to the new configuration
|
||||
sudo nixos-rebuild switch --flake .#nixos
|
||||
git clone https://github.com/qitechgmbh/control
|
||||
cd control
|
||||
```
|
||||
|
||||
### 3. Run Install Script
|
||||
|
||||
```bash
|
||||
# run as admin either with sudo or as root!
|
||||
sudo ./nixos-install.sh
|
||||
```
|
||||
|
||||
After the Installation script is finished the Computer should have rebooted and automatically started the control software.
|
||||
|
||||
### 4. Manual configuration
|
||||
|
||||
Disable any power management features or display sleep settings in the GNOME settings. Check that the on-screen keyboard is enabled under accessibility settings.
|
||||
Disable any power management features or display sleep settings in the GNOME settings.
|
||||
Check that the on-screen keyboard is enabled under accessibility settings.
|
||||
|
||||
## System Configuration Overview
|
||||
|
||||
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
Generated
+1681
-2575
File diff suppressed because it is too large.
Load diff
@@ -44,7 +44,6 @@
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"electron": "36.4.0",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-playwright-helpers": "^1.7.1",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
@@ -66,6 +65,7 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.2",
|
||||
"@icons-pack/react-simple-icons": "^11.2.0",
|
||||
"@jub3i/tree-kill": "^0.0.15",
|
||||
"@radix-ui/react-collapsible": "^1.1.8",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
@@ -87,6 +87,7 @@
|
||||
"babel-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"electron-devtools-installer": "^4.0.0",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"i18next": "^25.0.2",
|
||||
"immer": "^10.1.1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -1 +1 @@
|
||||
# Laser Manual
|
||||
# Laser Manual
|
||||
@@ -5,11 +5,12 @@
|
||||
This is the operating manual for the Mock Machine control system. The Mock Machine is a simulated industrial device used for testing and development purposes.
|
||||
|
||||

|
||||
*Figure 1: Mock Machine Mascot*
|
||||
_Figure 1: Mock Machine Mascot_
|
||||
|
||||
## Safety Information
|
||||
|
||||
⚠️ **Important Safety Notes:**
|
||||
|
||||
- Always ensure proper safety protocols are followed
|
||||
- Verify all connections before operation
|
||||
- Keep emergency stop accessible at all times
|
||||
@@ -19,6 +20,7 @@ This is the operating manual for the Mock Machine control system. The Mock Machi
|
||||
### Starting the Machine
|
||||
|
||||
1. **Power On Sequence**
|
||||
|
||||
- Check all safety switches
|
||||
- Verify emergency stops are released
|
||||
- Power on the main controller
|
||||
@@ -39,6 +41,7 @@ The mock machine interface provides:
|
||||
### Monitoring
|
||||
|
||||
Use the Graph tab to monitor:
|
||||
|
||||
- System performance metrics
|
||||
- Sensor readings
|
||||
- Historical data trends
|
||||
@@ -62,11 +65,13 @@ Use the Graph tab to monitor:
|
||||
## Maintenance
|
||||
|
||||
### Daily Checks
|
||||
|
||||
- [ ] Verify all indicators are green
|
||||
- [ ] Check for error messages
|
||||
- [ ] Review system logs
|
||||
|
||||
### Weekly Maintenance
|
||||
|
||||
- [ ] Clean control panel
|
||||
- [ ] Check cable connections
|
||||
- [ ] Update system logs
|
||||
@@ -77,4 +82,4 @@ For technical support, contact the engineering team or refer to the system docum
|
||||
|
||||
---
|
||||
|
||||
*Last updated: June 2025*
|
||||
_Last updated: June 2025_
|
||||
@@ -64,8 +64,17 @@ export class ThrottledStoreUpdater<S> {
|
||||
* Sync the entire buffer to the store
|
||||
*/
|
||||
private syncToStore(): void {
|
||||
this.store.setState(this.buffer);
|
||||
this.syncTimer = null;
|
||||
try {
|
||||
this.store.setState(this.buffer);
|
||||
this.syncTimer = null;
|
||||
} catch (error) {
|
||||
console.error("Error syncing to store:", error);
|
||||
this.syncTimer = null;
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
this.scheduleSync();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import {
|
||||
getVendorPreset,
|
||||
getMachinePreset,
|
||||
MachinePreset,
|
||||
VendorPreset,
|
||||
MachineProperties,
|
||||
MachineIdentificationUnique,
|
||||
} from "@/machines/types";
|
||||
import { useMainNamespace } from "./mainNamespace";
|
||||
import {
|
||||
getVendorProperties,
|
||||
getMachineProperties,
|
||||
VendorProperties,
|
||||
} from "@/machines/properties";
|
||||
|
||||
type UseMachine = {
|
||||
machine_identification_unique: MachineIdentificationUnique;
|
||||
name: MachinePreset["name"];
|
||||
version: MachinePreset["version"];
|
||||
slug: MachinePreset["slug"];
|
||||
vendor: VendorPreset["name"];
|
||||
icon: MachinePreset["icon"];
|
||||
name: MachineProperties["name"];
|
||||
version: MachineProperties["version"];
|
||||
slug: MachineProperties["slug"];
|
||||
vendor: VendorProperties["name"];
|
||||
icon: MachineProperties["icon"];
|
||||
};
|
||||
|
||||
// returns only valid machines
|
||||
@@ -25,10 +27,10 @@ export function useMachines(): UseMachine[] {
|
||||
machines.data.machines
|
||||
.filter((machine) => machine.error === null)
|
||||
.map((machine) => {
|
||||
const machinePreset = getMachinePreset(
|
||||
const machinePreset = getMachineProperties(
|
||||
machine.machine_identification_unique.machine_identification,
|
||||
);
|
||||
const vendorPreset = getVendorPreset(
|
||||
const vendorPreset = getVendorProperties(
|
||||
machinePreset!.machine_identification.vendor,
|
||||
);
|
||||
if (!machinePreset || !vendorPreset) {
|
||||
|
||||
@@ -157,7 +157,15 @@ export function Markdown({ text }: MarkdownProps) {
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-bold" {...props} />
|
||||
<strong className="[font-size:inherit] font-bold" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
em: ({ node, ...props }) => (
|
||||
<em className="[font-size:inherit] italic" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
del: ({ node, ...props }) => (
|
||||
<del className="[font-size:inherit] line-through" {...props} />
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
img: ({ node, alt, ...props }) => (
|
||||
@@ -171,7 +179,7 @@ export function Markdown({ text }: MarkdownProps) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
code: ({ node, ...props }) => (
|
||||
<code
|
||||
className="rounded-full bg-gray-100 p-0.5 px-2 text-sm"
|
||||
className="rounded-sm bg-gray-100 p-0.5 px-2 [font-size:inherit]"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -296,8 +296,10 @@ function parseMarkdownHeadings(markdownContent: string): TocItem[] {
|
||||
const match = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
const title = match[2].trim();
|
||||
const id = generateHeadingId(title);
|
||||
const rawTitle = match[2].trim();
|
||||
// Strip markdown formatting for TOC display
|
||||
const title = stripMarkdownInlineFormatting(rawTitle);
|
||||
const id = generateHeadingId(rawTitle); // Use raw title for ID generation
|
||||
headings.push({ level, title, id });
|
||||
}
|
||||
});
|
||||
@@ -305,6 +307,35 @@ function parseMarkdownHeadings(markdownContent: string): TocItem[] {
|
||||
return buildTocTree(headings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown inline formatting from text for TOC display
|
||||
* Removes: **bold**, *italic*, `code`, ~~strikethrough~~, links, etc.
|
||||
*/
|
||||
function stripMarkdownInlineFormatting(text: string): string {
|
||||
return (
|
||||
text
|
||||
// Remove bold (**text** or __text__)
|
||||
.replace(/\*\*(.*?)\*\*/g, "$1")
|
||||
.replace(/__(.*?)__/g, "$1")
|
||||
// Remove italic (*text* or _text_)
|
||||
.replace(/\*(.*?)\*/g, "$1")
|
||||
.replace(/_(.*?)_/g, "$1")
|
||||
// Remove strikethrough (~~text~~)
|
||||
.replace(/~~(.*?)~~/g, "$1")
|
||||
// Remove inline code (`text`)
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
// Remove links [text](url) -> text
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
// Remove reference-style links [text][ref] -> text
|
||||
.replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
|
||||
// Remove images  -> alt
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
||||
// Clean up any remaining markdown characters
|
||||
.replace(/[*_`~]/g, "")
|
||||
.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function buildTocTree(
|
||||
headings: Array<{ level: number; title: string; id: string }>,
|
||||
): TocItem[] {
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MarkdownWithToc({
|
||||
style={{ maxHeight: "calc(100vh - 6rem)" }}
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto scrollbar-hide"
|
||||
className="scrollbar-hide overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 8rem)" }}
|
||||
>
|
||||
<MarkdownToc
|
||||
|
||||
@@ -2,8 +2,13 @@ import React from "react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Page({ children }: Props) {
|
||||
return <div className="flex w-full flex-col gap-6 p-6">{children}</div>;
|
||||
export function Page({ children, className }: Props) {
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 p-6 ${className || ""}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import { renderUnitSymbol, getUnitIcon } from "@/control/units";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { BigGraphProps, HandlerRefs, SeriesData } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { DEFAULT_COLORS } from "./constants";
|
||||
import {
|
||||
useAnimationRefs,
|
||||
stopAnimations,
|
||||
normalizeDataSeries,
|
||||
getPrimarySeries,
|
||||
formatDisplayValue,
|
||||
} from "./animation";
|
||||
import { useLiveMode } from "./liveMode";
|
||||
import { useHistoricalMode } from "./historicalMode";
|
||||
import { useBigGraphEffects } from "./useBigGraphEffects";
|
||||
import { TouchButton } from "../touch/TouchButton";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import { getAllTimeSeries } from "./createChart";
|
||||
|
||||
// Collects lines connected to visible series for rendering
|
||||
const getVisibleLines = (data: any, visibleSeries: boolean[]) => {
|
||||
const visibleLines: any[] = [];
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((series, index) => {
|
||||
if (visibleSeries[index] && series.lines && Array.isArray(series.lines)) {
|
||||
const seriesLines = series.lines.map((line: any) => ({
|
||||
...line,
|
||||
color: line.color || series.color,
|
||||
dash: line.dash || [5, 5],
|
||||
seriesIndex: index,
|
||||
seriesTitle: series.title,
|
||||
}));
|
||||
visibleLines.push(...seriesLines);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (visibleSeries[0] && data.lines && Array.isArray(data.lines)) {
|
||||
const seriesLines = data.lines.map((line: any) => ({
|
||||
...line,
|
||||
color: line.color || data.color,
|
||||
dash: line.dash || [5, 5],
|
||||
seriesIndex: 0,
|
||||
seriesTitle: data.title,
|
||||
}));
|
||||
visibleLines.push(...seriesLines);
|
||||
}
|
||||
}
|
||||
|
||||
return visibleLines;
|
||||
};
|
||||
|
||||
// Merges configuration lines with lines from visible series
|
||||
const mergeConfigWithVisibleLines = (
|
||||
config: any,
|
||||
data: any,
|
||||
visibleSeries: boolean[],
|
||||
) => {
|
||||
const configLines = config.lines || [];
|
||||
const seriesLines = getVisibleLines(data, visibleSeries);
|
||||
|
||||
// Combine and remove duplicate lines based on label and series connection
|
||||
const allLines = [...configLines, ...seriesLines];
|
||||
const uniqueLines = allLines.filter((line, index, arr) => {
|
||||
return (
|
||||
arr.findIndex((l) => {
|
||||
if (line.seriesIndex !== undefined && l.seriesIndex !== undefined) {
|
||||
return l.label === line.label && l.seriesIndex === line.seriesIndex;
|
||||
}
|
||||
return (
|
||||
(l.label && l.label === line.label) ||
|
||||
(l.value === line.value && l.type === line.type)
|
||||
);
|
||||
}) === index
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
...config,
|
||||
lines: uniqueLines,
|
||||
};
|
||||
};
|
||||
|
||||
export function BigGraph({
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
}: BigGraphProps & {
|
||||
onRegisterForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
onUnregisterFromExport?: (graphId: string) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
const chartCreatedRef = useRef(false);
|
||||
|
||||
// Animation references for managing animations
|
||||
const animationRefs = useAnimationRefs();
|
||||
|
||||
// State for series visibility
|
||||
const normalizedSeries = normalizeDataSeries(newData);
|
||||
const [visibleSeries, setVisibleSeries] = useState<boolean[]>(
|
||||
new Array(normalizedSeries.length).fill(true),
|
||||
);
|
||||
|
||||
// Enhanced configuration with visible series lines
|
||||
const enhancedConfig = React.useMemo(() => {
|
||||
return mergeConfigWithVisibleLines(config, newData, visibleSeries);
|
||||
}, [config, newData, visibleSeries]);
|
||||
|
||||
// State for managing chart visibility and modes
|
||||
const [viewMode, setViewMode] = useState<"default" | "all" | "manual">(
|
||||
syncGraph?.viewMode ?? "default",
|
||||
);
|
||||
const [isLiveMode, setIsLiveMode] = useState(syncGraph?.isLiveMode ?? true);
|
||||
const [selectedTimeWindow, setSelectedTimeWindow] = useState<number | "all">(
|
||||
syncGraph?.timeWindow ?? enhancedConfig.defaultTimeWindow ?? 30 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Cursor state for displaying values
|
||||
const [cursorValue, setCursorValue] = useState<number | null>(null);
|
||||
const [cursorValues, setCursorValues] = useState<(number | null)[]>(
|
||||
new Array(normalizedSeries.length).fill(null),
|
||||
);
|
||||
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
const manualScaleRef = useRef<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>(null);
|
||||
const lastProcessedCountRef = useRef(0);
|
||||
|
||||
// Handler references for user interactions
|
||||
const handlerRefs: HandlerRefs = {
|
||||
isUserZoomingRef: useRef(false),
|
||||
isDraggingRef: useRef(false),
|
||||
lastDragXRef: useRef<number | null>(null),
|
||||
isPinchingRef: useRef(false),
|
||||
lastPinchDistanceRef: useRef<number | null>(null),
|
||||
pinchCenterRef: useRef<{ x: number; y: number } | null>(null),
|
||||
touchStartRef: useRef<{ x: number; y: number; time: number } | null>(null),
|
||||
touchDirectionRef: useRef<"horizontal" | "vertical" | "unknown">("unknown"),
|
||||
};
|
||||
|
||||
const colors = {
|
||||
primary: enhancedConfig.colors?.primary ?? DEFAULT_COLORS.primary,
|
||||
grid: enhancedConfig.colors?.grid ?? DEFAULT_COLORS.grid,
|
||||
axis: enhancedConfig.colors?.axis ?? DEFAULT_COLORS.axis,
|
||||
background: enhancedConfig.colors?.background ?? DEFAULT_COLORS.background,
|
||||
};
|
||||
|
||||
// Update cursor values when series length changes
|
||||
useEffect(() => {
|
||||
setCursorValues(new Array(normalizedSeries.length).fill(null));
|
||||
}, [normalizedSeries.length]);
|
||||
|
||||
// FAST VISIBILITY UPDATE FUNCTION
|
||||
const updateSeriesVisibility = useCallback(() => {
|
||||
if (!uplotRef.current || !visibleSeries) return;
|
||||
|
||||
const allOriginalSeries = getAllTimeSeries(newData);
|
||||
|
||||
// Update visibility for each series
|
||||
allOriginalSeries.forEach((_, index) => {
|
||||
const seriesIndex = index + 1; // +1 because index 0 is time axis
|
||||
const isVisible = visibleSeries[index];
|
||||
|
||||
if (uplotRef.current!.series[seriesIndex]) {
|
||||
uplotRef.current!.series[seriesIndex].show = isVisible;
|
||||
|
||||
// Update points visibility
|
||||
if (uplotRef.current!.series[seriesIndex].points) {
|
||||
uplotRef.current!.series[seriesIndex].points!.show = (
|
||||
_u,
|
||||
_seriesIdx,
|
||||
dataIdx,
|
||||
) => {
|
||||
return isVisible && dataIdx < animationRefs.realPointsCount.current;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger lightweight redraw
|
||||
uplotRef.current.redraw();
|
||||
}, [visibleSeries, newData, animationRefs.realPointsCount]);
|
||||
|
||||
// SEPARATE EFFECT FOR VISIBILITY UPDATES ONLY
|
||||
useEffect(() => {
|
||||
if (uplotRef.current && chartCreatedRef.current) {
|
||||
updateSeriesVisibility();
|
||||
}
|
||||
}, [visibleSeries, updateSeriesVisibility]);
|
||||
|
||||
// Register export functionality for the graph
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport) {
|
||||
// Unregister any previous registrations for this graph
|
||||
if (onUnregisterFromExport) {
|
||||
// Clear any existing registrations for this graph's series
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
onUnregisterFromExport(seriesId);
|
||||
});
|
||||
}
|
||||
|
||||
// Register each series as a separate export entry
|
||||
normalizedSeries.forEach((series, index) => {
|
||||
if (!series.newData) return;
|
||||
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
const seriesTitle = series.title || `Series ${index + 1}`;
|
||||
|
||||
const getSeriesExportData = (): GraphExportData | null => {
|
||||
// Create export data for this specific series only
|
||||
const seriesLines = series.lines || [];
|
||||
const exportConfig = {
|
||||
...config,
|
||||
title: config.title, // Keep original graph title
|
||||
lines: [...(config.lines || []), ...seriesLines],
|
||||
};
|
||||
|
||||
// Create a single-series data structure for this series
|
||||
const singleSeriesData: SeriesData = {
|
||||
newData: series.newData,
|
||||
title: seriesTitle,
|
||||
color: series.color,
|
||||
lines: series.lines,
|
||||
};
|
||||
|
||||
return {
|
||||
config: exportConfig,
|
||||
data: singleSeriesData, // Return ONLY this specific series
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
onRegisterForExport(seriesId, getSeriesExportData);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (onUnregisterFromExport) {
|
||||
// Clean up all series registrations
|
||||
normalizedSeries.forEach((_, index) => {
|
||||
const seriesId = `${graphId}-series-${index}`;
|
||||
onUnregisterFromExport(seriesId);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
graphId,
|
||||
config,
|
||||
normalizedSeries.length,
|
||||
unit,
|
||||
renderValue,
|
||||
// Add dependencies for series data
|
||||
JSON.stringify(
|
||||
normalizedSeries.map((s) => ({
|
||||
title: s.title,
|
||||
color: s.color,
|
||||
hasData: !!s.newData,
|
||||
linesCount: s.lines?.length || 0,
|
||||
})),
|
||||
),
|
||||
]);
|
||||
|
||||
// Updates Y-axis scale dynamically based on visible data
|
||||
const updateYAxisScale = useCallback(
|
||||
(xMin?: number, xMax?: number) => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const isInHistoricalMode = !isLiveMode || viewMode === "manual";
|
||||
if (isInHistoricalMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedData = normalizeDataSeries(newData);
|
||||
let allVisibleValues: number[] = [];
|
||||
|
||||
normalizedData.forEach((series, index) => {
|
||||
if (!visibleSeries[index] || !series.newData?.long) return;
|
||||
|
||||
const [timestamps, values] = seriesToUPlotData(series.newData.long);
|
||||
if (values.length === 0) return;
|
||||
|
||||
let seriesToInclude: number[] = [];
|
||||
|
||||
if (xMin !== undefined && xMax !== undefined) {
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (timestamps[i] >= xMin && timestamps[i] <= xMax) {
|
||||
seriesToInclude.push(values[i]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seriesToInclude = [...values];
|
||||
}
|
||||
|
||||
allVisibleValues.push(...seriesToInclude);
|
||||
});
|
||||
|
||||
enhancedConfig.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
allVisibleValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (allVisibleValues.length === 0) {
|
||||
const primarySeries = getPrimarySeries(newData);
|
||||
if (primarySeries?.newData?.long) {
|
||||
const [, values] = seriesToUPlotData(primarySeries.newData.long);
|
||||
allVisibleValues = values;
|
||||
}
|
||||
}
|
||||
|
||||
if (allVisibleValues.length === 0) return;
|
||||
|
||||
const minY = Math.min(...allVisibleValues);
|
||||
const maxY = Math.max(...allVisibleValues);
|
||||
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
||||
|
||||
const yRange = {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
};
|
||||
|
||||
try {
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("y", yRange);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Error updating Y-axis scale:", error);
|
||||
}
|
||||
},
|
||||
[enhancedConfig.lines, viewMode, isLiveMode, newData, visibleSeries],
|
||||
);
|
||||
|
||||
// Initialize live mode handlers
|
||||
const liveMode = useLiveMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
config: enhancedConfig,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
chartCreatedRef,
|
||||
});
|
||||
|
||||
// Initialize historical mode handlers
|
||||
const historicalMode = useHistoricalMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
animationRefs,
|
||||
getCurrentLiveEndTimestamp: liveMode.getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
manualScaleRef,
|
||||
});
|
||||
|
||||
// Handles time window changes
|
||||
const handleTimeWindowChangeInternal = useCallback(
|
||||
(newTimeWindow: number | "all", isSync: boolean = false) => {
|
||||
stopAnimations(animationRefs);
|
||||
setSelectedTimeWindow(newTimeWindow);
|
||||
|
||||
if (!uplotRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newTimeWindow === "all") {
|
||||
setViewMode("all");
|
||||
if (!isLiveMode) {
|
||||
setIsLiveMode(true);
|
||||
historicalMode.switchToLiveMode();
|
||||
}
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
setViewMode("default");
|
||||
if (isLiveMode) {
|
||||
liveMode.handleLiveTimeWindow(newTimeWindow);
|
||||
} else {
|
||||
historicalMode.handleHistoricalTimeWindow(newTimeWindow);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSync && syncGraph?.onTimeWindowChange) {
|
||||
syncGraph.onTimeWindowChange(graphId, newTimeWindow);
|
||||
}
|
||||
},
|
||||
[
|
||||
animationRefs,
|
||||
isLiveMode,
|
||||
liveMode.handleLiveTimeWindow,
|
||||
historicalMode.handleHistoricalTimeWindow,
|
||||
historicalMode.switchToLiveMode,
|
||||
syncGraph,
|
||||
graphId,
|
||||
],
|
||||
);
|
||||
|
||||
// Toggles visibility of a series
|
||||
const toggleSeries = useCallback((index: number) => {
|
||||
setVisibleSeries((prev) => {
|
||||
const newVisibility = [...prev];
|
||||
|
||||
const currentlyVisible = newVisibility.filter((visible) => visible);
|
||||
const wouldHideAll =
|
||||
newVisibility[index] && currentlyVisible.length === 1;
|
||||
|
||||
if (wouldHideAll) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
newVisibility[index] = !newVisibility[index];
|
||||
return newVisibility;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Gets the display value for a series
|
||||
const getSeriesDisplayValue = useCallback(
|
||||
(seriesIndex: number) => {
|
||||
const series = normalizedSeries[seriesIndex];
|
||||
if (!series) return null;
|
||||
|
||||
if (normalizedSeries.length > 1) {
|
||||
return cursorValues[seriesIndex] !== null
|
||||
? cursorValues[seriesIndex]
|
||||
: series.newData?.current?.value;
|
||||
} else {
|
||||
return cursorValue !== null
|
||||
? cursorValue
|
||||
: series.newData?.current?.value;
|
||||
}
|
||||
},
|
||||
[normalizedSeries, cursorValues, cursorValue],
|
||||
);
|
||||
|
||||
// Applies effects for the graph (REMOVED visibleSeries from dependencies)
|
||||
useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
chartCreatedRef,
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config: enhancedConfig,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries, // Keep for initial creation
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
liveMode,
|
||||
historicalMode,
|
||||
colors,
|
||||
updateYAxisScale,
|
||||
handleTimeWindowChangeInternal,
|
||||
});
|
||||
|
||||
const displayValue = getSeriesDisplayValue(0);
|
||||
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
|
||||
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">
|
||||
<Icon
|
||||
name={unit ? getUnitIcon(unit) : "lu:TrendingUp"}
|
||||
className="size-6 text-gray-600"
|
||||
/>
|
||||
|
||||
<h2 className="text-2xl leading-none font-bold text-gray-900">
|
||||
{enhancedConfig.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{normalizedSeries.length === 1 && (
|
||||
<ControlCard className="rounded-md px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-base text-gray-600">
|
||||
<span className="font-mono leading-none font-bold text-gray-900">
|
||||
{formatDisplayValue(displayValue, renderValue)}
|
||||
</span>
|
||||
<span className="leading-none text-gray-500">
|
||||
{renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</ControlCard>
|
||||
)}
|
||||
|
||||
{normalizedSeries.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{normalizedSeries.map((series, index) => {
|
||||
const seriesColor =
|
||||
series.color ||
|
||||
defaultColors[index % defaultColors.length];
|
||||
|
||||
const displayValue = getSeriesDisplayValue(index);
|
||||
const formattedValue = formatDisplayValue(
|
||||
displayValue,
|
||||
renderValue,
|
||||
);
|
||||
|
||||
const isLastVisible =
|
||||
visibleSeries[index] &&
|
||||
visibleSeries.filter((visible) => visible).length === 1;
|
||||
|
||||
const seriesLines = Array.isArray(newData)
|
||||
? newData[index]?.lines || []
|
||||
: newData.lines || [];
|
||||
|
||||
return (
|
||||
<TouchButton
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!isLastVisible) {
|
||||
toggleSeries(index);
|
||||
}
|
||||
}}
|
||||
className={`rounded-md px-4 py-1.5 text-sm transition-all ${
|
||||
visibleSeries[index]
|
||||
? "text-white shadow-md"
|
||||
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
|
||||
} ${isLastVisible ? "cursor-not-allowed" : ""}`}
|
||||
style={{
|
||||
backgroundColor: visibleSeries[index]
|
||||
? seriesColor
|
||||
: undefined,
|
||||
}}
|
||||
title={`${series.title || `Series ${index + 1}`}: ${formattedValue} ${renderUnitSymbol(unit) || ""}${
|
||||
seriesLines.length > 0
|
||||
? `\nLines: ${seriesLines.map((l) => l.label || `${l.value}`).join(", ")}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-xs font-medium">
|
||||
{series.title || `S${index + 1}`}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono leading-none font-bold ${
|
||||
visibleSeries[index]
|
||||
? "text-white"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{formattedValue} {renderUnitSymbol(unit)}
|
||||
</span>
|
||||
</div>
|
||||
</TouchButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 px-6">
|
||||
<div className="h-px bg-gray-200"></div>
|
||||
</div>
|
||||
|
||||
<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`}
|
||||
style={{ backgroundColor: colors.background }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from "react";
|
||||
import { TouchButton } from "@/components/touch/TouchButton";
|
||||
import { Icon } from "@/components/Icon";
|
||||
import { ControlCard } from "@/control/ControlCard";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ControlProps } from "./types";
|
||||
import { DEFAULT_TIME_WINDOW_OPTIONS } from "./constants";
|
||||
|
||||
export function GraphControls({
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange,
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
}: ControlProps) {
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlCard className="ml-auto w-fit py-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
</TouchButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="text-base font-medium">
|
||||
Time Window
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{timeWindowOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onTimeWindowChange(option.value)}
|
||||
className={`min-h-[48px] px-4 py-3 text-base ${
|
||||
timeWindow === option.value ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Historical
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
onClick={onSwitchToLive}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
|
||||
{onExport && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ControlCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingControlPanel({
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange,
|
||||
onSwitchToLive,
|
||||
onSwitchToHistorical,
|
||||
onExport,
|
||||
timeWindowOptions = DEFAULT_TIME_WINDOW_OPTIONS,
|
||||
}: ControlProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const getSelectedTimeWindowLabel = () => {
|
||||
const option = timeWindowOptions.find((opt) => opt.value === timeWindow);
|
||||
return option ? option.label : "1m";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-10 bottom-6 z-50">
|
||||
<ControlCard className="overflow-hidden px-4 py-4 transition-all duration-300 ease-in-out">
|
||||
<div
|
||||
className={`flex items-center ${isExpanded ? "gap-3" : "justify-center"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-3 transition-all duration-300 ease-in-out ${
|
||||
isExpanded
|
||||
? "max-w-none translate-x-0 opacity-100"
|
||||
: "w-0 max-w-0 overflow-hidden opacity-0"
|
||||
}`}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<TouchButton
|
||||
variant="outline"
|
||||
className="h-auto border-gray-300 bg-white px-3 py-3 text-base text-gray-900 hover:bg-gray-50"
|
||||
>
|
||||
{getSelectedTimeWindowLabel()}
|
||||
<Icon name="lu:ChevronDown" className="ml-2 size-4" />
|
||||
</TouchButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="text-base font-medium">
|
||||
Time Window
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{timeWindowOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onTimeWindowChange(option.value)}
|
||||
className={`min-h-[48px] px-4 py-3 text-base ${
|
||||
timeWindow === option.value ? "bg-blue-50" : ""
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<TouchButton
|
||||
onClick={onSwitchToHistorical}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
!isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Historical
|
||||
</TouchButton>
|
||||
<TouchButton
|
||||
onClick={onSwitchToLive}
|
||||
variant="outline"
|
||||
className={`h-auto px-3 py-3 text-base font-medium transition-colors ${
|
||||
isLiveMode
|
||||
? "bg-black text-white"
|
||||
: "border-gray-300 bg-white text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
Live
|
||||
</TouchButton>
|
||||
{isExpanded && onExport && (
|
||||
<div className="h-8 w-px bg-gray-200"></div>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{isExpanded && <div className="h-8 w-px bg-gray-200"></div>}
|
||||
|
||||
<TouchButton
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
variant="outline"
|
||||
className="h-auto flex-shrink-0 bg-black p-3 text-white hover:bg-gray-100"
|
||||
icon={isExpanded ? "lu:ChevronRight" : "lu:ChevronLeft"}
|
||||
/>
|
||||
</div>
|
||||
</ControlCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import {
|
||||
getSeriesMinMax,
|
||||
seriesToUPlotData,
|
||||
TimeSeries,
|
||||
} from "@/lib/timeseries";
|
||||
|
||||
type MiniGraphProps = {
|
||||
newData: TimeSeries | null;
|
||||
width: number;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
const HEIGHT = 64;
|
||||
|
||||
export function MiniGraph({ newData, width, renderValue }: MiniGraphProps) {
|
||||
const divRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Performance tracking refs
|
||||
const lastUpdateTimestamp = useRef<number>(0);
|
||||
const lastDataHash = useRef<string>("");
|
||||
const lastMinMax = useRef<{ min: number; max: number }>({ min: 0, max: 0 });
|
||||
const rafId = useRef<number>(0);
|
||||
const isInitialized = useRef<boolean>(false);
|
||||
const pendingUpdate = useRef<boolean>(false);
|
||||
|
||||
// Fast hash function for data change detection
|
||||
const hashData = useCallback(
|
||||
(timestamps: number[], values: number[]): string => {
|
||||
if (timestamps.length === 0) return "";
|
||||
// Hash only first, last, and length for performance
|
||||
return `${timestamps[0]}-${timestamps[timestamps.length - 1]}-${timestamps.length}-${values[values.length - 1]}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Memoize the tick formatter to avoid recreating on every render
|
||||
const tickFormatter = useMemo(() => {
|
||||
return renderValue
|
||||
? (u: uPlot, ticks: number[]) => ticks.map((v) => renderValue(v))
|
||||
: (u: uPlot, ticks: number[]) => ticks.map((v) => v.toFixed(1));
|
||||
}, [renderValue]);
|
||||
|
||||
// Ultra-efficient update function
|
||||
const updateChart = useCallback(() => {
|
||||
if (!uplotRef.current || !newData?.short || !newData?.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = newData.current;
|
||||
|
||||
// Skip if no new data
|
||||
if (cur.timestamp <= lastUpdateTimestamp.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get data
|
||||
const [timestamps, values] = seriesToUPlotData(short);
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if data actually changed using hash
|
||||
const dataHash = hashData(timestamps, values);
|
||||
if (dataHash === lastDataHash.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get min/max and check if scales need updating
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const scalesChanged =
|
||||
minY !== lastMinMax.current.min || maxY !== lastMinMax.current.max;
|
||||
|
||||
// Update tracking vars
|
||||
lastUpdateTimestamp.current = cur.timestamp;
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const range = maxY - minY || 1;
|
||||
const cutoff = cur.timestamp - timeWindow;
|
||||
|
||||
// Batch all updates to minimize redraws
|
||||
uplotRef.current.batch(() => {
|
||||
// Always update data and x-scale (time moves forward)
|
||||
uplotRef.current!.setData([timestamps, values]);
|
||||
uplotRef.current!.setScale("x", { min: cutoff, max: cur.timestamp });
|
||||
|
||||
// Only update y-scale if min/max changed
|
||||
if (scalesChanged) {
|
||||
uplotRef.current!.setScale("y", {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pendingUpdate.current = false;
|
||||
}, [newData, hashData]);
|
||||
|
||||
// RAF-throttled update scheduler
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (pendingUpdate.current || !isInitialized.current) return;
|
||||
|
||||
pendingUpdate.current = true;
|
||||
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
|
||||
rafId.current = requestAnimationFrame(updateChart);
|
||||
}, [updateChart]);
|
||||
|
||||
// Initialize chart only once
|
||||
useEffect(() => {
|
||||
if (!divRef.current || !newData?.short?.timeWindow || isInitialized.current)
|
||||
return;
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get initial data
|
||||
const [allTimestamps, allValues] = seriesToUPlotData(short);
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const range = maxY - minY || 1;
|
||||
|
||||
// Initialize tracking
|
||||
const dataHash = hashData(allTimestamps, allValues);
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const now = Date.now();
|
||||
const latestTimestamp =
|
||||
allTimestamps.length > 0 ? allTimestamps[allTimestamps.length - 1] : now;
|
||||
const cutoff = latestTimestamp - timeWindow;
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width,
|
||||
height: HEIGHT,
|
||||
padding: [4, 0, 4, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
min: cutoff,
|
||||
max: latestTimestamp,
|
||||
},
|
||||
y: {
|
||||
auto: false,
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{ show: false },
|
||||
{
|
||||
side: 1,
|
||||
grid: { stroke: "#ccc", width: 0.5 },
|
||||
ticks: { stroke: "#ccc", width: 0.5 },
|
||||
values: tickFormatter, // Use the memoized formatter
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: "black",
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: { show: false }, // Hide the dots/points
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
uplotRef.current = new uPlot(
|
||||
opts,
|
||||
[allTimestamps, allValues],
|
||||
divRef.current,
|
||||
);
|
||||
isInitialized.current = true;
|
||||
|
||||
return () => {
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
isInitialized.current = false;
|
||||
pendingUpdate.current = false;
|
||||
};
|
||||
}, [width, newData?.short?.timeWindow, hashData, tickFormatter]);
|
||||
|
||||
// Trigger updates only when timestamp changes
|
||||
useEffect(() => {
|
||||
scheduleUpdate();
|
||||
}, [newData?.current?.timestamp, scheduleUpdate]);
|
||||
|
||||
// Efficient width handling
|
||||
useEffect(() => {
|
||||
if (!uplotRef.current || !isInitialized.current) return;
|
||||
uplotRef.current.setSize({ width, height: HEIGHT });
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: HEIGHT,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { BigGraph } from "./BigGraph";
|
||||
import { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
import { useGraphSync } from "./useGraphSync";
|
||||
import { BigGraphProps, PropGraphSync, TimeWindowOption } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
|
||||
export function SyncedBigGraph({
|
||||
syncGraph: externalSyncGraph,
|
||||
registerForExport,
|
||||
unregisterFromExport,
|
||||
...props
|
||||
}: Omit<BigGraphProps, "syncGraph"> & {
|
||||
syncGraph?: PropGraphSync;
|
||||
registerForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
unregisterFromExport?: (graphId: string) => void;
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
|
||||
return (
|
||||
<BigGraph
|
||||
{...props}
|
||||
syncGraph={externalSyncGraph || defaultSync.syncGraph}
|
||||
onRegisterForExport={registerForExport}
|
||||
onUnregisterFromExport={unregisterFromExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedGraphControls({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
|
||||
return (
|
||||
<GraphControls
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncedFloatingControlPanel({
|
||||
controlProps,
|
||||
timeWindowOptions,
|
||||
...props
|
||||
}: {
|
||||
controlProps?: ReturnType<typeof useGraphSync>["controlProps"];
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
}) {
|
||||
const defaultSync = useGraphSync();
|
||||
const finalProps = controlProps || defaultSync.controlProps;
|
||||
|
||||
return (
|
||||
<FloatingControlPanel
|
||||
{...finalProps}
|
||||
timeWindowOptions={timeWindowOptions}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoSyncedBigGraph({
|
||||
syncHook,
|
||||
...props
|
||||
}: Omit<BigGraphProps, "syncGraph"> & {
|
||||
syncHook: ReturnType<typeof useGraphSync>;
|
||||
}) {
|
||||
return (
|
||||
<SyncedBigGraph
|
||||
{...props}
|
||||
syncGraph={syncHook.syncGraph}
|
||||
registerForExport={syncHook.registerGraphForExport}
|
||||
unregisterFromExport={syncHook.unregisterGraphFromExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { POINT_ANIMATION_DURATION } from "./constants";
|
||||
import {
|
||||
AnimationRefs,
|
||||
AnimationState,
|
||||
BigGraphProps,
|
||||
SeriesData,
|
||||
} from "./types";
|
||||
|
||||
export function useAnimationRefs(): AnimationRefs {
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const animationStateRef = useRef<AnimationState>({
|
||||
isAnimating: false,
|
||||
startTime: 0,
|
||||
fromValue: 0,
|
||||
toValue: 0,
|
||||
fromTimestamp: 0,
|
||||
toTimestamp: 0,
|
||||
targetIndex: 0,
|
||||
});
|
||||
const lastRenderedDataRef = useRef<{
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}>({ timestamps: [], values: [] });
|
||||
const realPointsCountRef = useRef(0);
|
||||
|
||||
return {
|
||||
animationFrame: animationFrameRef,
|
||||
animationState: animationStateRef,
|
||||
lastRenderedData: lastRenderedDataRef,
|
||||
realPointsCount: realPointsCountRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function lerp(start: number, end: number, t: number): number {
|
||||
return start + (end - start) * t;
|
||||
}
|
||||
|
||||
export function stopAnimations(refs: AnimationRefs): void {
|
||||
if (refs.animationFrame.current) {
|
||||
cancelAnimationFrame(refs.animationFrame.current);
|
||||
refs.animationFrame.current = null;
|
||||
}
|
||||
refs.animationState.current.isAnimating = false;
|
||||
}
|
||||
|
||||
export function buildUPlotData(
|
||||
timestamps: number[],
|
||||
values: number[],
|
||||
realPointsCount: number | undefined,
|
||||
realPointsCountRef: React.RefObject<number>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
allSeriesData?: number[][],
|
||||
): uPlot.AlignedData {
|
||||
const uData: uPlot.AlignedData = [timestamps];
|
||||
|
||||
// Add primary series (for animation)
|
||||
uData.push(values);
|
||||
|
||||
// Add additional series if provided
|
||||
if (allSeriesData) {
|
||||
allSeriesData.forEach((seriesValues) => {
|
||||
uData.push(seriesValues);
|
||||
});
|
||||
}
|
||||
|
||||
if (realPointsCount !== undefined) {
|
||||
realPointsCountRef.current = realPointsCount;
|
||||
}
|
||||
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
uData.push(timestamps.map(() => line.value));
|
||||
}
|
||||
});
|
||||
|
||||
return uData;
|
||||
}
|
||||
|
||||
export function animateNewPoint(
|
||||
currentData: { timestamps: number[]; values: number[] },
|
||||
targetData: { timestamps: number[]; values: number[] },
|
||||
refs: AnimationRefs,
|
||||
uplotRef: React.RefObject<uPlot | null>,
|
||||
isLiveMode: boolean,
|
||||
viewMode: string,
|
||||
selectedTimeWindow: number | "all",
|
||||
startTimeRef: React.RefObject<number | null>,
|
||||
config: { lines?: Array<{ show?: boolean; value: number }> },
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void, // Updated signature
|
||||
getAllSeriesData?: () => number[][],
|
||||
): void {
|
||||
if (targetData.timestamps.length <= currentData.timestamps.length) {
|
||||
return;
|
||||
}
|
||||
if (!isLiveMode || viewMode === "manual") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = currentData.timestamps.length;
|
||||
|
||||
const prevValue =
|
||||
currentData.values[newIndex - 1] ?? targetData.values[newIndex];
|
||||
const prevTimestamp =
|
||||
currentData.timestamps[newIndex - 1] ?? targetData.timestamps[newIndex];
|
||||
const newValue = targetData.values[newIndex];
|
||||
const newTimestamp = targetData.timestamps[newIndex];
|
||||
|
||||
if (refs.animationFrame.current) {
|
||||
cancelAnimationFrame(refs.animationFrame.current);
|
||||
refs.animationFrame.current = null;
|
||||
}
|
||||
|
||||
refs.animationState.current = {
|
||||
isAnimating: true,
|
||||
startTime: performance.now(),
|
||||
fromValue: prevValue,
|
||||
toValue: newValue,
|
||||
fromTimestamp: prevTimestamp,
|
||||
toTimestamp: newTimestamp,
|
||||
targetIndex: newIndex,
|
||||
};
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!refs.animationState.current.isAnimating ||
|
||||
!isLiveMode
|
||||
)
|
||||
return;
|
||||
|
||||
const elapsed = currentTime - refs.animationState.current.startTime;
|
||||
const progress = Math.min(elapsed / POINT_ANIMATION_DURATION, 1);
|
||||
|
||||
const animatedTimestamps = [...currentData.timestamps];
|
||||
const animatedValues = [...currentData.values];
|
||||
|
||||
if (progress < 1) {
|
||||
const interpolatedTimestamp = lerp(
|
||||
refs.animationState.current.fromTimestamp,
|
||||
refs.animationState.current.toTimestamp,
|
||||
progress,
|
||||
);
|
||||
const interpolatedValue = lerp(
|
||||
refs.animationState.current.fromValue,
|
||||
refs.animationState.current.toValue,
|
||||
progress,
|
||||
);
|
||||
|
||||
animatedTimestamps.push(interpolatedTimestamp);
|
||||
animatedValues.push(interpolatedValue);
|
||||
} else {
|
||||
animatedTimestamps.push(refs.animationState.current.toTimestamp);
|
||||
animatedValues.push(refs.animationState.current.toValue);
|
||||
|
||||
refs.lastRenderedData.current = {
|
||||
timestamps: [...animatedTimestamps],
|
||||
values: [...animatedValues],
|
||||
};
|
||||
refs.realPointsCount.current = animatedTimestamps.length;
|
||||
refs.animationState.current.isAnimating = false;
|
||||
}
|
||||
|
||||
// Get all series data for complete uPlot update
|
||||
const allSeriesData = getAllSeriesData ? getAllSeriesData() : undefined;
|
||||
|
||||
const animatedUData = buildUPlotData(
|
||||
animatedTimestamps,
|
||||
animatedValues,
|
||||
animatedTimestamps.length,
|
||||
refs.realPointsCount,
|
||||
config,
|
||||
allSeriesData,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(animatedUData);
|
||||
|
||||
if (isLiveMode && animatedTimestamps.length > 0) {
|
||||
const latestTimestamp = animatedTimestamps[animatedTimestamps.length - 1];
|
||||
|
||||
if (viewMode === "default") {
|
||||
let xMin: number | undefined, xMax: number | undefined;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = latestTimestamp;
|
||||
} else {
|
||||
xMin = latestTimestamp - (selectedTimeWindow as number);
|
||||
xMax = latestTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax); // Updated call
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? animatedTimestamps[0];
|
||||
uplotRef.current.setScale("x", {
|
||||
min: fullStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, latestTimestamp); // Updated call
|
||||
}
|
||||
}
|
||||
|
||||
if (progress < 1) {
|
||||
refs.animationFrame.current = requestAnimationFrame(animate);
|
||||
} else {
|
||||
if (targetData.timestamps.length > animatedTimestamps.length) {
|
||||
animateNewPoint(
|
||||
{ timestamps: animatedTimestamps, values: animatedValues },
|
||||
targetData,
|
||||
refs,
|
||||
uplotRef,
|
||||
isLiveMode,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
getAllSeriesData,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
refs.animationFrame.current = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// Helper function to normalize data to array format
|
||||
export function normalizeDataSeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): SeriesData[] {
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
return [data];
|
||||
}
|
||||
|
||||
// Helper function to get primary series for display value
|
||||
export function getPrimarySeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): SeriesData | null {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized.find((series) => series.newData !== null) || null;
|
||||
}
|
||||
|
||||
export function getPrimarySeriesData(data: BigGraphProps["newData"]) {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
const primarySeries = normalized.find((series) => series.newData !== null);
|
||||
return primarySeries?.newData || null;
|
||||
}
|
||||
|
||||
// Helper function to format value for display
|
||||
export function formatDisplayValue(
|
||||
value: number | undefined | null,
|
||||
renderValue?: (value: number) => string,
|
||||
): string {
|
||||
if (value === undefined || value === null) return "N/A";
|
||||
return renderValue ? renderValue(value) : value.toFixed(2);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { TimeWindowOption } from "./types";
|
||||
|
||||
export const DEFAULT_TIME_WINDOW_OPTIONS: TimeWindowOption[] = [
|
||||
{ value: 10 * 1000, label: "10s" },
|
||||
{ value: 30 * 1000, label: "30s" },
|
||||
{ value: 1 * 60 * 1000, label: "1m" },
|
||||
{ value: 5 * 60 * 1000, label: "5m" },
|
||||
{ value: 10 * 60 * 1000, label: "10m" },
|
||||
{ value: 30 * 60 * 1000, label: "30m" },
|
||||
{ value: 1 * 60 * 60 * 1000, label: "1h" },
|
||||
{ value: "all" as const, label: "Show All" },
|
||||
];
|
||||
|
||||
export const POINT_ANIMATION_DURATION = 1000;
|
||||
|
||||
export const DEFAULT_COLORS = {
|
||||
primary: "#3b82f6",
|
||||
grid: "#e2e8f0",
|
||||
axis: "#64748b",
|
||||
background: "#ffffff",
|
||||
};
|
||||
@@ -0,0 +1,531 @@
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
import {
|
||||
createEventHandlers,
|
||||
attachEventHandlers,
|
||||
HandlerCallbacks,
|
||||
} from "./handlers";
|
||||
import { BigGraphProps, CreateChartParams } from "./types";
|
||||
import { normalizeDataSeries } from "./animation";
|
||||
|
||||
export function createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
renderValue,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
graphId,
|
||||
syncGraph,
|
||||
getHistoricalEndTimestamp,
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
}: CreateChartParams & {
|
||||
setCursorValues?: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
visibleSeries?: boolean[];
|
||||
}): (() => void) | undefined {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Get ALL series from original data
|
||||
const allOriginalSeries = getAllTimeSeries(newData);
|
||||
if (allOriginalSeries.length === 0) return;
|
||||
|
||||
// Use the first available series for timing calculations (visible or not)
|
||||
const primarySeries = allOriginalSeries[0].series;
|
||||
const [timestamps, primaryValues] = seriesToUPlotData(primarySeries.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
if (startTimeRef.current === null && timestamps.length > 0) {
|
||||
startTimeRef.current = timestamps[0];
|
||||
}
|
||||
|
||||
// Build uPlot data for ALL series (including hidden ones)
|
||||
const uPlotData: uPlot.AlignedData = [timestamps as any];
|
||||
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [, values] = seriesToUPlotData(series.long);
|
||||
uPlotData.push(values as any);
|
||||
});
|
||||
|
||||
// Add config lines data
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
const lineData = new Array(timestamps.length).fill(line.value);
|
||||
uPlotData.push(lineData as any);
|
||||
}
|
||||
});
|
||||
|
||||
const fullStart = startTimeRef.current ?? timestamps[0] ?? 0;
|
||||
|
||||
let initialMin: number, initialMax: number;
|
||||
if (viewMode === "manual" && manualScaleRef.current) {
|
||||
initialMin = manualScaleRef.current.x.min;
|
||||
initialMax = manualScaleRef.current.x.max;
|
||||
} else if (selectedTimeWindow === "all") {
|
||||
initialMin = fullStart;
|
||||
initialMax = getHistoricalEndTimestamp();
|
||||
} else {
|
||||
const endTimestamp = getHistoricalEndTimestamp();
|
||||
|
||||
if (isLiveMode) {
|
||||
const defaultViewStart = Math.max(
|
||||
endTimestamp - (selectedTimeWindow as number),
|
||||
fullStart,
|
||||
);
|
||||
initialMin = defaultViewStart;
|
||||
} else {
|
||||
initialMin = endTimestamp - (selectedTimeWindow as number);
|
||||
}
|
||||
|
||||
initialMax = endTimestamp;
|
||||
}
|
||||
|
||||
// Calculate initial Y range from ALL series (not just visible ones)
|
||||
// This prevents scale changes when toggling visibility
|
||||
const initialAllValues: number[] = [];
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [seriesTimestamps, seriesValues] = seriesToUPlotData(series.long);
|
||||
for (let i = 0; i < seriesTimestamps.length; i++) {
|
||||
if (
|
||||
seriesTimestamps[i] >= initialMin &&
|
||||
seriesTimestamps[i] <= initialMax
|
||||
) {
|
||||
initialAllValues.push(seriesValues[i]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialAllValues.push(line.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (initialAllValues.length === 0) {
|
||||
allOriginalSeries.forEach(({ series }) => {
|
||||
const [, values] = seriesToUPlotData(series.long);
|
||||
initialAllValues.push(...values);
|
||||
});
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
initialAllValues.push(line.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let initialYMin: number, initialYMax: number;
|
||||
if (initialAllValues.length > 0) {
|
||||
const minY = Math.min(...initialAllValues);
|
||||
const maxY = Math.max(...initialAllValues);
|
||||
const range = maxY - minY || Math.abs(maxY) * 0.1 || 1;
|
||||
|
||||
initialYMin = minY - range * 0.1;
|
||||
initialYMax = maxY + range * 0.1;
|
||||
} else {
|
||||
initialYMin = -1;
|
||||
initialYMax = 1;
|
||||
}
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = Math.min(rect.height, window.innerHeight * 0.5);
|
||||
|
||||
// Build series configuration for ALL series (but control visibility)
|
||||
const seriesConfig: uPlot.Series[] = [{ label: "Time" }];
|
||||
|
||||
// Add ALL data series with visibility control
|
||||
const defaultColors = ["#3b82f6", "#ef4444", "#10b981", "#f59e0b", "#8b5cf6"];
|
||||
allOriginalSeries.forEach(({ title, color }, index) => {
|
||||
const isVisible = !visibleSeries || visibleSeries[index];
|
||||
|
||||
seriesConfig.push({
|
||||
label: title || `Series ${index + 1}`,
|
||||
stroke: color || defaultColors[index % defaultColors.length],
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
show: isVisible, // Control visibility here
|
||||
points: {
|
||||
show: (_u, _seriesIdx, dataIdx) => {
|
||||
return isVisible && dataIdx < animationRefs.realPointsCount.current;
|
||||
},
|
||||
size: 6,
|
||||
stroke: color || defaultColors[index % defaultColors.length],
|
||||
fill: color || defaultColors[index % defaultColors.length],
|
||||
width: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Add config lines
|
||||
config.lines?.forEach((line) => {
|
||||
if (line.show !== false) {
|
||||
seriesConfig.push({
|
||||
label: line.label,
|
||||
stroke: line.color,
|
||||
width: line.width ?? 1,
|
||||
dash: line.dash ?? (line.type === "threshold" ? [5, 5] : undefined),
|
||||
show: true,
|
||||
points: { show: false },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Always destroy existing chart before creating new one
|
||||
if (uplotRef.current) {
|
||||
uplotRef.current.destroy();
|
||||
uplotRef.current = null;
|
||||
}
|
||||
|
||||
// Create new chart with initial visibility settings
|
||||
uplotRef.current = new uPlot(
|
||||
{
|
||||
width,
|
||||
height,
|
||||
padding: [-10, 20, -10, 20],
|
||||
cursor: {
|
||||
show: true,
|
||||
x: true,
|
||||
y: true,
|
||||
drag: {
|
||||
x: true,
|
||||
y: false,
|
||||
setScale: true,
|
||||
},
|
||||
sync: { key: "myCursor" },
|
||||
},
|
||||
legend: {
|
||||
show: config.showLegend ?? allOriginalSeries.length > 1,
|
||||
},
|
||||
hooks: {
|
||||
setScale: [
|
||||
(u) => {
|
||||
if (handlerRefs.isUserZoomingRef.current) {
|
||||
const xScale = u.scales.x;
|
||||
if (xScale.min !== undefined && xScale.max !== undefined) {
|
||||
updateYAxisScale(xScale.min, xScale.max);
|
||||
|
||||
manualScaleRef.current = {
|
||||
x: { min: xScale.min ?? 0, max: xScale.max ?? 0 },
|
||||
y: {
|
||||
min: u.scales.y?.min ?? 0,
|
||||
max: u.scales.y?.max ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
setViewMode("manual");
|
||||
setIsLiveMode(false);
|
||||
|
||||
if (syncGraph?.onZoomChange) {
|
||||
syncGraph.onZoomChange(graphId, {
|
||||
min: xScale.min ?? 0,
|
||||
max: xScale.max ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (syncGraph?.onViewModeChange) {
|
||||
syncGraph.onViewModeChange(graphId, "manual", false);
|
||||
}
|
||||
}
|
||||
handlerRefs.isUserZoomingRef.current = false;
|
||||
}
|
||||
},
|
||||
],
|
||||
setCursor: [
|
||||
(u) => {
|
||||
if (typeof u.cursor.idx === "number") {
|
||||
const timestamp = u.data[0][u.cursor.idx];
|
||||
|
||||
// Handle single series case
|
||||
if (allOriginalSeries.length === 1) {
|
||||
if (u.data[1] && u.data[1][u.cursor.idx] !== undefined) {
|
||||
const value = u.data[1][u.cursor.idx];
|
||||
const cur = allOriginalSeries[0]?.series?.current;
|
||||
|
||||
const isNearCurrent =
|
||||
cur &&
|
||||
timestamp !== undefined &&
|
||||
Math.abs(timestamp - cur.timestamp) < 1000;
|
||||
|
||||
const displayValue = isNearCurrent ? cur.value : value;
|
||||
setCursorValue(displayValue ?? null);
|
||||
} else {
|
||||
setCursorValue(null);
|
||||
}
|
||||
}
|
||||
// Handle multiple series case
|
||||
else if (setCursorValues && visibleSeries) {
|
||||
// Create cursor values array for ALL original series
|
||||
const cursorValuesArray: (number | null)[] = new Array(
|
||||
allOriginalSeries.length,
|
||||
).fill(null);
|
||||
|
||||
// Since we include ALL series in uPlot data, indices match directly
|
||||
allOriginalSeries.forEach((originalSeries, originalIndex) => {
|
||||
const dataIndex = originalIndex + 1; // +1 because 0 is timestamps
|
||||
|
||||
if (
|
||||
u.data[dataIndex] &&
|
||||
u.data[dataIndex][u.cursor.idx!] !== undefined
|
||||
) {
|
||||
const value = u.data[dataIndex][u.cursor.idx!];
|
||||
const cur = originalSeries.series?.current;
|
||||
|
||||
const isNearCurrent =
|
||||
cur &&
|
||||
timestamp !== undefined &&
|
||||
Math.abs(timestamp - cur.timestamp) < 1000;
|
||||
|
||||
const displayValue = isNearCurrent ? cur.value : value;
|
||||
cursorValuesArray[originalIndex] = displayValue ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
setCursorValues(cursorValuesArray);
|
||||
|
||||
// Set primary cursor value for backward compatibility
|
||||
const firstVisibleValue = cursorValuesArray.find(
|
||||
(v) => v !== null,
|
||||
);
|
||||
setCursorValue(firstVisibleValue ?? null);
|
||||
}
|
||||
} else {
|
||||
setCursorValue(null);
|
||||
if (setCursorValues) {
|
||||
setCursorValues(new Array(allOriginalSeries.length).fill(null));
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
auto: true,
|
||||
min: initialMin,
|
||||
max: initialMax,
|
||||
distr: 1,
|
||||
},
|
||||
y: {
|
||||
auto: false,
|
||||
min: initialYMin,
|
||||
max: initialYMax,
|
||||
},
|
||||
},
|
||||
|
||||
axes: [
|
||||
{
|
||||
stroke: colors.axis,
|
||||
labelSize: 12,
|
||||
labelFont: "Inter, system-ui, sans-serif",
|
||||
grid: { stroke: colors.grid, width: 1 },
|
||||
space: 60,
|
||||
values: (u, ticks) => {
|
||||
const xScale = u.scales.x;
|
||||
if (
|
||||
!xScale ||
|
||||
xScale.min === undefined ||
|
||||
xScale.max === undefined
|
||||
) {
|
||||
return ticks.map((ts) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const timeRange = xScale.max - xScale.min;
|
||||
|
||||
return ticks.map((ts) => {
|
||||
const date = new Date(ts);
|
||||
|
||||
if (timeRange <= 30 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 5 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 60 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else if (timeRange <= 24 * 60 * 60 * 1000) {
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
splits: (_u, _axisIdx, scaleMin, scaleMax) => {
|
||||
const timeRange = scaleMax - scaleMin;
|
||||
const ticks: number[] = [];
|
||||
|
||||
if (timeRange <= 10 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 1000) * 1000;
|
||||
for (let t = startTime; t <= scaleMax; t += 1000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 30 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 5000) * 5000;
|
||||
for (let t = startTime; t <= scaleMax; t += 5000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 10000) * 10000;
|
||||
for (let t = startTime; t <= scaleMax; t += 10000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 5 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 30000) * 30000;
|
||||
for (let t = startTime; t <= scaleMax; t += 30000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 10 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 60000) * 60000;
|
||||
for (let t = startTime; t <= scaleMax; t += 60000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 30 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 120000) * 120000;
|
||||
for (let t = startTime; t <= scaleMax; t += 120000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 60 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 300000) * 300000;
|
||||
for (let t = startTime; t <= scaleMax; t += 300000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else if (timeRange <= 6 * 60 * 60 * 1000) {
|
||||
const startTime = Math.ceil(scaleMin / 1800000) * 1800000;
|
||||
for (let t = startTime; t <= scaleMax; t += 1800000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
} else {
|
||||
const startTime = Math.ceil(scaleMin / 3600000) * 3600000;
|
||||
for (let t = startTime; t <= scaleMax; t += 3600000) {
|
||||
if (t >= scaleMin) ticks.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
return ticks;
|
||||
},
|
||||
},
|
||||
{
|
||||
stroke: colors.axis,
|
||||
labelSize: 12,
|
||||
labelFont: "Inter, system-ui, sans-serif",
|
||||
grid: { stroke: colors.grid, width: 1 },
|
||||
side: 1,
|
||||
space: 60,
|
||||
values: (_u, ticks): (string | number | null)[] => {
|
||||
if (renderValue) {
|
||||
const renderedValues = ticks.map(renderValue);
|
||||
const uniqueValues = new Set(renderedValues);
|
||||
|
||||
if (uniqueValues.size === renderedValues.length) {
|
||||
return renderedValues as (string | number | null)[];
|
||||
}
|
||||
}
|
||||
|
||||
const precision = 0;
|
||||
const maxPrecision = 4;
|
||||
|
||||
for (let p = precision; p <= maxPrecision; p++) {
|
||||
const formattedValues = ticks.map((v) => v.toFixed(p));
|
||||
const uniqueFormatted = new Set(formattedValues);
|
||||
|
||||
if (uniqueFormatted.size === formattedValues.length) {
|
||||
return formattedValues;
|
||||
}
|
||||
}
|
||||
|
||||
return ticks.map((v) => v.toFixed(maxPrecision));
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
series: seriesConfig,
|
||||
},
|
||||
uPlotData,
|
||||
containerRef.current,
|
||||
);
|
||||
|
||||
// Create handler callbacks
|
||||
const handlerCallbacks: HandlerCallbacks = {
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
onZoomChange: syncGraph?.onZoomChange,
|
||||
onViewModeChange: syncGraph?.onViewModeChange,
|
||||
};
|
||||
|
||||
// Create and attach event handlers
|
||||
const handlers = createEventHandlers(
|
||||
containerRef,
|
||||
uplotRef,
|
||||
handlerRefs,
|
||||
handlerCallbacks,
|
||||
newData,
|
||||
config,
|
||||
graphId,
|
||||
manualScaleRef,
|
||||
width,
|
||||
);
|
||||
|
||||
const cleanup = attachEventHandlers(containerRef.current, handlers);
|
||||
|
||||
animationRefs.lastRenderedData.current = {
|
||||
timestamps: [...timestamps],
|
||||
values: [...primaryValues],
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// Helper function to get all valid TimeSeries from DataSeries
|
||||
export function getAllTimeSeries(
|
||||
data: BigGraphProps["newData"],
|
||||
): Array<{ series: TimeSeries; title?: string; color?: string }> {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized
|
||||
.filter((series) => series.newData !== null)
|
||||
.map((series) => ({
|
||||
series: series.newData!,
|
||||
title: series.title,
|
||||
color: series.color,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { TimeSeries, seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { renderUnitSymbol, Unit } from "@/control/units";
|
||||
import { GraphConfig, SeriesData, GraphLine } from "./types";
|
||||
|
||||
export type GraphExportData = {
|
||||
config: GraphConfig;
|
||||
data: SeriesData; // Always a single series
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
export function exportGraphsToExcel(
|
||||
graphDataMap: Map<string, () => GraphExportData | null>,
|
||||
groupId: string,
|
||||
): void {
|
||||
try {
|
||||
// Filter out invalid series IDs (those without "-series-")
|
||||
const filteredMap = new Map<string, () => GraphExportData | null>();
|
||||
graphDataMap.forEach((getDataFn, seriesId) => {
|
||||
if (seriesId.includes("-series-")) {
|
||||
filteredMap.set(seriesId, getDataFn);
|
||||
}
|
||||
});
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const exportTimestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")
|
||||
.slice(0, 19);
|
||||
|
||||
const usedSheetNames = new Set<string>(); // Track unique sheet names
|
||||
let processedCount = 0;
|
||||
|
||||
// Process each valid series
|
||||
filteredMap.forEach((getDataFn, seriesId) => {
|
||||
const exportData = getDataFn();
|
||||
if (!exportData?.data?.newData) {
|
||||
console.warn(`No data for series: ${seriesId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const series = exportData.data;
|
||||
const seriesTitle = series.title || `Series ${processedCount + 1}`;
|
||||
|
||||
if (!series.newData) {
|
||||
console.warn(`Series ${seriesTitle} has null data`);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetLines: GraphLine[] = [
|
||||
...(exportData.config.lines || []),
|
||||
...(series.lines || []),
|
||||
];
|
||||
|
||||
const graphLineData = {
|
||||
graphTitle: exportData.config.title,
|
||||
lineTitle: seriesTitle,
|
||||
series: series.newData,
|
||||
color: series.color,
|
||||
unit: exportData.unit,
|
||||
renderValue: exportData.renderValue,
|
||||
config: exportData.config,
|
||||
targetLines: targetLines,
|
||||
};
|
||||
|
||||
// Create and append statistics sheet
|
||||
const statsData = createGraphLineStatsSheet(graphLineData);
|
||||
const statsWorksheet = XLSX.utils.aoa_to_sheet(statsData);
|
||||
const statsSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Stats`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, statsWorksheet, statsSheetName);
|
||||
|
||||
// Create and append data sheet
|
||||
const dataRows = createGraphLineDataSheet(graphLineData);
|
||||
if (dataRows.length > 0) {
|
||||
const dataWorksheet = XLSX.utils.json_to_sheet(dataRows);
|
||||
const dataSheetName = generateUniqueSheetName(
|
||||
`${seriesTitle} Data`,
|
||||
usedSheetNames,
|
||||
);
|
||||
XLSX.utils.book_append_sheet(workbook, dataWorksheet, dataSheetName);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
});
|
||||
|
||||
if (processedCount === 0) {
|
||||
alert("No data available to export from any graphs in this group");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = `${groupId.toLowerCase().replace(/\s+/g, "_")}_export_${exportTimestamp}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
} catch (error) {
|
||||
alert(
|
||||
`Error exporting data to Excel: ${error instanceof Error ? error.message : "Unknown error"}. Please try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate statistics sheet for a graph line
|
||||
function createGraphLineStatsSheet(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) || "";
|
||||
|
||||
const statsData = [
|
||||
[`Graph Line Statistics: ${graphLine.lineTitle}`, ""],
|
||||
["Graph", graphLine.graphTitle],
|
||||
["Line Name", graphLine.lineTitle],
|
||||
["Line Color", graphLine.color || "Default"],
|
||||
["Generated", new Date()],
|
||||
["", ""],
|
||||
["Data Points Information", ""],
|
||||
["Total Data Points", timestamps.length.toString()],
|
||||
];
|
||||
|
||||
if (timestamps.length > 0) {
|
||||
statsData.push(["Time Range Start", new Date(timestamps[0])]);
|
||||
statsData.push([
|
||||
"Time Range End",
|
||||
new Date(timestamps[timestamps.length - 1]),
|
||||
]);
|
||||
|
||||
const duration = timestamps[timestamps.length - 1] - timestamps[0];
|
||||
const durationHours = (duration / (1000 * 60 * 60)).toFixed(2);
|
||||
statsData.push(["Duration (hours)", durationHours]);
|
||||
|
||||
if (values.length > 0) {
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
const avgValue = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const stdDev = Math.sqrt(
|
||||
values.reduce((sum, val) => sum + Math.pow(val - avgValue, 2), 0) /
|
||||
values.length,
|
||||
);
|
||||
|
||||
statsData.push(["", ""], ["Value Statistics", ""]);
|
||||
statsData.push([
|
||||
`Minimum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minValue)
|
||||
: minValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Maximum Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue)
|
||||
: maxValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Average Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(avgValue)
|
||||
: avgValue.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Standard Deviation (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(stdDev)
|
||||
: stdDev.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`Range (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxValue - minValue)
|
||||
: (maxValue - minValue).toFixed(3),
|
||||
]);
|
||||
|
||||
// Percentiles
|
||||
const sortedValues = [...values].sort((a, b) => a - b);
|
||||
const p25 = sortedValues[Math.floor(sortedValues.length * 0.25)];
|
||||
const p50 = sortedValues[Math.floor(sortedValues.length * 0.5)];
|
||||
const p75 = sortedValues[Math.floor(sortedValues.length * 0.75)];
|
||||
|
||||
statsData.push(["", ""], ["Percentiles", ""]);
|
||||
statsData.push([
|
||||
`25th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p25) : p25.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`50th Percentile/Median (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p50) : p50.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
`75th Percentile (${unitSymbol})`,
|
||||
graphLine.renderValue ? graphLine.renderValue(p75) : p75.toFixed(3),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add target line information
|
||||
if (graphLine.targetLines.length > 0) {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
graphLine.targetLines.forEach((line, index) => {
|
||||
statsData.push([
|
||||
`Target Line ${index + 1}`,
|
||||
line.label || `Line ${line.value}`,
|
||||
]);
|
||||
statsData.push([
|
||||
` Value (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(line.value)
|
||||
: line.value.toFixed(3),
|
||||
]);
|
||||
statsData.push([` Type`, line.type || "reference"]);
|
||||
statsData.push([` Color`, line.color || "default"]);
|
||||
statsData.push([` Show`, line.show !== false ? "Yes" : "No"]);
|
||||
|
||||
if (line.type === "threshold" && values.length > 0) {
|
||||
const withinThreshold = values.filter(
|
||||
(val) => Math.abs(val - line.value) <= line.value * 0.05,
|
||||
).length;
|
||||
const percentageWithin = (
|
||||
(withinThreshold / values.length) *
|
||||
100
|
||||
).toFixed(1);
|
||||
|
||||
statsData.push([
|
||||
` Points Within Threshold (5%)`,
|
||||
`${withinThreshold} (${percentageWithin}%)`,
|
||||
]);
|
||||
|
||||
const differences = values.map((val) => Math.abs(val - line.value));
|
||||
const minDifference = Math.min(...differences);
|
||||
const maxDifference = Math.max(...differences);
|
||||
|
||||
statsData.push([
|
||||
` Closest Approach (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(minDifference)
|
||||
: minDifference.toFixed(3),
|
||||
]);
|
||||
statsData.push([
|
||||
` Furthest Distance (${unitSymbol})`,
|
||||
graphLine.renderValue
|
||||
? graphLine.renderValue(maxDifference)
|
||||
: maxDifference.toFixed(3),
|
||||
]);
|
||||
}
|
||||
|
||||
if (index < graphLine.targetLines.length - 1) {
|
||||
statsData.push([""]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
statsData.push(["", ""], ["Target Lines", ""]);
|
||||
statsData.push(["No target lines defined", ""]);
|
||||
}
|
||||
|
||||
return statsData;
|
||||
}
|
||||
|
||||
// Generate data sheet for a graph line
|
||||
function createGraphLineDataSheet(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);
|
||||
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
const unitSymbol = renderUnitSymbol(graphLine.unit) || "";
|
||||
|
||||
return timestamps.map((timestamp, index) => {
|
||||
const value = values[index];
|
||||
return {
|
||||
Timestamp: new Date(timestamp),
|
||||
[`Value (${unitSymbol})`]: graphLine.renderValue
|
||||
? graphLine.renderValue(value)
|
||||
: value?.toFixed(3) || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure sheet names are unique and valid for Excel
|
||||
function generateUniqueSheetName(
|
||||
name: string,
|
||||
usedSheetNames: Set<string>,
|
||||
): string {
|
||||
let baseSheetName = name
|
||||
.replace(/[\\/?*$:[\]]/g, "_") // Remove invalid characters
|
||||
.substring(0, 31); // Excel sheet name limit
|
||||
|
||||
if (!baseSheetName || baseSheetName.trim().length === 0) {
|
||||
baseSheetName = "Sheet";
|
||||
}
|
||||
|
||||
let sheetName = baseSheetName;
|
||||
let counter = 1;
|
||||
|
||||
while (usedSheetNames.has(sheetName)) {
|
||||
const suffix = `_${counter}`;
|
||||
const maxBaseLength = 31 - suffix.length;
|
||||
sheetName = `${baseSheetName.substring(0, maxBaseLength)}${suffix}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
usedSheetNames.add(sheetName);
|
||||
return sheetName;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { RefObject } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { GraphConfig, HandlerRefs } from "./types";
|
||||
|
||||
export interface HandlerCallbacks {
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
setViewMode: (mode: "default" | "all" | "manual") => void;
|
||||
setIsLiveMode: (isLive: boolean) => void;
|
||||
onZoomChange?: (graphId: string, range: { min: number; max: number }) => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
mode: "default" | "all" | "manual",
|
||||
isLive: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function createEventHandlers(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
uplotRef: RefObject<uPlot | null>,
|
||||
handlerRefs: HandlerRefs,
|
||||
callbacks: HandlerCallbacks,
|
||||
newData: any,
|
||||
config: GraphConfig,
|
||||
graphId: string,
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>,
|
||||
width: number,
|
||||
) {
|
||||
// Timeout references for debounced callbacks
|
||||
const callbackTimeouts = {
|
||||
zoom: null as NodeJS.Timeout | null,
|
||||
viewMode: null as NodeJS.Timeout | null,
|
||||
};
|
||||
|
||||
// Debounced callback handlers
|
||||
const debouncedCallbacks = {
|
||||
onZoomChange: (range: { min: number; max: number }) => {
|
||||
if (callbackTimeouts.zoom) clearTimeout(callbackTimeouts.zoom);
|
||||
callbackTimeouts.zoom = setTimeout(() => {
|
||||
callbacks.onZoomChange?.(graphId, range);
|
||||
}, 16); // ~60fps
|
||||
},
|
||||
onViewModeChange: (mode: "default" | "all" | "manual", isLive: boolean) => {
|
||||
if (callbackTimeouts.viewMode) clearTimeout(callbackTimeouts.viewMode);
|
||||
callbackTimeouts.viewMode = setTimeout(() => {
|
||||
callbacks.onViewModeChange?.(graphId, mode, isLive);
|
||||
}, 16);
|
||||
},
|
||||
};
|
||||
|
||||
// Updates the graph's scale and synchronizes it with other graphs
|
||||
const updateScaleAndSync = (newMin: number, newMax: number) => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
uplotRef.current.setScale("x", { min: newMin, max: newMax });
|
||||
callbacks.updateYAxisScale(newMin, newMax);
|
||||
|
||||
manualScaleRef.current = {
|
||||
x: { min: newMin, max: newMax },
|
||||
y: {
|
||||
min: uplotRef.current.scales.y?.min ?? 0,
|
||||
max: uplotRef.current.scales.y?.max ?? 1,
|
||||
},
|
||||
};
|
||||
|
||||
callbacks.setViewMode("manual");
|
||||
callbacks.setIsLiveMode(false);
|
||||
|
||||
debouncedCallbacks.onZoomChange({ min: newMin, max: newMax });
|
||||
debouncedCallbacks.onViewModeChange("manual", false);
|
||||
};
|
||||
|
||||
// Handles touch start events for drag and pinch gestures
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
handlerRefs.touchStartRef.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now(),
|
||||
};
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
handlerRefs.isPinchingRef.current = true;
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
handlerRefs.touchDirectionRef.current = "horizontal";
|
||||
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
handlerRefs.lastPinchDistanceRef.current = distance;
|
||||
|
||||
handlerRefs.pinchCenterRef.current = {
|
||||
x: (touch1.clientX + touch2.clientX) / 2,
|
||||
y: (touch1.clientY + touch2.clientY) / 2,
|
||||
};
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Handles touch move events for drag and pinch gestures
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!handlerRefs.touchStartRef.current) return;
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(
|
||||
touch.clientX - handlerRefs.touchStartRef.current.x,
|
||||
);
|
||||
const deltaY = Math.abs(
|
||||
touch.clientY - handlerRefs.touchStartRef.current.y,
|
||||
);
|
||||
const timeDelta = Date.now() - handlerRefs.touchStartRef.current.time;
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "unknown" &&
|
||||
deltaX > 20 &&
|
||||
deltaY < 10 &&
|
||||
deltaX > deltaY * 4 &&
|
||||
timeDelta < 500
|
||||
) {
|
||||
handlerRefs.touchDirectionRef.current = "horizontal";
|
||||
handlerRefs.isDraggingRef.current = true;
|
||||
handlerRefs.lastDragXRef.current = touch.clientX;
|
||||
e.preventDefault();
|
||||
} else if (handlerRefs.touchDirectionRef.current === "unknown") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "horizontal" &&
|
||||
handlerRefs.isDraggingRef.current
|
||||
) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentX = touch.clientX;
|
||||
const dragDelta = currentX - (handlerRefs.lastDragXRef.current || 0);
|
||||
handlerRefs.lastDragXRef.current = currentX;
|
||||
|
||||
if (uplotRef.current && Math.abs(dragDelta) > 2) {
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
if (xScale && xScale.min !== undefined && xScale.max !== undefined) {
|
||||
const pixelToTime = (xScale.max - xScale.min) / width;
|
||||
const timeDelta = -dragDelta * pixelToTime;
|
||||
|
||||
const newMin = xScale.min + timeDelta;
|
||||
const newMax = xScale.max + timeDelta;
|
||||
|
||||
updateScaleAndSync(newMin, newMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.touches.length === 2 && handlerRefs.isPinchingRef.current) {
|
||||
e.preventDefault();
|
||||
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
|
||||
const newDistance = Math.sqrt(
|
||||
Math.pow(touch2.clientX - touch1.clientX, 2) +
|
||||
Math.pow(touch2.clientY - touch1.clientY, 2),
|
||||
);
|
||||
|
||||
if (handlerRefs.lastPinchDistanceRef.current && uplotRef.current) {
|
||||
const scaleFactor =
|
||||
newDistance / handlerRefs.lastPinchDistanceRef.current;
|
||||
const xScale = uplotRef.current.scales.x;
|
||||
|
||||
if (
|
||||
xScale &&
|
||||
xScale.min !== undefined &&
|
||||
xScale.max !== undefined &&
|
||||
handlerRefs.pinchCenterRef.current
|
||||
) {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const touchXRelative =
|
||||
(handlerRefs.pinchCenterRef.current.x - rect.left) / rect.width;
|
||||
const centerTime =
|
||||
xScale.min + (xScale.max - xScale.min) * touchXRelative;
|
||||
|
||||
const currentRange = xScale.max - xScale.min;
|
||||
const newRange = currentRange / scaleFactor;
|
||||
|
||||
const leftRatio = (centerTime - xScale.min) / currentRange;
|
||||
const rightRatio = (xScale.max - centerTime) / currentRange;
|
||||
|
||||
const newMin = centerTime - newRange * leftRatio;
|
||||
const newMax = centerTime + newRange * rightRatio;
|
||||
updateScaleAndSync(newMin, newMax);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlerRefs.lastPinchDistanceRef.current = newDistance;
|
||||
}
|
||||
};
|
||||
|
||||
// Handles touch end events and resets state
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
setTimeout(() => {
|
||||
if (e.touches.length === 0) {
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
handlerRefs.isPinchingRef.current = false;
|
||||
handlerRefs.lastDragXRef.current = null;
|
||||
handlerRefs.lastPinchDistanceRef.current = null;
|
||||
handlerRefs.pinchCenterRef.current = null;
|
||||
handlerRefs.touchStartRef.current = null;
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
} else if (e.touches.length === 1 && handlerRefs.isPinchingRef.current) {
|
||||
handlerRefs.isPinchingRef.current = false;
|
||||
handlerRefs.lastPinchDistanceRef.current = null;
|
||||
handlerRefs.pinchCenterRef.current = null;
|
||||
|
||||
const touch = e.touches[0];
|
||||
handlerRefs.touchStartRef.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now(),
|
||||
};
|
||||
handlerRefs.touchDirectionRef.current = "unknown";
|
||||
handlerRefs.isDraggingRef.current = false;
|
||||
}
|
||||
|
||||
if (
|
||||
handlerRefs.touchDirectionRef.current === "horizontal" &&
|
||||
handlerRefs.isDraggingRef.current
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
// Handles mouse down events for zooming
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
handlerRefs.isUserZoomingRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Prevents default behavior for wheel events
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Cleanup function to clear timeouts
|
||||
const cleanup = () => {
|
||||
if (callbackTimeouts.zoom) clearTimeout(callbackTimeouts.zoom);
|
||||
if (callbackTimeouts.viewMode) clearTimeout(callbackTimeouts.viewMode);
|
||||
};
|
||||
|
||||
return {
|
||||
handleTouchStart,
|
||||
handleTouchMove,
|
||||
handleTouchEnd,
|
||||
handleMouseDown,
|
||||
handleWheel,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachEventHandlers(
|
||||
containerElement: HTMLDivElement,
|
||||
handlers: ReturnType<typeof createEventHandlers>,
|
||||
) {
|
||||
containerElement.addEventListener("touchstart", handlers.handleTouchStart, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("touchmove", handlers.handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("touchend", handlers.handleTouchEnd, {
|
||||
passive: false,
|
||||
});
|
||||
containerElement.addEventListener("mousedown", handlers.handleMouseDown);
|
||||
containerElement.addEventListener("wheel", handlers.handleWheel, {
|
||||
passive: false,
|
||||
});
|
||||
|
||||
return () => {
|
||||
containerElement.removeEventListener(
|
||||
"touchstart",
|
||||
handlers.handleTouchStart,
|
||||
);
|
||||
containerElement.removeEventListener("touchmove", handlers.handleTouchMove);
|
||||
containerElement.removeEventListener("touchend", handlers.handleTouchEnd);
|
||||
containerElement.removeEventListener("mousedown", handlers.handleMouseDown);
|
||||
containerElement.removeEventListener("wheel", handlers.handleWheel);
|
||||
|
||||
handlers.cleanup();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useRef, useCallback } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { getPrimarySeriesData, stopAnimations } from "./animation";
|
||||
import { BigGraphProps, HistoricalModeHandlers, AnimationRefs } from "./types";
|
||||
|
||||
export function useHistoricalMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
animationRefs,
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
manualScaleRef,
|
||||
syncHistoricalFreezeTimestamp,
|
||||
}: {
|
||||
newData: BigGraphProps["newData"];
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
animationRefs: AnimationRefs;
|
||||
getCurrentLiveEndTimestamp: () => number;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
lastProcessedCountRef: React.RefObject<number>;
|
||||
manualScaleRef: React.RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
syncHistoricalFreezeTimestamp?: number | null;
|
||||
}): HistoricalModeHandlers {
|
||||
const localHistoricalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const localManualScale = useRef(manualScaleRef.current);
|
||||
const isInHistoricalModeRef = useRef(false);
|
||||
|
||||
// Capture the timestamp to freeze the historical view
|
||||
const captureHistoricalFreezeTimestamp = useCallback(() => {
|
||||
const freezeTimestamp =
|
||||
syncHistoricalFreezeTimestamp ?? getCurrentLiveEndTimestamp();
|
||||
localHistoricalFreezeTimestampRef.current = freezeTimestamp;
|
||||
return freezeTimestamp;
|
||||
}, [getCurrentLiveEndTimestamp, syncHistoricalFreezeTimestamp]);
|
||||
|
||||
// Get the end timestamp for the historical view
|
||||
const getHistoricalEndTimestamp = useCallback((): number => {
|
||||
if (
|
||||
syncHistoricalFreezeTimestamp !== null &&
|
||||
syncHistoricalFreezeTimestamp !== undefined
|
||||
) {
|
||||
return syncHistoricalFreezeTimestamp;
|
||||
}
|
||||
if (localHistoricalFreezeTimestampRef.current !== null) {
|
||||
return localHistoricalFreezeTimestampRef.current;
|
||||
}
|
||||
return getCurrentLiveEndTimestamp();
|
||||
}, [getCurrentLiveEndTimestamp, syncHistoricalFreezeTimestamp]);
|
||||
|
||||
// Handle the time window for historical mode
|
||||
const handleHistoricalTimeWindow = useCallback(
|
||||
(timeWindow: number | "all") => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
try {
|
||||
const endTimestamp = getHistoricalEndTimestamp();
|
||||
let startTimestamp: number;
|
||||
|
||||
if (timeWindow === "all") {
|
||||
startTimestamp = Math.min(...timestamps);
|
||||
} else {
|
||||
startTimestamp = endTimestamp - timeWindow;
|
||||
}
|
||||
|
||||
// Calculate Y-axis range for the visible data
|
||||
const visibleValues: number[] = [];
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (
|
||||
timestamps[i] >= startTimestamp &&
|
||||
timestamps[i] <= endTimestamp
|
||||
) {
|
||||
visibleValues.push(values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
let yMin: number, yMax: number;
|
||||
if (visibleValues.length > 0) {
|
||||
yMin = Math.min(...visibleValues);
|
||||
yMax = Math.max(...visibleValues);
|
||||
const range = yMax - yMin || Math.abs(yMax) * 0.1 || 1;
|
||||
yMin -= range * 0.1;
|
||||
yMax += range * 0.1;
|
||||
} else {
|
||||
yMin = -1;
|
||||
yMax = 1;
|
||||
}
|
||||
|
||||
// Update the chart scales
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: startTimestamp,
|
||||
max: endTimestamp,
|
||||
});
|
||||
uplotRef.current!.setScale("y", { min: yMin, max: yMax });
|
||||
});
|
||||
|
||||
// Update manual scale references
|
||||
localManualScale.current = {
|
||||
x: { min: startTimestamp, max: endTimestamp },
|
||||
y: { min: yMin, max: yMax },
|
||||
};
|
||||
manualScaleRef.current = localManualScale.current;
|
||||
} catch (error) {
|
||||
console.warn("Error in handleHistoricalTimeWindow:", error);
|
||||
}
|
||||
},
|
||||
[uplotRef, newData, manualScaleRef, getHistoricalEndTimestamp],
|
||||
);
|
||||
|
||||
// Switch to historical mode
|
||||
const switchToHistoricalMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = true;
|
||||
captureHistoricalFreezeTimestamp();
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
}, [captureHistoricalFreezeTimestamp, animationRefs, lastProcessedCountRef]);
|
||||
|
||||
// Switch back to live mode
|
||||
const switchToLiveMode = useCallback(() => {
|
||||
isInHistoricalModeRef.current = false;
|
||||
localHistoricalFreezeTimestampRef.current = null; // Clear freeze timestamp
|
||||
manualScaleRef.current = null; // Clear manual scale
|
||||
lastProcessedCountRef.current = 0; // Reset processed count
|
||||
stopAnimations(animationRefs);
|
||||
|
||||
if (uplotRef.current) {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (primaryData?.long) {
|
||||
try {
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length > 0) {
|
||||
const currentLiveEnd = getCurrentLiveEndTimestamp();
|
||||
const recentStart = currentLiveEnd - 30 * 60 * 1000; // Last 30 minutes
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: recentStart,
|
||||
max: currentLiveEnd,
|
||||
});
|
||||
updateYAxisScale(recentStart, currentLiveEnd);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error switching to live mode:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
uplotRef,
|
||||
newData,
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
return {
|
||||
captureHistoricalFreezeTimestamp,
|
||||
getHistoricalEndTimestamp,
|
||||
handleHistoricalTimeWindow,
|
||||
switchToHistoricalMode,
|
||||
switchToLiveMode,
|
||||
};
|
||||
}
|
||||
|
||||
export type { HistoricalModeHandlers };
|
||||
@@ -0,0 +1,31 @@
|
||||
// Core components
|
||||
export { BigGraph } from "./BigGraph";
|
||||
export { GraphControls, FloatingControlPanel } from "./GraphControls";
|
||||
|
||||
// Synchronized components
|
||||
export {
|
||||
SyncedBigGraph,
|
||||
SyncedGraphControls,
|
||||
SyncedFloatingControlPanel,
|
||||
AutoSyncedBigGraph,
|
||||
} from "./SyncedComponents";
|
||||
|
||||
// Hooks
|
||||
export { useGraphSync } from "./useGraphSync";
|
||||
|
||||
// Types - make sure these are exported with 'type'
|
||||
export type {
|
||||
PropGraphSync,
|
||||
GraphLine,
|
||||
GraphConfig,
|
||||
BigGraphProps,
|
||||
TimeWindowOption,
|
||||
ControlProps,
|
||||
} from "./types";
|
||||
|
||||
// Constants
|
||||
export { DEFAULT_TIME_WINDOW_OPTIONS, DEFAULT_COLORS } from "./constants";
|
||||
|
||||
// Excel helpers (re-export)
|
||||
export { exportGraphsToExcel } from "./excelExport";
|
||||
export type { GraphExportData } from "./excelExport";
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import {
|
||||
buildUPlotData,
|
||||
animateNewPoint,
|
||||
normalizeDataSeries,
|
||||
getPrimarySeriesData,
|
||||
} from "./animation";
|
||||
import {
|
||||
GraphConfig,
|
||||
BigGraphProps,
|
||||
LiveModeHandlers,
|
||||
AnimationRefs,
|
||||
} from "./types";
|
||||
|
||||
// Retrieve all series data as an array of number arrays
|
||||
function getAllSeriesData(data: BigGraphProps["newData"]): number[][] {
|
||||
const normalized = normalizeDataSeries(data);
|
||||
return normalized
|
||||
.filter((series) => series.newData !== null)
|
||||
.map((series) => {
|
||||
if (!series.newData?.long) return [];
|
||||
const [, values] = seriesToUPlotData(series.newData.long);
|
||||
return values;
|
||||
})
|
||||
.filter((values) => values.length > 0);
|
||||
}
|
||||
|
||||
export function useLiveMode({
|
||||
newData,
|
||||
uplotRef,
|
||||
config,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
lastProcessedCountRef,
|
||||
chartCreatedRef,
|
||||
}: {
|
||||
newData: BigGraphProps["newData"];
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
config: GraphConfig;
|
||||
animationRefs: AnimationRefs;
|
||||
viewMode: "default" | "all" | "manual";
|
||||
selectedTimeWindow: number | "all";
|
||||
startTimeRef: React.RefObject<number | null>;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
lastProcessedCountRef: React.RefObject<number>;
|
||||
chartCreatedRef: React.RefObject<boolean>;
|
||||
}): LiveModeHandlers {
|
||||
const localStartTime = useRef(startTimeRef.current);
|
||||
const localanimationData = useRef(animationRefs.lastRenderedData.current);
|
||||
const localProcessedCount = useRef(lastProcessedCountRef.current);
|
||||
|
||||
// Get the latest timestamp for live mode, considering current and historical data
|
||||
const getCurrentLiveEndTimestamp = useCallback((): number => {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return Date.now();
|
||||
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return Date.now();
|
||||
|
||||
const lastDataTimestamp = timestamps[timestamps.length - 1];
|
||||
const currentTimestamp = primaryData.current?.timestamp;
|
||||
|
||||
return currentTimestamp && currentTimestamp > lastDataTimestamp
|
||||
? currentTimestamp
|
||||
: lastDataTimestamp;
|
||||
}, [newData]);
|
||||
|
||||
// Update the live data on the chart
|
||||
const updateLiveData = useCallback(() => {
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long || !primaryData?.current || !uplotRef.current)
|
||||
return;
|
||||
|
||||
try {
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
const cur = primaryData.current;
|
||||
const liveTimestamps = [...timestamps, cur.timestamp];
|
||||
const liveValues = [...values, cur.value];
|
||||
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
const normalized = normalizeDataSeries(newData);
|
||||
|
||||
// Extend additional series with current or last known values
|
||||
const additionalSeriesValues =
|
||||
normalized.length > 1
|
||||
? allSeriesValues.slice(1).map((seriesValues, index) => {
|
||||
const seriesIndex = index + 1;
|
||||
const seriesData = normalized[seriesIndex]?.newData;
|
||||
const currentValue =
|
||||
seriesData?.current?.value ??
|
||||
(seriesValues.length > 0
|
||||
? seriesValues[seriesValues.length - 1]
|
||||
: 0);
|
||||
return [...seriesValues, currentValue];
|
||||
})
|
||||
: [];
|
||||
|
||||
const liveData = buildUPlotData(
|
||||
liveTimestamps,
|
||||
liveValues,
|
||||
undefined,
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(liveData);
|
||||
|
||||
const latestTimestamp = liveTimestamps[liveTimestamps.length - 1];
|
||||
|
||||
// Adjust x-axis and y-axis scales based on the selected view mode
|
||||
if (viewMode === "default") {
|
||||
let xMin, xMax;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? liveTimestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = latestTimestamp;
|
||||
} else {
|
||||
xMin = latestTimestamp - (selectedTimeWindow as number);
|
||||
xMax = latestTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax);
|
||||
});
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? liveTimestamps[0];
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: fullStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, latestTimestamp);
|
||||
});
|
||||
}
|
||||
|
||||
if (startTimeRef.current === null && liveTimestamps.length > 0) {
|
||||
localStartTime.current = liveTimestamps[0];
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error in updateLiveData:", error);
|
||||
}
|
||||
}, [
|
||||
newData,
|
||||
uplotRef,
|
||||
config,
|
||||
animationRefs,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
// Handle changes to the live time window
|
||||
const handleLiveTimeWindow = useCallback(
|
||||
(timeWindow: number | "all") => {
|
||||
if (!uplotRef.current) return;
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
try {
|
||||
const [timestamps] = seriesToUPlotData(primaryData.long);
|
||||
|
||||
if (timeWindow === "all") {
|
||||
const fullStart =
|
||||
timestamps.length > 0
|
||||
? timestamps[0]
|
||||
: Date.now() - 24 * 60 * 60 * 1000;
|
||||
const fullEnd =
|
||||
timestamps.length > 0
|
||||
? timestamps[timestamps.length - 1]
|
||||
: Date.now();
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: fullStart, max: fullEnd });
|
||||
updateYAxisScale(fullStart, fullEnd);
|
||||
});
|
||||
} else {
|
||||
const latestTimestamp =
|
||||
timestamps.length > 0
|
||||
? timestamps[timestamps.length - 1]
|
||||
: Date.now();
|
||||
const viewStart = latestTimestamp - timeWindow;
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: viewStart,
|
||||
max: latestTimestamp,
|
||||
});
|
||||
updateYAxisScale(viewStart, latestTimestamp);
|
||||
});
|
||||
}
|
||||
|
||||
localanimationData.current = { timestamps, values: [] };
|
||||
localProcessedCount.current = timestamps.length;
|
||||
} catch (error) {
|
||||
console.warn("Error in handleLiveTimeWindow:", error);
|
||||
}
|
||||
},
|
||||
[uplotRef, newData, updateYAxisScale],
|
||||
);
|
||||
|
||||
// Process new historical data and update the chart
|
||||
const processNewHistoricalData = useCallback(() => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!chartCreatedRef.current ||
|
||||
viewMode === "manual"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryData = getPrimarySeriesData(newData);
|
||||
if (!primaryData?.long) return;
|
||||
|
||||
try {
|
||||
const [timestamps, values] = seriesToUPlotData(primaryData.long);
|
||||
if (timestamps.length === 0) return;
|
||||
|
||||
if (timestamps.length <= lastProcessedCountRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentData = animationRefs.lastRenderedData.current;
|
||||
const targetData = { timestamps, values };
|
||||
|
||||
const getAllSeriesDataForAnimation = () => {
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
return allSeriesValues.slice(1);
|
||||
};
|
||||
|
||||
if (targetData.timestamps.length > currentData.timestamps.length) {
|
||||
const maxAnimatableLength = Math.min(
|
||||
targetData.timestamps.length,
|
||||
currentData.timestamps.length + 1,
|
||||
);
|
||||
|
||||
const limitedTargetData = {
|
||||
timestamps: targetData.timestamps.slice(0, maxAnimatableLength),
|
||||
values: targetData.values.slice(0, maxAnimatableLength),
|
||||
};
|
||||
|
||||
animateNewPoint(
|
||||
currentData,
|
||||
limitedTargetData,
|
||||
animationRefs,
|
||||
uplotRef,
|
||||
true,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
getAllSeriesDataForAnimation,
|
||||
);
|
||||
} else if (
|
||||
targetData.timestamps.length === currentData.timestamps.length
|
||||
) {
|
||||
let hasChanges = false;
|
||||
for (let i = 0; i < targetData.values.length; i++) {
|
||||
if (Math.abs(targetData.values[i] - currentData.values[i]) > 0.001) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
const allSeriesValues = getAllSeriesData(newData);
|
||||
const additionalSeriesValues = allSeriesValues.slice(1);
|
||||
|
||||
const uData = buildUPlotData(
|
||||
timestamps,
|
||||
values,
|
||||
undefined,
|
||||
animationRefs.realPointsCount,
|
||||
config,
|
||||
additionalSeriesValues,
|
||||
);
|
||||
|
||||
uplotRef.current.setData(uData);
|
||||
localanimationData.current = { timestamps, values };
|
||||
|
||||
const lastTimestamp = timestamps[timestamps.length - 1] ?? 0;
|
||||
if (viewMode === "default") {
|
||||
let xMin: number | undefined, xMax: number | undefined;
|
||||
|
||||
if (selectedTimeWindow === "all") {
|
||||
const fullStart = startTimeRef.current ?? timestamps[0];
|
||||
xMin = fullStart;
|
||||
xMax = lastTimestamp;
|
||||
} else {
|
||||
xMin = lastTimestamp - (selectedTimeWindow as number);
|
||||
xMax = lastTimestamp;
|
||||
}
|
||||
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", { min: xMin, max: xMax });
|
||||
updateYAxisScale(xMin, xMax);
|
||||
});
|
||||
} else if (viewMode === "all") {
|
||||
const fullStart = startTimeRef.current ?? timestamps[0];
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: fullStart,
|
||||
max: lastTimestamp,
|
||||
});
|
||||
updateYAxisScale(fullStart, lastTimestamp);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error in processNewHistoricalData:", error);
|
||||
}
|
||||
}, [
|
||||
uplotRef,
|
||||
newData,
|
||||
chartCreatedRef,
|
||||
viewMode,
|
||||
animationRefs,
|
||||
lastProcessedCountRef,
|
||||
selectedTimeWindow,
|
||||
startTimeRef,
|
||||
config,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
return {
|
||||
getCurrentLiveEndTimestamp,
|
||||
updateLiveData,
|
||||
handleLiveTimeWindow,
|
||||
processNewHistoricalData,
|
||||
};
|
||||
}
|
||||
export type { LiveModeHandlers };
|
||||
@@ -0,0 +1,166 @@
|
||||
import { IconName } from "@/components/Icon";
|
||||
import { Unit } from "@/control/units";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { RefObject } from "react";
|
||||
|
||||
// Prop-based sync types
|
||||
export type PropGraphSync = {
|
||||
timeWindow: number | "all";
|
||||
viewMode: "default" | "all" | "manual";
|
||||
isLiveMode: boolean;
|
||||
xRange?: { min: number; max: number };
|
||||
historicalFreezeTimestamp?: number | null;
|
||||
onTimeWindowChange?: (graphId: string, timeWindow: number | "all") => void;
|
||||
onViewModeChange?: (
|
||||
graphId: string,
|
||||
viewMode: "default" | "all" | "manual",
|
||||
isLiveMode: boolean,
|
||||
) => void;
|
||||
onZoomChange?: (
|
||||
graphId: string,
|
||||
xRange: { min: number; max: number },
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Configuration types for additional lines
|
||||
export type GraphLine = {
|
||||
type: "threshold" | "target";
|
||||
value: number;
|
||||
color: string;
|
||||
label?: string;
|
||||
width?: number;
|
||||
dash?: number[];
|
||||
show?: boolean;
|
||||
};
|
||||
|
||||
export type GraphConfig = {
|
||||
title: string;
|
||||
icon?: IconName;
|
||||
lines?: GraphLine[];
|
||||
timeWindows?: Array<{ value: number | "all"; label: string }>;
|
||||
defaultTimeWindow?: number | "all";
|
||||
exportFilename?: string;
|
||||
showLegend?: boolean;
|
||||
colors?: {
|
||||
primary?: string;
|
||||
grid?: string;
|
||||
axis?: string;
|
||||
background?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Support for multiple series
|
||||
export type SeriesData = {
|
||||
newData: TimeSeries | null;
|
||||
title?: string;
|
||||
color?: string;
|
||||
lines?: GraphLine[];
|
||||
};
|
||||
|
||||
export type DataSeries = SeriesData | SeriesData[];
|
||||
|
||||
export type BigGraphProps = {
|
||||
newData: DataSeries;
|
||||
unit?: Unit;
|
||||
renderValue?: (value: number) => string;
|
||||
config: GraphConfig;
|
||||
graphId: string;
|
||||
syncGraph?: PropGraphSync;
|
||||
};
|
||||
|
||||
export type TimeWindowOption = {
|
||||
value: number | "all";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type ControlProps = {
|
||||
timeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
onTimeWindowChange: (timeWindow: number | "all") => void;
|
||||
onSwitchToLive: () => void;
|
||||
onSwitchToHistorical: () => void;
|
||||
onExport?: () => void;
|
||||
timeWindowOptions?: TimeWindowOption[];
|
||||
};
|
||||
|
||||
export interface LiveModeHandlers {
|
||||
getCurrentLiveEndTimestamp: () => number;
|
||||
updateLiveData: () => void;
|
||||
handleLiveTimeWindow: (timeWindow: number | "all") => void;
|
||||
processNewHistoricalData: () => void;
|
||||
}
|
||||
export interface HistoricalModeHandlers {
|
||||
captureHistoricalFreezeTimestamp: () => number;
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
handleHistoricalTimeWindow: (timeWindow: number | "all") => void;
|
||||
switchToHistoricalMode: () => void;
|
||||
switchToLiveMode: () => void;
|
||||
}
|
||||
|
||||
export interface CreateChartParams {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
uplotRef: React.RefObject<uPlot | null>;
|
||||
newData: BigGraphProps["newData"];
|
||||
config: BigGraphProps["config"];
|
||||
colors: {
|
||||
primary: string;
|
||||
grid: string;
|
||||
axis: string;
|
||||
background: string;
|
||||
};
|
||||
renderValue?: (value: number) => string;
|
||||
viewMode: "default" | "all" | "manual";
|
||||
selectedTimeWindow: number | "all";
|
||||
isLiveMode: boolean;
|
||||
startTimeRef: React.RefObject<number | null>;
|
||||
manualScaleRef: React.RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
animationRefs: AnimationRefs;
|
||||
handlerRefs: HandlerRefs;
|
||||
graphId: string;
|
||||
syncGraph?: BigGraphProps["syncGraph"];
|
||||
getHistoricalEndTimestamp: () => number;
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
setViewMode: React.Dispatch<
|
||||
React.SetStateAction<"default" | "all" | "manual">
|
||||
>;
|
||||
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
}
|
||||
|
||||
export interface AnimationState {
|
||||
isAnimating: boolean;
|
||||
startTime: number;
|
||||
fromValue: number;
|
||||
toValue: number;
|
||||
fromTimestamp: number;
|
||||
toTimestamp: number;
|
||||
targetIndex: number;
|
||||
}
|
||||
|
||||
export interface AnimationRefs {
|
||||
animationFrame: React.RefObject<number | null>;
|
||||
animationState: React.RefObject<AnimationState>;
|
||||
lastRenderedData: React.RefObject<{
|
||||
timestamps: number[];
|
||||
values: number[];
|
||||
}>;
|
||||
realPointsCount: React.RefObject<number>;
|
||||
}
|
||||
|
||||
export interface HandlerRefs {
|
||||
isUserZoomingRef: RefObject<boolean>;
|
||||
isDraggingRef: RefObject<boolean>;
|
||||
lastDragXRef: RefObject<number | null>;
|
||||
isPinchingRef: RefObject<boolean>;
|
||||
lastPinchDistanceRef: RefObject<number | null>;
|
||||
pinchCenterRef: RefObject<{ x: number; y: number } | null>;
|
||||
touchStartRef: RefObject<{
|
||||
x: number;
|
||||
y: number;
|
||||
time: number;
|
||||
} | null>;
|
||||
touchDirectionRef: RefObject<"horizontal" | "vertical" | "unknown">;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
/* eslint-disable react-compiler/react-compiler */
|
||||
import { useEffect, RefObject, useRef, useState } from "react";
|
||||
import uPlot from "uplot";
|
||||
import { seriesToUPlotData } from "@/lib/timeseries";
|
||||
import { BigGraphProps, SeriesData, AnimationRefs, HandlerRefs } from "./types";
|
||||
import { GraphExportData } from "./excelExport";
|
||||
import { getPrimarySeries, stopAnimations } from "./animation";
|
||||
import { createChart } from "./createChart";
|
||||
import { LiveModeHandlers } from "./liveMode";
|
||||
import { HistoricalModeHandlers } from "./historicalMode";
|
||||
|
||||
interface UseBigGraphEffectsProps {
|
||||
// Refs
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
uplotRef: RefObject<uPlot | null>;
|
||||
startTimeRef: RefObject<number | null>;
|
||||
manualScaleRef: RefObject<{
|
||||
x: { min: number; max: number };
|
||||
y: { min: number; max: number };
|
||||
} | null>;
|
||||
lastProcessedCountRef: RefObject<number>;
|
||||
animationRefs: AnimationRefs;
|
||||
handlerRefs: HandlerRefs;
|
||||
chartCreatedRef: RefObject<boolean>;
|
||||
|
||||
// Props
|
||||
newData: BigGraphProps["newData"];
|
||||
unit: BigGraphProps["unit"];
|
||||
renderValue: BigGraphProps["renderValue"];
|
||||
config: BigGraphProps["config"];
|
||||
graphId: BigGraphProps["graphId"];
|
||||
syncGraph: BigGraphProps["syncGraph"];
|
||||
onRegisterForExport?: (
|
||||
graphId: string,
|
||||
getDataFn: () => GraphExportData | null,
|
||||
) => void;
|
||||
onUnregisterFromExport?: (graphId: string) => void;
|
||||
|
||||
// State
|
||||
viewMode: "default" | "all" | "manual";
|
||||
isLiveMode: boolean;
|
||||
selectedTimeWindow: number | "all";
|
||||
visibleSeries: boolean[];
|
||||
|
||||
// State setters
|
||||
setSelectedTimeWindow: (value: number | "all") => void;
|
||||
setViewMode: React.Dispatch<
|
||||
React.SetStateAction<"default" | "all" | "manual">
|
||||
>;
|
||||
setIsLiveMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setCursorValue: React.Dispatch<React.SetStateAction<number | null>>;
|
||||
setCursorValues: React.Dispatch<React.SetStateAction<(number | null)[]>>;
|
||||
|
||||
// Handlers
|
||||
liveMode: LiveModeHandlers;
|
||||
historicalMode: HistoricalModeHandlers;
|
||||
colors: {
|
||||
primary: string;
|
||||
grid: string;
|
||||
axis: string;
|
||||
background: string;
|
||||
};
|
||||
updateYAxisScale: (xMin?: number, xMax?: number) => void;
|
||||
handleTimeWindowChangeInternal: (
|
||||
newTimeWindow: number | "all",
|
||||
isSync?: boolean,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useBigGraphEffects({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
lastProcessedCountRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
chartCreatedRef,
|
||||
|
||||
newData,
|
||||
unit,
|
||||
renderValue,
|
||||
config,
|
||||
graphId,
|
||||
syncGraph,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
selectedTimeWindow,
|
||||
visibleSeries,
|
||||
|
||||
setSelectedTimeWindow,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
|
||||
liveMode,
|
||||
historicalMode,
|
||||
colors,
|
||||
updateYAxisScale,
|
||||
handleTimeWindowChangeInternal,
|
||||
}: UseBigGraphEffectsProps) {
|
||||
const [isChartCreated, setIsChartCreated] = useState(false);
|
||||
|
||||
// Track the last synchronized state for comparison
|
||||
const lastSyncStateRef = useRef({
|
||||
timeWindow: selectedTimeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange: syncGraph?.xRange,
|
||||
});
|
||||
|
||||
// Get the primary series for compatibility with existing logic
|
||||
const primarySeries = getPrimarySeries(newData);
|
||||
|
||||
// Register the graph for Excel export functionality
|
||||
useEffect(() => {
|
||||
if (onRegisterForExport && primarySeries?.newData) {
|
||||
const getExportData = (): GraphExportData | null => {
|
||||
if (!primarySeries?.newData) return null;
|
||||
|
||||
return {
|
||||
config,
|
||||
data: {
|
||||
newData: primarySeries?.newData ?? null,
|
||||
title: primarySeries?.title,
|
||||
color: primarySeries?.color,
|
||||
lines: primarySeries?.lines,
|
||||
} as SeriesData,
|
||||
unit,
|
||||
renderValue,
|
||||
};
|
||||
};
|
||||
|
||||
onRegisterForExport(graphId, getExportData);
|
||||
|
||||
return () => {
|
||||
onUnregisterFromExport?.(graphId);
|
||||
};
|
||||
}
|
||||
}, [
|
||||
graphId,
|
||||
primarySeries?.newData,
|
||||
config,
|
||||
unit,
|
||||
renderValue,
|
||||
onRegisterForExport,
|
||||
onUnregisterFromExport,
|
||||
]);
|
||||
|
||||
// Synchronize graph state with external sync properties
|
||||
useEffect(() => {
|
||||
if (!syncGraph) return;
|
||||
|
||||
const lastState = lastSyncStateRef.current;
|
||||
let hasChanges = false;
|
||||
|
||||
if (syncGraph.timeWindow !== lastState.timeWindow) {
|
||||
setSelectedTimeWindow(syncGraph.timeWindow);
|
||||
handleTimeWindowChangeInternal(syncGraph.timeWindow, true);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (syncGraph.viewMode !== lastState.viewMode) {
|
||||
setViewMode(syncGraph.viewMode);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (syncGraph.isLiveMode !== lastState.isLiveMode) {
|
||||
const newIsLiveMode = syncGraph.isLiveMode;
|
||||
|
||||
if (lastState.isLiveMode && !newIsLiveMode) {
|
||||
historicalMode.switchToHistoricalMode();
|
||||
} else if (!lastState.isLiveMode && newIsLiveMode) {
|
||||
historicalMode.switchToLiveMode();
|
||||
|
||||
setTimeout(() => {
|
||||
liveMode.processNewHistoricalData?.();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
setIsLiveMode(newIsLiveMode);
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const xRangeChanged =
|
||||
syncGraph.xRange &&
|
||||
(!lastState.xRange ||
|
||||
syncGraph.xRange.min !== lastState.xRange.min ||
|
||||
syncGraph.xRange.max !== lastState.xRange.max);
|
||||
|
||||
if (xRangeChanged && uplotRef.current) {
|
||||
uplotRef.current.batch(() => {
|
||||
uplotRef.current!.setScale("x", {
|
||||
min: syncGraph.xRange?.min ?? 0,
|
||||
max: syncGraph.xRange?.max ?? 0,
|
||||
});
|
||||
|
||||
updateYAxisScale(
|
||||
syncGraph.xRange?.min ?? 0,
|
||||
syncGraph.xRange?.max ?? 0,
|
||||
);
|
||||
});
|
||||
manualScaleRef.current = {
|
||||
x: syncGraph.xRange ?? { min: 0, max: 1 },
|
||||
y: manualScaleRef.current?.y ?? { min: 0, max: 1 },
|
||||
};
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
if (syncGraph.viewMode === "manual") {
|
||||
setViewMode("manual");
|
||||
setIsLiveMode(false);
|
||||
stopAnimations(animationRefs);
|
||||
lastProcessedCountRef.current = 0;
|
||||
}
|
||||
|
||||
lastSyncStateRef.current = {
|
||||
timeWindow: syncGraph.timeWindow,
|
||||
viewMode: syncGraph.viewMode,
|
||||
isLiveMode: syncGraph.isLiveMode,
|
||||
xRange: syncGraph.xRange,
|
||||
};
|
||||
}
|
||||
}, [
|
||||
syncGraph?.timeWindow,
|
||||
syncGraph?.viewMode,
|
||||
syncGraph?.isLiveMode,
|
||||
syncGraph?.xRange?.min,
|
||||
syncGraph?.xRange?.max,
|
||||
historicalMode.switchToHistoricalMode,
|
||||
historicalMode.switchToLiveMode,
|
||||
updateYAxisScale,
|
||||
]);
|
||||
|
||||
// Create and initialize the chart when data becomes available
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !primarySeries?.newData?.long) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const [timestamps] = seriesToUPlotData(primarySeries.newData.long);
|
||||
if (timestamps.length === 0) {
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanup = createChart({
|
||||
containerRef,
|
||||
uplotRef,
|
||||
newData,
|
||||
config,
|
||||
colors,
|
||||
renderValue,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
startTimeRef,
|
||||
manualScaleRef,
|
||||
animationRefs,
|
||||
handlerRefs,
|
||||
graphId,
|
||||
syncGraph,
|
||||
getHistoricalEndTimestamp: historicalMode.getHistoricalEndTimestamp,
|
||||
updateYAxisScale,
|
||||
setViewMode,
|
||||
setIsLiveMode,
|
||||
setCursorValue,
|
||||
setCursorValues,
|
||||
visibleSeries,
|
||||
});
|
||||
|
||||
setIsChartCreated(true);
|
||||
chartCreatedRef.current = true;
|
||||
|
||||
if (isLiveMode) {
|
||||
lastProcessedCountRef.current = timestamps.length;
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanup?.();
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
stopAnimations(animationRefs);
|
||||
setIsChartCreated(false);
|
||||
chartCreatedRef.current = false;
|
||||
};
|
||||
}, [primarySeries?.newData?.long, containerRef.current]);
|
||||
|
||||
// Process new historical data in live mode
|
||||
useEffect(() => {
|
||||
if (!isLiveMode || viewMode === "manual" || !isChartCreated) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
liveMode.processNewHistoricalData();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [
|
||||
primarySeries?.newData?.long?.validCount,
|
||||
primarySeries?.newData?.long?.lastTimestamp,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
isLiveMode,
|
||||
isChartCreated,
|
||||
liveMode.processNewHistoricalData,
|
||||
]);
|
||||
|
||||
// Update chart with real-time data in live mode
|
||||
useEffect(() => {
|
||||
if (
|
||||
!uplotRef.current ||
|
||||
!primarySeries?.newData?.current ||
|
||||
!isLiveMode ||
|
||||
!isChartCreated ||
|
||||
animationRefs.animationState.current.isAnimating ||
|
||||
viewMode === "manual"
|
||||
)
|
||||
return;
|
||||
|
||||
liveMode.updateLiveData();
|
||||
}, [
|
||||
primarySeries?.newData?.current?.timestamp,
|
||||
viewMode,
|
||||
selectedTimeWindow,
|
||||
config.lines,
|
||||
isLiveMode,
|
||||
isChartCreated,
|
||||
liveMode.updateLiveData,
|
||||
]);
|
||||
|
||||
// Recalculate Y-axis scale when series visibility changes
|
||||
useEffect(() => {
|
||||
if (!uplotRef.current || !isChartCreated) return;
|
||||
|
||||
const currentScale = uplotRef.current.scales.x;
|
||||
if (currentScale?.min !== undefined && currentScale?.max !== undefined) {
|
||||
updateYAxisScale(currentScale.min, currentScale.max);
|
||||
} else {
|
||||
updateYAxisScale();
|
||||
}
|
||||
}, [visibleSeries, isChartCreated, updateYAxisScale]);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { PropGraphSync } from "./types";
|
||||
import { GraphExportData, exportGraphsToExcel } from "./excelExport";
|
||||
|
||||
export function useGraphSync(
|
||||
defaultTimeWindow: number | "all" = 30 * 60 * 1000,
|
||||
exportGroupId?: string,
|
||||
) {
|
||||
const [timeWindow, setTimeWindow] = useState<number | "all">(
|
||||
defaultTimeWindow,
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"default" | "all" | "manual">(
|
||||
"default",
|
||||
);
|
||||
const [isLiveMode, setIsLiveMode] = useState(true);
|
||||
const [xRange, setXRange] = useState<
|
||||
{ min: number; max: number } | undefined
|
||||
>();
|
||||
|
||||
const historicalFreezeTimestampRef = useRef<number | null>(null);
|
||||
const graphDataRef = useRef<Map<string, () => GraphExportData | null>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
const syncStateRef = useRef({
|
||||
lastChangeSource: null as string | null,
|
||||
isProcessingChange: false,
|
||||
pendingChanges: new Set<string>(),
|
||||
currentRequestId: 0,
|
||||
});
|
||||
|
||||
// IMPROVED: Separate timeouts for different operations
|
||||
const timeoutRefs = useRef({
|
||||
cleanup: null as NodeJS.Timeout | null,
|
||||
throttledZoom: null as NodeJS.Timeout | null,
|
||||
});
|
||||
|
||||
// IMPROVED: Throttling state using ref instead of function property
|
||||
const throttleStateRef = useRef({
|
||||
lastCall: 0,
|
||||
});
|
||||
|
||||
const clearChangeSource = useCallback(() => {
|
||||
if (timeoutRefs.current.cleanup) {
|
||||
clearTimeout(timeoutRefs.current.cleanup);
|
||||
}
|
||||
timeoutRefs.current.cleanup = setTimeout(() => {
|
||||
syncStateRef.current.lastChangeSource = null;
|
||||
syncStateRef.current.isProcessingChange = false;
|
||||
syncStateRef.current.pendingChanges.clear();
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// IMPROVED: Atomic state updates with request ID tracking
|
||||
const updateSyncState = useCallback(
|
||||
(
|
||||
graphId: string,
|
||||
updates: {
|
||||
timeWindow?: number | "all";
|
||||
viewMode?: "default" | "all" | "manual";
|
||||
isLiveMode?: boolean;
|
||||
xRange?: { min: number; max: number } | undefined;
|
||||
clearHistoricalFreeze?: boolean;
|
||||
setHistoricalFreeze?: boolean;
|
||||
},
|
||||
requestId?: number,
|
||||
) => {
|
||||
// Generate request ID if not provided
|
||||
const currentRequestId =
|
||||
requestId ?? ++syncStateRef.current.currentRequestId;
|
||||
|
||||
// Prevent circular updates and stale requests
|
||||
if (syncStateRef.current.lastChangeSource === graphId) return;
|
||||
if (syncStateRef.current.isProcessingChange && !requestId) {
|
||||
syncStateRef.current.pendingChanges.add(graphId);
|
||||
return;
|
||||
}
|
||||
|
||||
syncStateRef.current.isProcessingChange = true;
|
||||
syncStateRef.current.lastChangeSource = graphId;
|
||||
// Use requestAnimationFrame to ensure state updates happen in next frame
|
||||
requestAnimationFrame(() => {
|
||||
// Check if this request is still valid
|
||||
if (currentRequestId < syncStateRef.current.currentRequestId - 1) {
|
||||
return; // Skip stale request
|
||||
}
|
||||
|
||||
// Batch all state updates together
|
||||
if (updates.timeWindow !== undefined) setTimeWindow(updates.timeWindow);
|
||||
if (updates.viewMode !== undefined) setViewMode(updates.viewMode);
|
||||
if (updates.isLiveMode !== undefined) setIsLiveMode(updates.isLiveMode);
|
||||
if (updates.xRange !== undefined) setXRange(updates.xRange);
|
||||
|
||||
// Handle historical freeze timestamp
|
||||
if (updates.clearHistoricalFreeze) {
|
||||
historicalFreezeTimestampRef.current = null;
|
||||
} else if (updates.setHistoricalFreeze) {
|
||||
historicalFreezeTimestampRef.current = Date.now();
|
||||
}
|
||||
|
||||
clearChangeSource();
|
||||
});
|
||||
},
|
||||
[clearChangeSource],
|
||||
);
|
||||
|
||||
const registerGraphForExport = useCallback(
|
||||
(graphId: string, getDataFn: () => GraphExportData | null) => {
|
||||
graphDataRef.current.set(graphId, getDataFn);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unregisterGraphFromExport = useCallback((graphId: string) => {
|
||||
graphDataRef.current.delete(graphId);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (graphDataRef.current.size === 0) {
|
||||
console.warn("No graphs registered for export");
|
||||
return;
|
||||
}
|
||||
exportGraphsToExcel(graphDataRef.current, exportGroupId || "synced-graphs");
|
||||
}, [exportGroupId]);
|
||||
|
||||
const handleTimeWindowChange = useCallback(
|
||||
(graphId: string, newTimeWindow: number | "all") => {
|
||||
updateSyncState(graphId, {
|
||||
timeWindow: newTimeWindow,
|
||||
viewMode: newTimeWindow === "all" ? "all" : "default",
|
||||
isLiveMode: newTimeWindow === "all" ? true : isLiveMode,
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: newTimeWindow === "all",
|
||||
});
|
||||
},
|
||||
[updateSyncState, isLiveMode],
|
||||
);
|
||||
|
||||
const handleViewModeChange = useCallback(
|
||||
(
|
||||
graphId: string,
|
||||
newViewMode: "default" | "all" | "manual",
|
||||
newIsLiveMode: boolean,
|
||||
) => {
|
||||
updateSyncState(graphId, {
|
||||
viewMode: newViewMode,
|
||||
isLiveMode: newIsLiveMode,
|
||||
clearHistoricalFreeze: newIsLiveMode,
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(graphId: string, newXRange: { min: number; max: number }) => {
|
||||
updateSyncState(graphId, {
|
||||
xRange: newXRange,
|
||||
viewMode: "manual",
|
||||
isLiveMode: false,
|
||||
setHistoricalFreeze: historicalFreezeTimestampRef.current === null,
|
||||
});
|
||||
},
|
||||
[updateSyncState],
|
||||
);
|
||||
|
||||
// IMPROVED: Better throttling with immediate first call using ref
|
||||
const handleZoomChangeThrottled = useCallback(
|
||||
(graphId: string, newXRange: { min: number; max: number }) => {
|
||||
// Clear existing throttled call
|
||||
if (timeoutRefs.current.throttledZoom) {
|
||||
clearTimeout(timeoutRefs.current.throttledZoom);
|
||||
}
|
||||
|
||||
// For the first call or if enough time has passed, execute immediately
|
||||
const now = Date.now();
|
||||
const lastCall = throttleStateRef.current.lastCall;
|
||||
|
||||
if (now - lastCall > 100) {
|
||||
handleZoomChange(graphId, newXRange);
|
||||
throttleStateRef.current.lastCall = now;
|
||||
} else {
|
||||
// Throttle subsequent calls
|
||||
timeoutRefs.current.throttledZoom = setTimeout(() => {
|
||||
handleZoomChange(graphId, newXRange);
|
||||
throttleStateRef.current.lastCall = Date.now();
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
[handleZoomChange],
|
||||
);
|
||||
|
||||
const handleSwitchToLive = useCallback(() => {
|
||||
updateSyncState("control", {
|
||||
isLiveMode: true,
|
||||
viewMode: timeWindow === "all" ? "all" : "default",
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: true,
|
||||
});
|
||||
}, [updateSyncState, timeWindow]);
|
||||
|
||||
const handleSwitchToHistorical = useCallback(() => {
|
||||
updateSyncState("control", {
|
||||
isLiveMode: false,
|
||||
viewMode: "manual",
|
||||
setHistoricalFreeze: true,
|
||||
});
|
||||
}, [updateSyncState]);
|
||||
|
||||
const handleControlTimeWindowChange = useCallback(
|
||||
(newTimeWindow: number | "all") => {
|
||||
updateSyncState("control", {
|
||||
timeWindow: newTimeWindow,
|
||||
viewMode: newTimeWindow === "all" ? "all" : "default",
|
||||
isLiveMode: newTimeWindow === "all" ? true : isLiveMode,
|
||||
xRange: undefined,
|
||||
clearHistoricalFreeze: newTimeWindow === "all",
|
||||
});
|
||||
},
|
||||
[updateSyncState, isLiveMode],
|
||||
);
|
||||
|
||||
const syncGraph: PropGraphSync = {
|
||||
timeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
onTimeWindowChange: handleTimeWindowChange,
|
||||
onViewModeChange: handleViewModeChange,
|
||||
onZoomChange: handleZoomChangeThrottled,
|
||||
};
|
||||
|
||||
const controlProps = {
|
||||
timeWindow,
|
||||
isLiveMode,
|
||||
onTimeWindowChange: handleControlTimeWindowChange,
|
||||
onSwitchToLive: handleSwitchToLive,
|
||||
onSwitchToHistorical: handleSwitchToHistorical,
|
||||
onExport: handleExport,
|
||||
};
|
||||
|
||||
return {
|
||||
syncGraph,
|
||||
controlProps,
|
||||
registerGraphForExport,
|
||||
unregisterGraphFromExport,
|
||||
handleExport,
|
||||
timeWindow,
|
||||
viewMode,
|
||||
isLiveMode,
|
||||
xRange,
|
||||
historicalFreezeTimestamp: historicalFreezeTimestampRef.current,
|
||||
};
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export function TouchSlider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="block size-14 shrink-0 rounded-2xl bg-black shadow-sm ring-gray-950/50 transition-[color,box-shadow] hover:ring-8 focus-visible:ring-8 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:border-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-gray-300/50"
|
||||
className="block size-14 shrink-0 rounded-2xl bg-black shadow-sm ring-gray-950/50 transition-[color,box-shadow] focus-visible:ring-8 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 dark:border-gray-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-gray-300/50"
|
||||
>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Icon name="lu:Pointer" className="text-white" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
@@ -16,8 +16,8 @@ const alertVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
@@ -31,7 +31,7 @@ function Alert({
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
@@ -56,11 +56,11 @@ function AlertDescription({
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border border-neutral-200 px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-neutral-950 focus-visible:ring-neutral-950/50 focus-visible:ring-[3px] aria-invalid:ring-red-500/20 dark:aria-invalid:ring-red-500/40 aria-invalid:border-red-500 transition-[color,box-shadow] overflow-hidden dark:border-neutral-800 dark:focus-visible:border-neutral-300 dark:focus-visible:ring-neutral-300/50 dark:aria-invalid:ring-red-900/20 dark:dark:aria-invalid:ring-red-900/40 dark:aria-invalid:border-red-900",
|
||||
@@ -10,20 +10,19 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-neutral-900 text-neutral-50 [a&]:hover:bg-neutral-900/90 dark:bg-neutral-50 dark:text-neutral-900 dark:[a&]:hover:bg-neutral-50/90",
|
||||
"border-transparent bg-neutral-900 text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900",
|
||||
secondary:
|
||||
"border-transparent bg-neutral-100 text-neutral-900 [a&]:hover:bg-neutral-100/90 dark:bg-neutral-800 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800/90",
|
||||
"border-transparent bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50",
|
||||
destructive:
|
||||
"border-transparent bg-red-500 text-white [a&]:hover:bg-red-500/90 focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/70 dark:bg-red-900 dark:[a&]:hover:bg-red-900/90 dark:focus-visible:ring-red-900/20 dark:dark:focus-visible:ring-red-900/40 dark:dark:bg-red-900/70",
|
||||
outline:
|
||||
"text-neutral-950 [a&]:hover:bg-neutral-100 [a&]:hover:text-neutral-900 dark:text-neutral-50 dark:[a&]:hover:bg-neutral-800 dark:[a&]:hover:text-neutral-50",
|
||||
"border-transparent bg-red-500 text-white focus-visible:ring-red-500/20 dark:focus-visible:ring-red-500/40 dark:bg-red-500/70 dark:bg-red-900 dark:focus-visible:ring-red-900/20 dark:dark:focus-visible:ring-red-900/40 dark:dark:bg-red-900/70",
|
||||
outline: "text-neutral-950 dark:text-neutral-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -32,7 +31,7 @@ function Badge({
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -40,7 +39,7 @@ function Badge({
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,24 +1,21 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground shadow-xs",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"bg-destructive text-white shadow-xs focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
outline: "border border-input bg-background shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs",
|
||||
ghost: "",
|
||||
link: "text-primary underline-offset-4",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
@@ -31,8 +28,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -42,9 +39,9 @@ function Button({
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
@@ -52,7 +49,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
@@ -14,7 +14,7 @@ function CollapsibleTrigger({
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
@@ -25,7 +25,7 @@ function CollapsibleContent({
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -1,31 +1,31 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@@ -37,11 +37,11 @@ function DialogOverlay({
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -56,18 +56,18 @@ function DialogContent({
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -77,7 +77,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -86,11 +86,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@@ -103,7 +103,7 @@ function DialogTitle({
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@@ -116,7 +116,7 @@ function DialogDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -130,4 +130,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -40,13 +40,13 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-white text-neutral-950 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-neutral-200 p-1 shadow-md dark:bg-neutral-950 dark:text-neutral-50 dark:border-neutral-800",
|
||||
className
|
||||
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-md dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -63,8 +63,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -72,12 +72,12 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-neutral-100 focus:text-neutral-900 data-[variant=destructive]:text-red-500 data-[variant=destructive]:focus:bg-red-500/10 dark:data-[variant=destructive]:focus:bg-red-500/20 data-[variant=destructive]:focus:text-red-500 data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-neutral-500 relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 dark:data-[variant=destructive]:text-red-900 dark:data-[variant=destructive]:focus:bg-red-900/10 dark:dark:data-[variant=destructive]:focus:bg-red-900/20 dark:data-[variant=destructive]:focus:text-red-900 dark:[&_svg:not([class*='text-'])]:text-neutral-400",
|
||||
className
|
||||
"data-[variant=destructive]:*:[svg]:!text-destructive relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-red-500 data-[variant=destructive]:focus:bg-red-500/10 data-[variant=destructive]:focus:text-red-500 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 dark:data-[variant=destructive]:text-red-900 dark:dark:data-[variant=destructive]:focus:bg-red-900/20 dark:data-[variant=destructive]:focus:bg-red-500/20 dark:data-[variant=destructive]:focus:bg-red-900/10 dark:data-[variant=destructive]:focus:text-red-900 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-neutral-500 dark:[&_svg:not([class*='text-'])]:text-neutral-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -90,8 +90,8 @@ function DropdownMenuCheckboxItem({
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-neutral-100 focus:text-neutral-900 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
className
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -126,8 +126,8 @@ function DropdownMenuRadioItem({
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-neutral-100 focus:text-neutral-900 relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
className
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -168,10 +168,13 @@ function DropdownMenuSeparator({
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-neutral-200 -mx-1 my-1 h-px dark:bg-neutral-800", className)}
|
||||
className={cn(
|
||||
"-mx-1 my-1 h-px bg-neutral-200 dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@@ -182,18 +185,18 @@ function DropdownMenuShortcut({
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-neutral-500 ml-auto text-xs tracking-widest dark:text-neutral-400",
|
||||
className
|
||||
"ml-auto text-xs tracking-widest text-neutral-500 dark:text-neutral-400",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -202,22 +205,22 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-neutral-100 focus:text-neutral-900 data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-900 flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-50",
|
||||
className
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-neutral-100 focus:text-neutral-900 data-[inset]:pl-8 data-[state=open]:bg-neutral-100 data-[state=open]:text-neutral-900 dark:focus:bg-neutral-800 dark:focus:text-neutral-50 dark:data-[state=open]:bg-neutral-800 dark:data-[state=open]:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -228,12 +231,12 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-white text-neutral-950 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-neutral-200 p-1 shadow-lg dark:bg-neutral-950 dark:text-neutral-50 dark:border-neutral-800",
|
||||
className
|
||||
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-lg dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -252,4 +255,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
@@ -9,23 +9,23 @@ import {
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
} from "react-hook-form"
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const Form = FormProvider
|
||||
const Form = FormProvider;
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
{} as FormFieldContextValue,
|
||||
);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
@@ -37,21 +37,21 @@ const FormField = <
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState } = useFormContext();
|
||||
const formState = useFormState({ name: fieldContext.name });
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -60,19 +60,19 @@ const useFormField = () => {
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
{} as FormItemContextValue,
|
||||
);
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return (
|
||||
<Label
|
||||
@@ -99,11 +99,12 @@ function FormLabel({
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||
useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : props.children
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : props.children;
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -162,4 +163,4 @@ export {
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({
|
||||
className,
|
||||
@@ -14,11 +14,11 @@ function Label({
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils/tailwind"
|
||||
import { cn } from "@/utils/tailwind";
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
||||
@@ -13,15 +13,15 @@ const NavigationMenu = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
))
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
||||
));
|
||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName;
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
||||
@@ -31,18 +31,18 @@ const NavigationMenuList = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
||||
));
|
||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50"
|
||||
)
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-active:bg-accent/50 data-[state=open]:bg-accent/50",
|
||||
);
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
||||
@@ -59,8 +59,8 @@ const NavigationMenuTrigger = React.forwardRef<
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
))
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
||||
));
|
||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName;
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
||||
@@ -69,33 +69,33 @@ const NavigationMenuContent = React.forwardRef<
|
||||
<NavigationMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
||||
className
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full md:absolute md:w-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
||||
));
|
||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName;
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
const NavigationMenuViewport = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
||||
<div className={cn("absolute top-full left-0 flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow-lg md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
));
|
||||
NavigationMenuViewport.displayName =
|
||||
NavigationMenuPrimitive.Viewport.displayName
|
||||
NavigationMenuPrimitive.Viewport.displayName;
|
||||
|
||||
const NavigationMenuIndicator = React.forwardRef<
|
||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
||||
@@ -104,16 +104,16 @@ const NavigationMenuIndicator = React.forwardRef<
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-1 flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
))
|
||||
));
|
||||
NavigationMenuIndicator.displayName =
|
||||
NavigationMenuPrimitive.Indicator.displayName
|
||||
NavigationMenuPrimitive.Indicator.displayName;
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
@@ -125,4 +125,4 @@ export {
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
};
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -28,19 +28,19 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-white text-neutral-950 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border border-neutral-200 p-4 shadow-md outline-hidden dark:bg-neutral-950 dark:text-neutral-50 dark:border-neutral-800",
|
||||
className
|
||||
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-hidden dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -1,25 +1,25 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -32,7 +32,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -41,7 +41,7 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
@@ -58,7 +58,7 @@ function SelectContent({
|
||||
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
@@ -68,7 +68,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -76,7 +76,7 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
@@ -89,7 +89,7 @@ function SelectLabel({
|
||||
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
@@ -102,7 +102,7 @@ function SelectItem({
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -113,7 +113,7 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
@@ -126,7 +126,7 @@ function SelectSeparator({
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -138,13 +138,13 @@ function SelectScrollUpButton({
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -156,13 +156,13 @@ function SelectScrollDownButton({
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -176,4 +176,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
@@ -16,11 +16,11 @@ function Separator({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
@@ -1,13 +1,16 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-neutral-100 animate-pulse rounded-md dark:bg-neutral-800", className)}
|
||||
className={cn(
|
||||
"animate-pulse rounded-md bg-neutral-100 dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
@@ -12,7 +12,8 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-white group-[.toaster]:text-neutral-950 group-[.toaster]:border-neutral-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-neutral-950 dark:group-[.toaster]:text-neutral-50 dark:group-[.toaster]:border-neutral-800",
|
||||
description: "group-[.toast]:text-neutral-500 dark:group-[.toast]:text-neutral-400",
|
||||
description:
|
||||
"group-[.toast]:text-neutral-500 dark:group-[.toast]:text-neutral-400",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-neutral-900 group-[.toast]:text-neutral-50 font-medium dark:group-[.toast]:bg-neutral-50 dark:group-[.toast]:text-neutral-900",
|
||||
cancelButton:
|
||||
@@ -21,7 +22,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
@@ -13,16 +13,16 @@ const Table = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
@@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
@@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
"data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
"text-muted-foreground h-10 px-2 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
@@ -89,12 +89,12 @@ const TableCell = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
@@ -102,11 +102,11 @@ const TableCaption = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
@@ -117,4 +117,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
@@ -5,13 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/tailwind";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background cursor-pointer transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background cursor-pointer transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
outline: "border border-input bg-transparent",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3",
|
||||
|
||||
@@ -8,13 +8,13 @@ type Props = {
|
||||
|
||||
export function ControlGrid({ children, columns = 3 }: Props) {
|
||||
return (
|
||||
<div id="grid" className={controlGrifStyle({ columns })}>
|
||||
<div id="grid" className={controlGridStyle({ columns })}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const controlGrifStyle = cva(
|
||||
const controlGridStyle = cva(
|
||||
"grid w-full auto-cols-fr grid-cols-1 gap-6 lg:grid-cols-2",
|
||||
{
|
||||
variants: {
|
||||
|
||||
@@ -28,7 +28,7 @@ type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: IconName;
|
||||
defaultValue: number;
|
||||
defaultValue?: number;
|
||||
min?: number;
|
||||
minSlider?: number; // Override the slider min value
|
||||
minLabel?: string;
|
||||
@@ -38,6 +38,7 @@ type Props = {
|
||||
step?: number;
|
||||
valueSchema?: z.ZodType<number>;
|
||||
inverted?: boolean;
|
||||
confirmation?: string;
|
||||
renderValue: (value: number) => string;
|
||||
onChange?: (value: number) => void;
|
||||
};
|
||||
@@ -96,8 +97,11 @@ export function EditValue({
|
||||
maxLabel,
|
||||
minSlider,
|
||||
maxSlider,
|
||||
confirmation,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const defaultOrZero = defaultValue ?? 0;
|
||||
|
||||
// Form setup
|
||||
const formSchema = z.object({
|
||||
value: schema ?? z.number(),
|
||||
@@ -106,7 +110,7 @@ export function EditValue({
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver<FormSchema>(formSchema),
|
||||
values: { value: value ?? defaultValue },
|
||||
values: { value: value ?? defaultOrZero },
|
||||
defaultValues: { value: defaultValue },
|
||||
mode: "all",
|
||||
});
|
||||
@@ -250,10 +254,20 @@ export function EditValue({
|
||||
|
||||
const handleSubmit = () => {
|
||||
form.handleSubmit((data) => {
|
||||
onChange?.(data.value);
|
||||
setOpen(false);
|
||||
setNumpadExtended(false);
|
||||
resetInput();
|
||||
if (confirmation) {
|
||||
if (window.confirm(confirmation)) {
|
||||
onChange?.(data.value);
|
||||
setOpen(false);
|
||||
setNumpadExtended(false);
|
||||
resetInput();
|
||||
}
|
||||
// else do nothing if user cancels
|
||||
} else {
|
||||
onChange?.(data.value);
|
||||
setOpen(false);
|
||||
setNumpadExtended(false);
|
||||
resetInput();
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
@@ -412,26 +426,29 @@ export function EditValue({
|
||||
// Continuous increment/decrement functionality
|
||||
const intervalRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const startContinuousChange = React.useCallback((increment: boolean) => {
|
||||
const performChange = () => {
|
||||
const currentValue = form.getValues().value;
|
||||
const newValue = increment
|
||||
? (max !== undefined
|
||||
? Math.min(max, roundToStepDecimals(currentValue + step))
|
||||
: roundToStepDecimals(currentValue + step))
|
||||
: (min !== undefined
|
||||
? Math.max(min, roundToStepDecimals(currentValue - step))
|
||||
: roundToStepDecimals(currentValue - step));
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
// Initial delay before starting continuous changes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
// Start continuous changes at regular intervals
|
||||
intervalRef.current = setInterval(performChange, 100); // Change every 100ms
|
||||
}, 500); // Wait 500ms before starting continuous changes
|
||||
}, [step, min, max, roundToStepDecimals, form]);
|
||||
const startContinuousChange = React.useCallback(
|
||||
(increment: boolean) => {
|
||||
const performChange = () => {
|
||||
const currentValue = form.getValues().value;
|
||||
const newValue = increment
|
||||
? max !== undefined
|
||||
? Math.min(max, roundToStepDecimals(currentValue + step))
|
||||
: roundToStepDecimals(currentValue + step)
|
||||
: min !== undefined
|
||||
? Math.max(min, roundToStepDecimals(currentValue - step))
|
||||
: roundToStepDecimals(currentValue - step);
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
// Initial delay before starting continuous changes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
// Start continuous changes at regular intervals
|
||||
intervalRef.current = setInterval(performChange, 100); // Change every 100ms
|
||||
}, 500); // Wait 500ms before starting continuous changes
|
||||
},
|
||||
[step, min, max, roundToStepDecimals, form],
|
||||
);
|
||||
|
||||
const stopContinuousChange = React.useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./units";
|
||||
import { Label } from "./Label";
|
||||
import { TimeSeries } from "@/lib/timeseries";
|
||||
import { MiniGraph } from "@/helpers/MiniGraph";
|
||||
import { MiniGraph } from "@/components/graph/MiniGraph";
|
||||
import {
|
||||
useContainerDimensions,
|
||||
useMaxContainerMaxDimension,
|
||||
|
||||
@@ -9,6 +9,8 @@ export function getUnitIcon(unit: Unit): IconName {
|
||||
return "lu:Gauge";
|
||||
case "mm":
|
||||
return "lu:Ruler";
|
||||
case "cm":
|
||||
return "lu:Ruler";
|
||||
case "m":
|
||||
return "lu:Ruler";
|
||||
case "rpm":
|
||||
@@ -36,6 +38,8 @@ export function renderUnitSymbol(unit: Unit | undefined): string {
|
||||
return "m/s";
|
||||
case "mm":
|
||||
return "mm";
|
||||
case "cm":
|
||||
return "cm";
|
||||
case "m":
|
||||
return "m";
|
||||
case "rpm":
|
||||
@@ -85,6 +89,8 @@ export function renderUnitSymbolLong(unit: Unit): string {
|
||||
return "meters/second";
|
||||
case "mm":
|
||||
return "millimeters";
|
||||
case "cm":
|
||||
return "centimeters";
|
||||
case "m":
|
||||
return "meters";
|
||||
case "rpm":
|
||||
@@ -107,6 +113,7 @@ export function renderUnitSymbolLong(unit: Unit): string {
|
||||
export const units = [
|
||||
"m/s",
|
||||
"mm",
|
||||
"cm",
|
||||
"rpm",
|
||||
"deg",
|
||||
"m",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,210 +0,0 @@
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import { getSeriesMinMax, seriesToUPlotData, TimeSeries } from "@/lib/timeseries";
|
||||
|
||||
type MiniGraphProps = {
|
||||
newData: TimeSeries | null;
|
||||
width: number;
|
||||
renderValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
const HEIGHT = 64;
|
||||
|
||||
export function MiniGraph({ newData, width, renderValue }: MiniGraphProps) {
|
||||
const divRef = useRef<HTMLDivElement | null>(null);
|
||||
const uplotRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Performance tracking refs
|
||||
const lastUpdateTimestamp = useRef<number>(0);
|
||||
const lastDataHash = useRef<string>("");
|
||||
const lastMinMax = useRef<{ min: number; max: number }>({ min: 0, max: 0 });
|
||||
const rafId = useRef<number>(0);
|
||||
const isInitialized = useRef<boolean>(false);
|
||||
const pendingUpdate = useRef<boolean>(false);
|
||||
|
||||
// Fast hash function for data change detection
|
||||
const hashData = useCallback((timestamps: number[], values: number[]): string => {
|
||||
if (timestamps.length === 0) return "";
|
||||
// Hash only first, last, and length for performance
|
||||
return `${timestamps[0]}-${timestamps[timestamps.length - 1]}-${timestamps.length}-${values[values.length - 1]}`;
|
||||
}, []);
|
||||
|
||||
// Memoize the tick formatter to avoid recreating on every render
|
||||
const tickFormatter = useMemo(() => {
|
||||
return renderValue ?
|
||||
(u: uPlot, ticks: number[]) => ticks.map(v => renderValue(v)) :
|
||||
(u: uPlot, ticks: number[]) => ticks.map(v => v.toFixed(1));
|
||||
}, [renderValue]);
|
||||
|
||||
// Ultra-efficient update function
|
||||
const updateChart = useCallback(() => {
|
||||
if (!uplotRef.current || !newData?.short || !newData?.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cur = newData.current;
|
||||
|
||||
// Skip if no new data
|
||||
if (cur.timestamp <= lastUpdateTimestamp.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get data
|
||||
const [timestamps, values] = seriesToUPlotData(short);
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if data actually changed using hash
|
||||
const dataHash = hashData(timestamps, values);
|
||||
if (dataHash === lastDataHash.current) {
|
||||
pendingUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get min/max and check if scales need updating
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const scalesChanged = minY !== lastMinMax.current.min || maxY !== lastMinMax.current.max;
|
||||
|
||||
// Update tracking vars
|
||||
lastUpdateTimestamp.current = cur.timestamp;
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const range = maxY - minY || 1;
|
||||
const cutoff = cur.timestamp - timeWindow;
|
||||
|
||||
// Batch all updates to minimize redraws
|
||||
uplotRef.current.batch(() => {
|
||||
// Always update data and x-scale (time moves forward)
|
||||
uplotRef.current!.setData([timestamps, values]);
|
||||
uplotRef.current!.setScale("x", { min: cutoff, max: cur.timestamp });
|
||||
|
||||
// Only update y-scale if min/max changed
|
||||
if (scalesChanged) {
|
||||
uplotRef.current!.setScale("y", {
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
pendingUpdate.current = false;
|
||||
}, [newData, hashData]);
|
||||
|
||||
// RAF-throttled update scheduler
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
if (pendingUpdate.current || !isInitialized.current) return;
|
||||
|
||||
pendingUpdate.current = true;
|
||||
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
|
||||
rafId.current = requestAnimationFrame(updateChart);
|
||||
}, [updateChart]);
|
||||
|
||||
// Initialize chart only once
|
||||
useEffect(() => {
|
||||
if (!divRef.current || !newData?.short?.timeWindow || isInitialized.current) return;
|
||||
|
||||
const short = newData.short;
|
||||
const timeWindow = short.timeWindow;
|
||||
|
||||
// Get initial data
|
||||
const [allTimestamps, allValues] = seriesToUPlotData(short);
|
||||
const { min: minY, max: maxY } = getSeriesMinMax(short);
|
||||
const range = maxY - minY || 1;
|
||||
|
||||
// Initialize tracking
|
||||
const dataHash = hashData(allTimestamps, allValues);
|
||||
lastDataHash.current = dataHash;
|
||||
lastMinMax.current = { min: minY, max: maxY };
|
||||
|
||||
const now = Date.now();
|
||||
const latestTimestamp = allTimestamps.length > 0 ? allTimestamps[allTimestamps.length - 1] : now;
|
||||
const cutoff = latestTimestamp - timeWindow;
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width,
|
||||
height: HEIGHT,
|
||||
padding: [4, 0, 4, 0],
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
scales: {
|
||||
x: {
|
||||
time: true,
|
||||
min: cutoff,
|
||||
max: latestTimestamp,
|
||||
},
|
||||
y: {
|
||||
auto: false,
|
||||
min: minY - range * 0.1,
|
||||
max: maxY + range * 0.1,
|
||||
},
|
||||
},
|
||||
axes: [
|
||||
{ show: false },
|
||||
{
|
||||
side: 1,
|
||||
grid: { stroke: "#ccc", width: 0.5 },
|
||||
ticks: { stroke: "#ccc", width: 0.5 },
|
||||
values: tickFormatter, // Use the memoized formatter
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: "black",
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: { show: false }, // Hide the dots/points
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
uplotRef.current = new uPlot(opts, [allTimestamps, allValues], divRef.current);
|
||||
isInitialized.current = true;
|
||||
|
||||
return () => {
|
||||
if (rafId.current) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
uplotRef.current?.destroy();
|
||||
uplotRef.current = null;
|
||||
isInitialized.current = false;
|
||||
pendingUpdate.current = false;
|
||||
};
|
||||
}, [width, newData?.short?.timeWindow, hashData, tickFormatter]);
|
||||
|
||||
// Trigger updates only when timestamp changes
|
||||
useEffect(() => {
|
||||
scheduleUpdate();
|
||||
}, [newData?.current?.timestamp, scheduleUpdate]);
|
||||
|
||||
// Efficient width handling
|
||||
useEffect(() => {
|
||||
if (!uplotRef.current || !isInitialized.current) return;
|
||||
uplotRef.current.setSize({ width, height: HEIGHT });
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: HEIGHT,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export const UPDATE_EXECUTE = "update:execute";
|
||||
export const UPDATE_LOG = "update:log";
|
||||
export const UPDATE_END = "update:end";
|
||||
export const UPDATE_CANCEL = "update:cancel";
|
||||
@@ -1,4 +1,9 @@
|
||||
import { UPDATE_END, UPDATE_EXECUTE, UPDATE_LOG } from "./update-channels";
|
||||
import {
|
||||
UPDATE_CANCEL,
|
||||
UPDATE_END,
|
||||
UPDATE_EXECUTE,
|
||||
UPDATE_LOG,
|
||||
} from "./update-channels";
|
||||
|
||||
type UpdateExecuteInvokeParams = {
|
||||
source: {
|
||||
@@ -16,6 +21,7 @@ export function exposeUpdateContext() {
|
||||
contextBridge.exposeInMainWorld("update", {
|
||||
execute: (params: UpdateExecuteInvokeParams) =>
|
||||
ipcRenderer.invoke(UPDATE_EXECUTE, params),
|
||||
cancel: () => ipcRenderer.invoke(UPDATE_CANCEL),
|
||||
onLog: (callback: (log: string) => void) =>
|
||||
ipcRenderer.on(UPDATE_LOG, (_event, log: string) => {
|
||||
callback(log);
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { UPDATE_END, UPDATE_EXECUTE, UPDATE_LOG } from "./update-channels";
|
||||
import { spawn } from "child_process";
|
||||
import {
|
||||
UPDATE_CANCEL,
|
||||
UPDATE_END,
|
||||
UPDATE_EXECUTE,
|
||||
UPDATE_LOG,
|
||||
} from "./update-channels";
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import tkill from "@jub3i/tree-kill";
|
||||
import { existsSync, rmSync } from "fs";
|
||||
|
||||
type UpdateExecuteListenerParams = {
|
||||
githubRepoOwner: string;
|
||||
@@ -11,18 +18,23 @@ type UpdateExecuteListenerParams = {
|
||||
commit?: string;
|
||||
};
|
||||
|
||||
// Store reference to current update process for cancellation
|
||||
let currentUpdateProcess: ChildProcess | null = null;
|
||||
|
||||
export function addUpdateEventListeners() {
|
||||
ipcMain.handle(
|
||||
UPDATE_EXECUTE,
|
||||
async (event, params: UpdateExecuteListenerParams) => {
|
||||
update(event, params)
|
||||
.then(() => {
|
||||
currentUpdateProcess = null;
|
||||
event.sender.send(
|
||||
UPDATE_END,
|
||||
terminalSuccess("Update completed successfully!"),
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
currentUpdateProcess = null;
|
||||
event.sender.send(
|
||||
UPDATE_END,
|
||||
terminalError(`Update failed: ${error.message}`),
|
||||
@@ -30,6 +42,52 @@ export function addUpdateEventListeners() {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(UPDATE_CANCEL, async (event) => {
|
||||
if (currentUpdateProcess) {
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalInfo("Cancelling update process..."),
|
||||
);
|
||||
|
||||
// Kill the process and all its child processes using tree-kill
|
||||
try {
|
||||
const pid = currentUpdateProcess.pid!;
|
||||
|
||||
// Use tree-kill to properly terminate the entire process tree
|
||||
// First try graceful termination with SIGTERM (default signal)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
tkill(pid, (err) => {
|
||||
if (err) {
|
||||
// If graceful termination fails, force kill with SIGKILL
|
||||
tkill(pid, "SIGKILL", (killErr) => {
|
||||
if (killErr) {
|
||||
reject(killErr);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
currentUpdateProcess = null;
|
||||
event.sender.send(UPDATE_END, terminalInfo("Update process cancelled"));
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalError(`Error cancelling process: ${error.message}`),
|
||||
);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
} else {
|
||||
event.sender.send(UPDATE_LOG, terminalInfo("No update process running"));
|
||||
return { success: false, error: "No update process running" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function update(
|
||||
@@ -143,15 +201,10 @@ async function clearRepoDirectory(
|
||||
event: Electron.IpcMainInvokeEvent,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Check if the directory exists
|
||||
const lsResult = await runCommand("ls", [".git"], repoDir, event);
|
||||
if (lsResult.success) {
|
||||
// If it exists, delete the directory
|
||||
const rmResult = await runCommand("rm", ["-rf", repoDir], repoDir, event);
|
||||
if (!rmResult.success) {
|
||||
event.sender.send(UPDATE_LOG, rmResult.error);
|
||||
return { success: false, error: rmResult.error };
|
||||
}
|
||||
// Check if the repo directory exists
|
||||
if (existsSync(repoDir)) {
|
||||
// If it exists, delete the repo directory
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalSuccess(`Deleted existing repository at ${repoDir}`),
|
||||
@@ -159,7 +212,7 @@ async function clearRepoDirectory(
|
||||
} else {
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalError(
|
||||
terminalInfo(
|
||||
`No existing repository found at ${repoDir}, nothing to delete`,
|
||||
),
|
||||
);
|
||||
@@ -261,19 +314,22 @@ async function runCommand(
|
||||
`🚀 ${workingDirText} ${terminalColor("blue", completeCommand)}`,
|
||||
);
|
||||
|
||||
const process = spawn(cmd, args, {
|
||||
const childProcess = spawn(cmd, args, {
|
||||
cwd: workingDir,
|
||||
});
|
||||
|
||||
// Store reference to current process for cancellation
|
||||
currentUpdateProcess = childProcess;
|
||||
|
||||
// Stream stdout logs back to renderer
|
||||
process.stdout.on("data", (data) => {
|
||||
childProcess.stdout.on("data", (data) => {
|
||||
const log = data.toString();
|
||||
console.log(log);
|
||||
event.sender.send(UPDATE_LOG, log);
|
||||
});
|
||||
|
||||
// Stream stderr logs back to renderer
|
||||
process.stderr.on("data", (data) => {
|
||||
childProcess.stderr.on("data", (data) => {
|
||||
const log = data.toString();
|
||||
console.error(log);
|
||||
event.sender.send(UPDATE_LOG, log);
|
||||
@@ -281,8 +337,19 @@ async function runCommand(
|
||||
|
||||
// Handle process completion
|
||||
return new Promise((resolve, reject) => {
|
||||
process.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
childProcess.on("close", (code, signal) => {
|
||||
// Clear process reference when completed
|
||||
if (currentUpdateProcess === childProcess) {
|
||||
currentUpdateProcess = null;
|
||||
}
|
||||
|
||||
if (signal === "SIGTERM" || signal === "SIGKILL") {
|
||||
event.sender.send(UPDATE_LOG, terminalInfo("Command was cancelled"));
|
||||
reject({
|
||||
success: false,
|
||||
error: "Command was cancelled",
|
||||
});
|
||||
} else if (code === 0) {
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalSuccess("Command completed successfully"),
|
||||
@@ -300,7 +367,12 @@ async function runCommand(
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (err) => {
|
||||
childProcess.on("error", (err) => {
|
||||
// Clear process reference on error
|
||||
if (currentUpdateProcess === childProcess) {
|
||||
currentUpdateProcess = null;
|
||||
}
|
||||
|
||||
event.sender.send(
|
||||
UPDATE_LOG,
|
||||
terminalError(`Command error: ${err.message}`),
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* NixOS Helper Functions
|
||||
*
|
||||
*
|
||||
* This module provides wrapper functions for NixOS generation management IPC operations,
|
||||
* following the same pattern as update_helpers.ts and troubleshoot_helpers.ts.
|
||||
*
|
||||
*
|
||||
* @see src/helpers/update_helpers.ts for similar pattern
|
||||
*/
|
||||
|
||||
/**
|
||||
* List all available NixOS generations
|
||||
*/
|
||||
export async function listNixOSGenerations(): Promise<{
|
||||
success: boolean;
|
||||
generations: NixOSGeneration[];
|
||||
error?: string
|
||||
export async function listNixOSGenerations(): Promise<{
|
||||
success: boolean;
|
||||
generations: NixOSGeneration[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.nixos.listGenerations();
|
||||
@@ -21,7 +21,7 @@ export async function listNixOSGenerations(): Promise<{
|
||||
return {
|
||||
success: false,
|
||||
generations: [],
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -29,16 +29,16 @@ export async function listNixOSGenerations(): Promise<{
|
||||
/**
|
||||
* Set/switch to a specific NixOS generation
|
||||
*/
|
||||
export async function setNixOSGeneration(generationId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string
|
||||
export async function setNixOSGeneration(generationId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.nixos.setGeneration(generationId);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -46,16 +46,16 @@ export async function setNixOSGeneration(generationId: string): Promise<{
|
||||
/**
|
||||
* Delete a specific NixOS generation
|
||||
*/
|
||||
export async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string
|
||||
export async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return await window.nixos.deleteGeneration(generationId);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,5 @@ export async function deleteNixOSGeneration(generationId: string): Promise<{
|
||||
* Check if NixOS context is available
|
||||
*/
|
||||
export function isNixOSAvailable(): boolean {
|
||||
return typeof window !== 'undefined' &&
|
||||
typeof window.nixos !== 'undefined';
|
||||
return typeof window !== "undefined" && typeof window.nixos !== "undefined";
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { UpdateInfo } from "@/stores/updateStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
|
||||
export async function updateExecute(
|
||||
source: {
|
||||
githubRepoOwner: string;
|
||||
githubRepoName: string;
|
||||
githubToken?: string;
|
||||
tag?: string;
|
||||
branch?: string;
|
||||
commit?: string;
|
||||
},
|
||||
source: UpdateInfo,
|
||||
onLog: (log: string) => void,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -19,3 +15,43 @@ export async function updateExecute(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enhanced helper that automatically manages store state
|
||||
export async function updateExecuteWithStore(
|
||||
source: UpdateInfo,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const { setUpdateInfo, startUpdate, stopUpdate, addTerminalLine } =
|
||||
useUpdateStore.getState();
|
||||
|
||||
setUpdateInfo(source);
|
||||
startUpdate();
|
||||
|
||||
try {
|
||||
const result = await updateExecute(source, addTerminalLine);
|
||||
stopUpdate();
|
||||
return result;
|
||||
} catch (error) {
|
||||
stopUpdate();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCancel(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
return window.update.cancel();
|
||||
}
|
||||
|
||||
// Enhanced helper that automatically manages store state
|
||||
export async function updateCancelWithStore(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const result = await updateCancel();
|
||||
if (result.success) {
|
||||
const { stopUpdate } = useUpdateStore.getState();
|
||||
stopUpdate();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
export function generateHeadingId(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters except spaces and hyphens
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
|
||||
.replace(/[^\w\s-]/g, "") // Remove special characters except spaces and hyphens
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
|
||||
.trim();
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
export function processMarkdownImages(
|
||||
markdownContent: string,
|
||||
imageImports: Record<string, string>
|
||||
imageImports: Record<string, string>,
|
||||
): string {
|
||||
let processedContent = markdownContent;
|
||||
|
||||