Compare commits

..
Author SHA1 Message Date
Robin Krämer 92cacbc6c8 Merge pull request #513 from qitechgmbh/510-add-a-getting-started-dev-documentation
510 add a getting started dev documentation
2025-07-21 14:28:26 +02:00
93094049+kraemr@users.noreply.github.com 4bad318ce7 updated readme and getting-started dev docs 2025-07-21 14:25:41 +02:00
93094049+kraemr@users.noreply.github.com 0eeb7f372e added Contributing 2025-07-21 14:18:30 +02:00
93094049+kraemr@users.noreply.github.com 1a72f3f8bb added inital dev docs and updated build script 2025-07-21 14:11:38 +02:00
Robin Krämer 522ace8a4f Merge pull request #508 from qitechgmbh/501-raise-pressure-limit-of-extruder-and-add-conifrmation
501 raise pressure limit of extruder and add confirmation to EditValue
2025-07-21 12:05:50 +02:00
93094049+kraemr@users.noreply.github.com 5c00d28396 raised maximum for Target Pressure 2025-07-16 16:09:19 +02:00
93094049+kraemr@users.noreply.github.com 2b0cff358e added optional confirmation to EditValue 2025-07-16 16:08:49 +02:00
Robin Krämer c3b7f9d050 Merge pull request #498 from qitechgmbh/497-laser-control-page-diameter-circle-extends-over-the-boundaries
clamped diameter visualisation to avoid large circles
2025-07-15 16:08:17 +02:00
93094049+kraemr@users.noreply.github.com a7fce331a0 fixed lint error 2025-07-15 16:06:24 +02:00
93094049+kraemr@users.noreply.github.com 6dd8573584 removed console log and cleaned up code 2025-07-15 16:04:24 +02:00
93094049+kraemr@users.noreply.github.com 124418dc7a clamped diameter visualisation to avoid large circles 2025-07-15 16:00:49 +02:00
Robin Krämer 59e8ddbf82 Update CHANGELOG.md 2025-07-15 12:28:14 +02:00
Robin Krämer 366f58f072 Update CHANGELOG.md 2025-07-15 12:25:06 +02:00
Robin Krämer 7a12950c22 Update CHANGELOG.md 2025-07-15 12:24:47 +02:00
Robin Krämer 668000adf9 Update CHANGELOG.md 2025-07-15 12:24:07 +02:00
Robin Krämer f2a2fee8f6 Delete tmux-dev.sh
Keep this out of master branch please
2025-07-15 12:22:47 +02:00
Robin Krämer b412fb9d4e Merge pull request #492 from qitechgmbh/169-conditions-to-enter-extrusion-mode
added confirmation for extrusion mode based on temperature
2025-07-14 17:49:34 +02:00
93094049+kraemr@users.noreply.github.com 73de9f2e4c added confirmation for extrusion mode based on temperature 2025-07-14 17:47:49 +02:00
Robin Krämer f4e5570ad5 Merge pull request #480 from qitechgmbh/471-add-winder-auto-stop-or-pull-after-n-meters-of-filament
471 add winder auto stop or pull after n meters of filament
2025-07-14 17:46:09 +02:00
93094049+kraemr@users.noreply.github.com 2d1da2bbf5 small refactor/cleanup 2025-07-14 17:43:01 +02:00
93094049+kraemr@users.noreply.github.com 23cb7ce992 frontend/backend refactor and changed progress to meters 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 941a1edb0c backend refactored 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 19af948deb frontend refactoring 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 63ea9a0f7d fixed lint error 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 784e0f8652 changed naming 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com e5f6f3f8e6 implemented backend 2025-07-14 17:33:31 +02:00
93094049+kraemr@users.noreply.github.com 7c47ef0dee implemented frontend for auto pull/stop 2025-07-14 17:33:31 +02:00
Robin Krämer 2a668d8602 Merge pull request #495 from qitechgmbh/138-update-nixos-quickstart-guidemd-to-match-current-installation-flow
138 update nixos quickstart guidemd to match current installation flow
2025-07-14 17:30:13 +02:00
93094049+kraemr@users.noreply.github.com efe18e9d81 updated quick-start guide 2025-07-14 17:29:07 +02:00
Icedsoda cf20bb57b9 Update CHANGELOG.md 2025-07-14 14:24:44 +02:00
Icedsoda 284f427da0 Update CHANGELOG.md 2025-07-14 14:24:08 +02:00
Icedsoda 9f55171286 Merge pull request #121 from qitechgmbh/fix/7
Use Realtime Scheduling
2025-07-14 14:20:54 +02:00
Icedsoda d427f50037 put both realtime threads on separate cores 2025-07-14 14:19:11 +02:00
Icedsoda 5bfaf8178f fix realtime logging 2025-07-14 14:19:11 +02:00
Icedsoda d57866e1a6 move lock emmory after tracing
Only lock future memory

disable memory locking
2025-07-14 14:19:11 +02:00
Icedsoda 401f1bf7db # This is a combination of 2 commits.
# This is the 1st commit message:

move lock emmory after tracing

# This is the commit message #2:

Only lock future memory
2025-07-14 14:18:56 +02:00
Icedsoda a46dcbd0ae Set core affinity 2025-07-14 14:18:56 +02:00
Icedsoda d2fea5f23b Add logging 2025-07-14 14:18:21 +02:00
Icedsoda 11b78638ec Merge pull request #487 from qitechgmbh/195-remove-actor-trait
Remove `Actor` trait
2025-07-11 19:09:03 +02:00
Icedsoda d6b6f76b55 move serial_interface_actors to modbus_serial_interface 2025-07-11 18:14:58 +02:00
Icedsoda bfba0b1d62 refactor mondbus/mitsubishi stuff 2025-07-11 16:52:29 +02:00
Icedsoda 3b93e6ddc6 Remove Actor trait 2025-07-11 16:52:29 +02:00
Robin Krämer 2a0ba7f72a Merge pull request #392 from qitechgmbh/391-control-server-doesnt-shutdown-properly-on-a-panic
instead of panic_handling directly shutdown
2025-07-11 16:19:27 +02:00
93094049+kraemr@users.noreply.github.com ff9fbe6a6e instead of panic_handling directly shutdown 2025-07-11 16:02:59 +02:00
93094049+kraemr@users.noreply.github.com 7c712faff9 send pressure state event when pressure sensor disconnected 2025-07-11 14:56:12 +02:00
93094049+kraemr@users.noreply.github.com 5a4ca867a8 added wiring_error to AnalogInputState 2025-07-11 14:53:45 +02:00
93094049+kraemr@users.noreply.github.com 88633798e7 added frontend logic for pressure wiring error 2025-07-11 14:53:05 +02:00
Icedsoda 86df807b01 Merge pull request #490 from qitechgmbh/489-emit-default-state
Emit default state
2025-07-10 17:44:37 +02:00
Icedsoda 3ae04f5547 Emit default state 2025-07-10 17:40:54 +02:00
Icedsoda c1ad22266d Merge pull request #488 from qitechgmbh/refactor-mock-api
Use only a StateEvent and LiveValuesEvent on Laser V1
2025-07-10 16:52:14 +02:00
Icedsoda ec4f2517f6 Refactor API of MockMachine 2025-07-10 16:50:28 +02:00
Robin Krämer 4c73710665 Merge pull request #486 from qitechgmbh/485-extruder-requestresponse-out-of-sync-again
485 fixed out of sync error and sped up inverter reaction time
2025-07-08 11:49:04 +02:00
93094049+kraemr@users.noreply.github.com fcea61979f fixed out of sync error 2025-07-08 11:43:19 +02:00
Robin Krämer bc68644468 Merge pull request #484 from qitechgmbh/483-fix-extruder-timeout
483 fixed timeout issue
2025-07-07 19:37:27 +02:00
93094049+kraemr@users.noreply.github.com 652af28c76 fixed timeout issue 2025-07-07 19:34:09 +02:00
Robin Krämer 31d6c5c4b9 Merge pull request #470 from qitechgmbh/196-extract-act-function-from-mitsubishiinverterrs485actor-by-creating-serialinterfaceactor
196 extract act function from mitsubishiinverterrs485actor by creating serialinterfaceactor
2025-07-07 16:51:26 +02:00
93094049+kraemr@users.noreply.github.com 28fdbd8bce Fixed Error Msgs in New 2025-07-07 16:36:03 +02:00
93094049+kraemr@users.noreply.github.com 314f5b3eed added improved timeout 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 8983a67390 updated imports 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 74d67cc261 moved implementations of serial_interface and invertercontroller 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 2c4c64c0b7 prevented possible panics and finished refactoring 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com 758816815e extracted functionality from mitsubishicontroller and refactored 2025-07-07 16:29:10 +02:00
93094049+kraemr@users.noreply.github.com bf52030506 refactored out the act functionanality 2025-07-07 16:26:59 +02:00
93094049+kraemr@users.noreply.github.com 29cf495dd0 started refactoring 2025-07-07 16:26:59 +02:00
Robin Krämer 500917937b Merge pull request #476 from qitechgmbh/475-inverter-waits-unnecessarily-long
475 inverter actor only waits when necessary
2025-07-04 16:57:43 +02:00
93094049+kraemr@users.noreply.github.com 812080341c inverter actor only waits when necessary 2025-07-04 16:54:02 +02:00
Robin Krämer 221fa7edb3 Merge pull request #474 from qitechgmbh/473-usb-disconnects-randomly-nixos
473 added usbcore autosuspend to os config
2025-07-04 15:47:00 +02:00
93094049+kraemr@users.noreply.github.com fd58786524 added usbcore autosuspend to os config 2025-07-04 15:43:21 +02:00
Robin Krämer a3398cbe34 Merge pull request #468 from qitechgmbh/467-pid-pressure-mode-overshoots-drastically-when-switching-from-rpm
467 pid pressure mode overshoots drastically when switching from rpm
2025-07-03 16:46:39 +02:00
Robin Krämer b4cb5916bd remove println from clamping_timeagnostic_pid.rs 2025-07-03 16:44:41 +02:00
93094049+kraemr@users.noreply.github.com b7e9c3c1eb removed print 2025-07-03 16:15:04 +02:00
93094049+kraemr@users.noreply.github.com c291c532ec fine tuned Pressure PID 2025-07-03 15:52:43 +02:00
Robin Krämer edded9e5a6 Merge pull request #466 from qitechgmbh/465-state-data-doesnt-work-properly-when-winder-is-in-use
465 now emits state on extruder startup
2025-07-03 14:03:50 +02:00
93094049+kraemr@users.noreply.github.com eb2f0f34a4 now emits state on startup 2025-07-03 14:00:01 +02:00
Icedsoda a1fca74d66 Merge pull request #464 from qitechgmbh/453-use-only-a-stateevent-and-livevaluesevent-on-extruder-v2
refactor(extruder2): Consolidate state and event handling
2025-07-01 19:06:19 +02:00
Icedsoda 36089c3e75 refactor(extruder2): Consolidate state and event handling
This patch refactors the state management for the Extruder v2 on both the frontend and backend to use a more centralized and robust model.

- **Backend (Rust):**
  - Replaced numerous individual events with two primary events: `StateEvent` for configuration and less frequent state, and `LiveValuesEvent` for high-frequency telemetry.
  - State-mutating API calls now emit the entire updated `StateEvent`.

- **Frontend (TypeScript):**
  - Replaced multiple specialized hooks (`useInverter`, `useMode`, `useMotor`, etc.) with a single, comprehensive `useExtruder2` hook.
  - Centralized all client-side state, including optimistic updates, into the new `useExtruder2` hook.
  - UI components are updated to consume all data and actions from the single hook, simplifying data flow.
  - Renamed action functions to a consistent verb-first convention (e.g., `extruderSetMode` -> `setExtruderMode`).
2025-07-01 18:52:25 +02:00
Icedsoda d27eecd54c Merge pull request #457 from qitechgmbh/454-use-only-a-stateevent-and-livevaluesevent-on-laser-v1
Use only a StateEvent and LiveValuesEvent on Laser V1
2025-07-01 17:29:12 +02:00
Icedsoda 183166d8d5 refactor(laser): Consolidate state and event handling
This commit refactors the state management and event handling for the laser machine on both the frontend and backend. The changes clarify the distinction between infrequently changing state and frequently updated live values.

- **State and Event Consolidation:**
    - Renamed `DiameterEvent` to `LiveValuesEvent` and `LaserStateEvent` to `StateEvent` for better clarity.
    - The `StateEvent` now nests laser properties under a `laser_state` object.
    - Frontend hook `useLaser1` now manages a single, consolidated state object with optimistic updates using `immer`.

- **API and Naming Simplification:**
    - Simplified mutation names by removing the "Target" prefix (e.g., `TargetSetTargetDiameter` is now `SetTargetDiameter`).
    - Renamed variables and functions in the frontend for better readability (e.g., `laserDiameter` to `diameter`, `laserSet...` to `set...`).

- **Improved Backend Efficiency:**
    - The backend now emits the full state only when it changes, rather than on every tick.
    - Live values (diameter) are still emitted at 60 FPS.
2025-07-01 16:59:00 +02:00
Icedsoda e01a8268aa Merge pull request #461 from qitechgmbh/459-serial-machines-wont-get-executed-if-ethercat-is-not-initalized
refactor: Run EtherCAT initialization in a separate thread
2025-07-01 16:48:31 +02:00
Icedsoda d4d1969ec3 refactor: Run EtherCAT initialization in a separate thread
This change refactors the EtherCAT initialization process to be non-blocking by moving it into its own dedicated thread.

- The `init_ethercat` function now spawns a named thread ("init_ethercat") to handle interface discovery and the setup loop.
- This prevents the main application from blocking during startup while searching for a valid EtherCAT interface.
- The initialization order in `main.rs` has been slightly adjusted.
- A minor optimization was made to the laser machine actor to avoid redundant `Instant::now()` calls.
2025-07-01 16:45:48 +02:00
Icedsoda 3dc371ce68 Merge pull request #458 from qitechgmbh/455-improve-or-replace-pressure-pid
455 improve or replace pressure pid
2025-07-01 16:12:45 +02:00
93094049+kraemr@users.noreply.github.com fba882adb8 updated Changelog 2025-07-01 16:08:32 +02:00
93094049+kraemr@users.noreply.github.com e8ca268f1c removed constants, uses uom now, smooth transition from pressure to rpm, rpm to pressure 2025-07-01 16:08:32 +02:00
93094049+kraemr@users.noreply.github.com 3a0840d758 Now set_regulation doesnt reset Pressure PID 2025-07-01 16:08:24 +02:00
93094049+kraemr@users.noreply.github.com 409460736d greatly improved the precision and stability of Pressure PID 2025-07-01 15:57:57 +02:00
Icedsoda 879de6ffcc Merge pull request #456 from qitechgmbh/452-use-only-a-stateevent-and-livevaluesevent-on-winder-v2
Use only a StateEvent and LiveValuesEvent on Winder V2
2025-07-01 14:31:52 +02:00
Icedsoda d513f1ad84 Rename Winder2 Mutations 2025-07-01 14:17:43 +02:00
Icedsoda 98b65fadb6 Simplify Winder2 Events 2025-07-01 14:17:34 +02:00
Icedsoda 832bafa217 Remove Autostop API 2025-07-01 10:39:23 +02:00
Icedsoda 769dc6220f Merge pull request #451 from qitechgmbh/450-laser-ui-stops-working-when-disconnecting-and-reconnecting
Disconnect all sockets when `Namespace` is `drop`ped
2025-07-01 10:03:24 +02:00
Icedsoda a15af859d4 Disconnect all sockets when Namespace is dropped 2025-07-01 10:01:53 +02:00
Robin Krämer 9a4d1e288d Merge pull request #449 from qitechgmbh/448-extruder-does-not-do-anything-in-pressure-pid-mode
replaced Timeagnostic PID with normal PID
2025-07-01 09:02:34 +02:00
93094049+kraemr@users.noreply.github.com e20791aa6b replaced Timeagnostic PID with normal PID 2025-07-01 08:58:23 +02:00
Robin Krämer 19c4a48150 Merge pull request #444 from qitechgmbh/440-add-clamping-to-all-error-values-for-timeagnosticpid
440 add clamping to all error values for timeagnosticpid
2025-06-30 16:56:41 +02:00
93094049+kraemr@users.noreply.github.com fba5c5c4f3 renamed timeagnostic to clamping_timeagnostic 2025-06-30 16:50:53 +02:00
93094049+kraemr@users.noreply.github.com 1f05edc79f added min_signal, max_signal and tests 2025-06-30 16:48:03 +02:00
93094049+kraemr@users.noreply.github.com f79c7ba58d added clamping to timeagnostic_pid 2025-06-30 16:47:27 +02:00
Icedsoda 5e7a6f1523 Merge pull request #443 from qitechgmbh/442-function-hashing-panics-sometimes
Replaces XOR-based hashing with byte folding methods
2025-06-30 16:42:24 +02:00
Icedsoda cc41a2e696 Replaces XOR-based hashing with byte folding methods
Replaces previous XOR-based hashing functions with new byte folding methods for improved clarity and flexibility. Introduces a djb2 hashing implementation for non-cryptographic use cases.

Updates affected modules to use the new hashing functions, simplifying logic and enhancing maintainability.

Fixes inconsistencies in hashing logic across different data types.
2025-06-30 16:40:45 +02:00
Icedsoda 32ba1b0f75 Merge pull request #441 from qitechgmbh/markdown-inline-styles-in-headings
Support inline styles in markdown headings
2025-06-30 16:15:22 +02:00
Icedsoda d38b7e8051 Support inline styles in markdown headings 2025-06-30 16:08:00 +02:00
Robin Krämer 353be9a4f8 Merge pull request #439 from qitechgmbh/436-extruder-doesnt-properly-stay-in-pressure-mode
added an integral windup prevention (clamp) for integral error
2025-06-30 15:33:35 +02:00
Icedsoda ff14618ec0 Update Changelog for 2.1.0 2025-06-30 14:50:50 +02:00
93094049+kraemr@users.noreply.github.com 208306cba5 added an integral windup prevention (clamp) for integral error 2025-06-30 14:46:28 +02:00
Darmenov Alisher 7ef2c95b32 Merge pull request #415 from qitechgmbh/fix/414
Enhance Big Graph
2025-06-30 14:26:06 +02:00
Anatomik5 a41c822618 enhanced animation, graph logic, buttons, excel and etc. 2025-06-30 14:25:37 +02:00
Icedsoda 69a3cdbef3 Merge pull request #429 from qitechgmbh/428-make-adaptivespoolspeedcontroller-core-parameters-customizable
Make `AdaptiveSpoolSpeedController` Core Parameters Customizable
2025-06-28 17:23:49 +02:00
Icedsoda ff51018d35 Make AdaptiveSpoolSpeedController Core Parameters Customizable 2025-06-28 17:22:18 +02:00
Icedsoda a228906eed Merge pull request #427 from qitechgmbh/426-show-estimated-spool-diameter
Show estimated diameter
2025-06-28 16:49:29 +02:00
Icedsoda a238fc9246 Show estimated diameter 2025-06-28 16:48:00 +02:00
Icedsoda 82a7e2fbfa Merge pull request #401 from qitechgmbh/388-improve-spool-speed-controller-again
Improve spool speed controller (again)
2025-06-28 15:58:33 +02:00
Icedsoda 23b2fa5faf Merge pull request #424 from qitechgmbh/403-cancel-update
Make an update cancelable
2025-06-28 15:10:35 +02:00
Icedsoda 1d78bacbe8 fix test errors 2025-06-28 14:36:40 +02:00
Icedsoda 043e781049 Improve AdaptiveSpoolSpeedController 2025-06-28 14:36:40 +02:00
Icedsoda 2d23d9db2f Make two spool speed algos available 2025-06-28 14:36:40 +02:00
Icedsoda c260403370 Make update store global 2025-06-28 14:07:35 +02:00
Icedsoda 8ac9b73bd5 Delete repo folder using fs 2025-06-28 12:41:14 +02:00
Icedsoda 609cce0a42 Make update cancelable
use tree-kill
2025-06-28 12:40:58 +02:00
Icedsoda ec304649bd Merge pull request #423 from qitechgmbh/422-warning-input-crane-has-an-override-for-a-non-existent-input-nixpkgs
Fix crane nixos issue
2025-06-28 12:12:21 +02:00
Icedsoda 217edd7db0 Add update-flake-lock.sh 2025-06-28 12:03:28 +02:00
Icedsoda 5d441b2b9a Fix crane nixos issue 2025-06-28 12:01:25 +02:00
Icedsoda 157989316e Merge branch 'rename-machine-presets' 2025-06-27 20:05:10 +02:00
Oshgnacknak 7fc5a35b6a Run formatter in ci to test 2025-06-27 20:02:32 +02:00
Oshgnacknak a93c863688 Move machine properties to their own file
- rename "Presets" to "Properties"

Script to start both dev servers using tmux

Rename MachinePresets to MachineProperties
2025-06-27 20:02:32 +02:00
Icedsoda 7735a76b1f Merge pull request #412 from qitechgmbh/411-make-nixos-rust-builds-faster
Make nixos rust builds faster
2025-06-27 19:11:48 +02:00
Icedsoda 58b0412c68 Use 2 cargo build jobs 2025-06-27 19:11:16 +02:00
Icedsoda 7ade900f46 Use crane for nixos rust builds 2025-06-27 19:11:16 +02:00
Icedsoda ad678e9c67 remove rust overlay 2025-06-27 19:11:16 +02:00
Icedsoda 6455a8b6f1 Merge pull request #419 from qitechgmbh/418-traverse-doesnt-move-at-low-spool-speeds
Change `steps_per_second` from `i32` to `f64`
2025-06-27 17:24:04 +02:00
Icedsoda b1b26fb5ae Change steps_per_second from i32 to f64 2025-06-27 17:22:10 +02:00
Robin Krämer d68be75be1 Merge pull request #417 from qitechgmbh/416-remove-arbitrary-limits-in-extruder-frontend
removed hardcoded limits from HeatingZone, now configurable
2025-06-27 16:30:43 +02:00
93094049+kraemr@users.noreply.github.com f40a5ac3af removed hardcoded limits from HeatingZone, now configurable 2025-06-27 16:04:08 +02:00
Robin Krämer 0c2cf6f460 Merge pull request #413 from qitechgmbh/186-fix-rust-warnings-in-modbus-implementation
fixed warnings in modbus and extruder
2025-06-27 12:56:35 +02:00
93094049+kraemr@users.noreply.github.com b5a989b7fe fixed warnings in modbus and extruder 2025-06-27 12:51:41 +02:00
Icedsoda ab9fad2514 Add Wireshark (#410)
* Add wireshark

* fix wireshark permision issue
2025-06-27 12:35:15 +02:00
Icedsoda e3e7cbb20f Increase PDU timeout 2025-06-26 17:40:23 +02:00
Icedsoda 7b1395b803 Merge pull request #405 from qitechgmbh/404-remove-hover-styling-of-buttons
Remove `hover:` styling
2025-06-26 17:32:18 +02:00
Icedsoda d67db133ea Remove hover: styling 2025-06-26 15:49:16 +02:00
Robin Krämer d74f3aad96 Merge pull request #400 from qitechgmbh/342-mode-button-switches-back-and-forth
Fixed Mode button and Regulation button switching back and forth
2025-06-26 13:44:55 +02:00
93094049+kraemr@users.noreply.github.com 7f2b24f075 fixed warning 2025-06-26 13:41:47 +02:00
93094049+kraemr@users.noreply.github.com d609d41628 Fixed Mode button and Regulation button switching back and forth 2025-06-26 13:35:47 +02:00
Darmenov Alisher 50c241dd59 Merge pull request #399 from qitechgmbh/fix/393
Fix/393
2025-06-25 19:10:18 +02:00
Darmenov Alisher 1a6e7da996 Merge branch 'master' into fix/393 2025-06-25 19:10:12 +02:00
Anatomik5 3a0eaf53f3 handlers fix 2025-06-25 19:04:44 +02:00
Anatomik5 16a952349f graphs enchanced 2025-06-25 18:47:55 +02:00
Anatomik5 c40f256df5 graph visual enchance 2025-06-25 18:42:30 +02:00
Anatomik5 8f0cc645ef small fix graphs 2025-06-25 17:57:18 +02:00
Anatomik5 1f436a6abe color fix 2025-06-25 17:15:32 +02:00
Anatomik5 477b44c8bb fix graph and button color 2025-06-25 17:15:04 +02:00
Anatomik5 4147528d0e fixed scale problem 2025-06-25 16:01:19 +02:00
Anatomik5 2b533f2c57 fix 2 2025-06-24 17:53:40 +02:00
Darmenov Alisher afaf8c919e Merge branch 'master' into fix/393 2025-06-24 17:08:57 +02:00
Anatomik5 2e87efdb63 update handlers and graph visual 2025-06-24 17:07:15 +02:00
Anatomik5 65fe3626ba graphs enchanced 2025-06-24 16:11:00 +02:00
Darmenov Alisher 36bae74613 Merge pull request #390 from qitechgmbh/fix/389
fix
2025-06-24 11:01:58 +02:00
Anatomik5 2e34fa11f8 fix 2025-06-24 11:01:38 +02:00
Robin Krämer 3410493c62 Merge pull request #387 from qitechgmbh/386-add-configuration-for-pressure-pid
386 add configuration for pressure pid
2025-06-23 18:33:12 +02:00
93094049+kraemr@users.noreply.github.com f13ad8fd0a fixed errors 2025-06-23 18:29:25 +02:00
93094049+kraemr@users.noreply.github.com 8208762da5 removed temperature pid kept pressure pid 2025-06-23 18:24:42 +02:00
93094049+kraemr@users.noreply.github.com 1beb01da98 Revert "Revert "frontend changes for api""
This reverts commit eb08493fdc.
2025-06-23 18:21:56 +02:00
93094049+kraemr@users.noreply.github.com e3dcfaec3b Revert "Revert "bakcend api""
This reverts commit 11e965ddeb.
2025-06-23 18:19:27 +02:00
Robin Krämer efbf7bc350 Merge pull request #378 from qitechgmbh/377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses
377 inverter state machine and el6021 incorrectly handle requestresponses
2025-06-23 17:23:54 +02:00
93094049+kraemr@users.noreply.github.com 6cf769891f removed unnecessary comment 2025-06-23 17:21:17 +02:00
Robin Krämer 840e4b8566 Merge pull request #385 from qitechgmbh/384-add-graph-to-extruder
added extruder graphs
2025-06-23 17:16:42 +02:00
93094049+kraemr@users.noreply.github.com 38341f6968 added extruder graphs 2025-06-23 17:13:26 +02:00
93094049+kraemr@users.noreply.github.com 80e9726ba3 simplified SerialInterface 2025-06-23 17:07:21 +02:00
93094049+kraemr@users.noreply.github.com 8afce68864 cleanup 2025-06-23 16:10:37 +02:00
Robin Krämer 5f7b7544c3 Merge pull request #381 from qitechgmbh/355-enable-inverter-timeout-when-communication-is-interrupted
355 enable inverter timeout when communication is interrupted
2025-06-23 15:20:59 +02:00
93094049+kraemr@users.noreply.github.com 27897182f9 updated docs 2025-06-23 15:17:54 +02:00
93094049+kraemr@users.noreply.github.com 2af1f0d79c updated Docs 2025-06-23 15:16:56 +02:00
Robin Krämer 4eeaebe355 Merge pull request #380 from qitechgmbh/379-dre-laser-graph-is-broken
fixed assignment error
2025-06-23 14:45:43 +02:00
93094049+kraemr@users.noreply.github.com 1dc85828d9 fixed assignment error 2025-06-23 14:42:59 +02:00
Darmenov Alisher bf5408cd18 Merge pull request #324 from qitechgmbh/fix/320
Group Excel Button
2025-06-23 12:39:13 +02:00
Anatomik5 d80b06256a nicer code + props sync 2025-06-23 12:36:16 +02:00
Anatomik5 aa0fb88fd2 nicer code + props sync 2025-06-23 12:36:16 +02:00
Darmenov Alisher fa85501578 Merge pull request #343 from qitechgmbh/fix/318
Add Graphs to Extruder
2025-06-23 12:28:14 +02:00
Anatomik5 500390e6dd extruder graph code enchancing 2025-06-23 12:22:48 +02:00
Anatomik5 bf73f996ca fix 2025-06-23 12:22:48 +02:00
Anatomik5 cb7fb902be extruder graph 2025-06-23 12:22:48 +02:00
Anatomik5 8fabc46ad4 fix padding 2025-06-23 12:22:48 +02:00
Anatomik5 38a7da7ee2 extruder graph var 1 2025-06-23 12:22:48 +02:00
Anatomik5 bbae14a727 Group Excel Button 2025-06-23 12:22:48 +02:00
93094049+kraemr@users.noreply.github.com f4609bfb3c Merge branch 'master' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 12:04:27 +02:00
93094049+kraemr@users.noreply.github.com afa4663176 cleanup of EL6021 and inverter 2025-06-23 12:02:21 +02:00
93094049+kraemr@users.noreply.github.com 9b806ee8b3 Merge branch 'inverter-fix' into 377-inverter-state-machine-and-el6021-incorrectly-handle-requestresponses 2025-06-23 11:54:57 +02:00
93094049+kraemr@users.noreply.github.com 4975d2f6a7 fixed el6021 and inverter state machine 2025-06-23 11:54:30 +02:00
93094049+kraemr@users.noreply.github.com 1a675db872 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-23 11:47:00 +02:00
Robin Krämer 0230ff4f81 Merge pull request #373 from qitechgmbh/360-show-inverter-errors-and-recover
360 show inverter errors and recover
2025-06-23 11:46:50 +02:00
Robin Krämer d8039156ae Update mod.rs 2025-06-23 11:45:09 +02:00
Darmenov Alisher e7105334c4 Merge pull request #358 from qitechgmbh/fix/319
Fix/319 Code Nicer
2025-06-20 15:44:22 +02:00
Darmenov Alisher ad04ea0a62 Merge branch 'master' into fix/319 2025-06-20 15:42:48 +02:00
Anatomik5 0128268719 nicer code + props sync 2025-06-20 15:28:14 +02:00
Anatomik5 7873975980 Group Excel Button 2025-06-20 15:17:29 +02:00
Icedsoda 57883fcb7e Add retry logic to syncToStore 2025-06-20 15:16:17 +02:00
Icedsoda 5979d9497e Ship Chrome DevTools in Production 2025-06-20 15:16:17 +02:00
Icedsoda 5dade5a633 Retry EtherCAT TX/RX if it fails 2025-06-20 15:16:17 +02:00
Icedsoda 694b46afbe Reduce logging of ethercrab 2025-06-20 15:16:17 +02:00
Icedsoda 5f81b2df5b increase namespace disconnect time 2025-06-20 15:16:17 +02:00
Icedsoda ff47ed3261 fix correct cacheing durations 2025-06-20 15:16:17 +02:00
Icedsoda da84e2a562 fix heating power value flickering 2025-06-20 15:16:17 +02:00
Icedsoda a272fd57bb Use 16.67 instead of 16ms for 60fps event updates
Emit events at 60FPS
2025-06-20 15:16:17 +02:00
Icedsoda e2fb623cbb Throttle Event Stores' setState to 60fps 2025-06-20 15:16:17 +02:00
Icedsoda d797584637 Use msgpack with SocketIO 2025-06-20 15:16:17 +02:00
Icedsoda 4549d806c9 Don't use parse to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda a22d1d0ff1 Don't use produce to improve performance 2025-06-20 15:16:17 +02:00
Icedsoda adde259aa0 Reduce namespace logging 2025-06-20 15:16:17 +02:00
Icedsoda 2768609286 Fix some thread assigments 2025-06-20 15:16:17 +02:00
Icedsoda 1973d8841d Add global SocketIO Queue
add init_socketio_queue
2025-06-20 15:16:17 +02:00
Icedsoda b337031272 Pass GenericEvents using Arc
make socket queue faster
2025-06-20 15:16:17 +02:00
Icedsoda 507b7d507a Fix 0 division bug in Namespace 2025-06-20 15:16:17 +02:00
Icedsoda 2f231b203c Remove trait NamespaceInterface 2025-06-20 15:16:17 +02:00
Icedsoda 6e7a528d6f Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
Icedsoda 43dd37e8e0 Workaround for socket performance issues (hopefully solved by #269) 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 11e965ddeb Revert "bakcend api"
This reverts commit db039c7ccc.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com eb08493fdc Revert "frontend changes for api"
This reverts commit 7f7984ebe4.
2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 27b0386eac added comments to el6021 init 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com c4314a45dd refactored, clamped nozzle temp 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 02a436f053 el6021 properly handles receive_request 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com fea487c74e rpm now gets read perfectly again 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 7e317c4b23 temporary fix for rpm monitor 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 2c02d98890 bakcend api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com bdbad1dca2 frontend changes for api 2025-06-20 15:16:17 +02:00
93094049+kraemr@users.noreply.github.com 202ae24eea fixed pressure PID 2025-06-20 15:16:17 +02:00
Icedsoda 38db16468e Add Extruder Icon 2025-06-20 15:16:17 +02:00
Icedsoda 1f272f4bce Selection buttons can have a confirmation prompt 2025-06-20 15:16:17 +02:00
dependabot[bot] ed2b94ce05 Bump globals from 15.15.0 to 16.2.0 in /electron
Bumps [globals](https://github.com/sindresorhus/globals) from 15.15.0 to 16.2.0.
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v15.15.0...v16.2.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 16.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 146365fae2 Bump crc from 3.2.1 to 3.3.0
Bumps [crc](https://github.com/mrhooray/crc-rs) from 3.2.1 to 3.3.0.
- [Release notes](https://github.com/mrhooray/crc-rs/releases)
- [Commits](https://github.com/mrhooray/crc-rs/commits)

---
updated-dependencies:
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
dependabot[bot] 3bf9956c41 Bump @vitejs/plugin-react from 4.3.4 to 4.5.1 in /electron
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.5.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 4.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:16:17 +02:00
Icedsoda 7d8f833063 Add EL6021_IDENTITY_D 2025-06-20 15:16:17 +02:00
Icedsoda fcdac437eb Change Laser Icon 2025-06-20 15:16:17 +02:00
Icedsoda cb74dbc22b Reanem Dre to Laser 2025-06-20 15:16:17 +02:00
Icedsoda a749f98e81 Fix NixOS settings with wrong units 2025-06-20 15:15:11 +02:00
Icedsoda 634c00cdd1 Switches to tee for safer file redirection in script
Replaces `cat` with `sudo tee` to avoid potential permission issues and
ensure proper handling of file redirection when generating `installInfo.nix`.
Redirects output to `/dev/null` to suppress unnecessary output.
2025-06-20 15:15:11 +02:00
dependabot[bot] 5524be3ce0 Bump react-hook-form from 7.54.2 to 7.57.0 in /electron
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.54.2 to 7.57.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.54.2...v7.57.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-20 15:15:11 +02:00
Icedsoda 77bdb2f92c Bump OpenTelemetry Dependencies 2025-06-20 15:15:11 +02:00
nixos-install.sh 163fae21eb Remove KV pairs in logs 2025-06-20 15:15:11 +02:00
nixos-install.sh f58226aa27 Add Markdown TOC 2025-06-20 15:15:11 +02:00
nixos-install.sh 2742a296ef Revert "Add installInfo.nix file with current commit information"
This reverts commit d0a98557d3.
2025-06-20 15:15:11 +02:00
Icedsoda bb204d6efb delete summary.txt 2025-06-20 14:56:39 +02:00
Anatomik5 3a7c99cbbd fixed lint bugs 2025-06-18 17:51:47 +02:00
Anatomik5 7796d20984 nice code, modular 2025-06-18 16:40:00 +02:00
Anatomik5 9b3223c014 fixed problem with time and problem with mode switching 2025-06-18 16:40:00 +02:00
Anatomik5 a5c5ba4b87 nicer code + props sync 2025-06-18 16:40:00 +02:00
93094049+kraemr@users.noreply.github.com b53fea1812 frontendChanges for readStatus and resetInverter 2025-06-17 18:59:44 +02:00
93094049+kraemr@users.noreply.github.com b71a3456ce backend changes for readStatus and ResetInverter 2025-06-17 18:59:27 +02:00
93094049+kraemr@users.noreply.github.com 81cb44ed2b handle_response inverterstatus 2025-06-17 17:48:35 +02:00
Icedsoda 8c1fe6df1f Merge pull request #370 from qitechgmbh/add-retry-logic-to-sync-to-store
Add retry logic to `syncToStore`
2025-06-17 17:05:34 +02:00
Icedsoda 91fadd1c3d Merge pull request #372 from qitechgmbh/371-ship-chrome-devtools-in-production
Ship Chrome DevTools in Production
2025-06-17 16:51:02 +02:00
Icedsoda 28660a0873 Ship Chrome DevTools in Production 2025-06-17 16:49:57 +02:00
Icedsoda 5171ef5ef4 Add retry logic to syncToStore 2025-06-17 16:42:54 +02:00
Icedsoda 0cfd7eec9f Merge pull request #369 from qitechgmbh/367-retry-ethercat-txrx-if-it-fails
Retry EtherCAT TX/RX if it fails
2025-06-17 16:41:24 +02:00
Icedsoda 2ed2361fdf Retry EtherCAT TX/RX if it fails 2025-06-17 16:39:30 +02:00
Icedsoda 7c8b05241a Merge pull request #366 from qitechgmbh/365-reduce-logging-of-ethercrab
Reduce logging of ethercrab
2025-06-17 15:42:22 +02:00
Icedsoda 80e6edc474 Reduce logging of ethercrab 2025-06-17 15:29:06 +02:00
93094049+kraemr@users.noreply.github.com 9f13765db6 Merge branch 'master' of github.com:qitechgmbh/control 2025-06-17 14:13:56 +02:00
93094049+kraemr@users.noreply.github.com 465f8e9f07 added revision 0x10000 el6021 to frontend 2025-06-17 14:13:46 +02:00
Anatomik5 87112035fb nicer code + props sync 2025-06-16 17:13:05 +02:00
Anatomik5 ecb27ea068 sync enchance 2025-06-16 16:32:36 +02:00
Anatomik5 8c7a71a3f6 enchance 2025-06-16 15:41:38 +02:00
Anatomik5 4ea2d3bee2 button format changed 2025-06-16 01:17:06 +02:00
Anatomik5 42867bf41c Group Excel Button 2025-06-12 17:34:59 +02:00
207 changed files with 15678 additions and 11371 deletions

No files matched your search

+3
View File
@@ -36,6 +36,9 @@ jobs:
- name: Install
run: npm i
- name: Format
run: npm run format
- name: Lint
run: npm run lint
+69 -4
View File
@@ -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
View File
@@ -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",
+5 -3
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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;
})
}
}
-19
View File
@@ -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 -1
View File
@@ -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;
+20 -1
View File
@@ -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;
}
}
+90 -22
View File
@@ -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);
}
+699 -5
View File
@@ -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() {
+415 -1
View File
@@ -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 -1
View File
@@ -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;
+2 -2
View File
@@ -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 {}
+5 -1
View File
@@ -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,
+55
View File
@@ -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 => {}
}
}
}
+178
View File
@@ -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, &param);
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"))
}
}
+45 -3
View File
@@ -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
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
+1681 -2575
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
"@tailwindcss/postcss": {},
},
};
+1 -1
View File
@@ -1 +1 @@
# Laser Manual
# Laser Manual
+7 -2
View File
@@ -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.
![Mock Machine Duck](duck.jpg)
*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_
+11 -2
View File
@@ -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);
}
}
/**
+13 -11
View File
@@ -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) {
+10 -2
View File
@@ -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}
/>
),
+33 -2
View File
@@ -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](url) -> alt
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
// Clean up any remaining markdown characters
.replace(/[*_`~]/g, "")
.trim()
);
}
function buildTocTree(
headings: Array<{ level: number; title: string; id: string }>,
): TocItem[] {
+1 -1
View File
@@ -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
+7 -2
View File
@@ -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>
);
}
+605
View File
@@ -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>
);
}
+224
View File
@@ -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}
/>
);
}
+262
View File
@@ -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;
}
+299
View File
@@ -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 };
+31
View File
@@ -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";
+343
View File
@@ -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 };
+166
View File
@@ -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" />
+11 -11
View File
@@ -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 };
+13 -14
View File
@@ -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 };
+16 -19
View File
@@ -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 };
+5 -5
View File
@@ -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 };
+19 -19
View File
@@ -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,
}
};
+43 -40
View File
@@ -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,
}
};
+40 -39
View File
@@ -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,
}
};
+5 -5
View File
@@ -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 };
+7 -7
View File
@@ -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 };
+32 -32
View File
@@ -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,
}
};
+10 -10
View File
@@ -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 };
+21 -21
View File
@@ -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,
}
};
+6 -6
View File
@@ -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 };
+7 -4
View File
@@ -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 };
+8 -7
View File
@@ -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 };
+27 -27
View File
@@ -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,
}
};
+2 -3
View File
@@ -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",
+2 -2
View File
@@ -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: {
+42 -25
View File
@@ -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) {
+1 -1
View File
@@ -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,
+7
View File
@@ -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
-210
View File
@@ -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}`),
+16 -17
View File
@@ -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";
}
+44 -8
View File
@@ -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;
}
+3 -3
View File
@@ -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();
}
+13 -11
View File
@@ -10,7 +10,7 @@
*/
export function processMarkdownImages(
markdownContent: string,
imageImports: Record<string, string>
imageImports: Record<string, string>,
): string {
let processedContent = markdownContent;