Upload 24 files
Browse files- Cargo.lock +2484 -0
- Cargo.toml +31 -0
- Dockerfile +41 -0
- README.md +8 -6
- src/api.rs +1252 -0
- src/console.rs +361 -0
- src/constraints.rs +195 -0
- src/demo_data.rs +449 -0
- src/domain.rs +568 -0
- src/geometry.rs +308 -0
- src/lib.rs +26 -0
- src/main.rs +60 -0
- src/routing.rs +822 -0
- src/solver.rs +625 -0
- static/app.js +1627 -0
- static/index.html +473 -0
- static/recommended-fit.js +206 -0
- static/score-analysis.js +96 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
Cargo.lock
ADDED
|
@@ -0,0 +1,2484 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is automatically @generated by Cargo.
|
| 2 |
+
# It is not intended for manual editing.
|
| 3 |
+
version = 4
|
| 4 |
+
|
| 5 |
+
[[package]]
|
| 6 |
+
name = "adler2"
|
| 7 |
+
version = "2.0.1"
|
| 8 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9 |
+
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
| 10 |
+
|
| 11 |
+
[[package]]
|
| 12 |
+
name = "aho-corasick"
|
| 13 |
+
version = "1.1.4"
|
| 14 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 15 |
+
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
| 16 |
+
dependencies = [
|
| 17 |
+
"memchr",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[[package]]
|
| 21 |
+
name = "android_system_properties"
|
| 22 |
+
version = "0.1.5"
|
| 23 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 24 |
+
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
| 25 |
+
dependencies = [
|
| 26 |
+
"libc",
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
[[package]]
|
| 30 |
+
name = "arbitrary"
|
| 31 |
+
version = "1.4.2"
|
| 32 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 33 |
+
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
| 34 |
+
dependencies = [
|
| 35 |
+
"derive_arbitrary",
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
[[package]]
|
| 39 |
+
name = "arrayvec"
|
| 40 |
+
version = "0.7.6"
|
| 41 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 42 |
+
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
| 43 |
+
|
| 44 |
+
[[package]]
|
| 45 |
+
name = "async-stream"
|
| 46 |
+
version = "0.3.6"
|
| 47 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 48 |
+
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
| 49 |
+
dependencies = [
|
| 50 |
+
"async-stream-impl",
|
| 51 |
+
"futures-core",
|
| 52 |
+
"pin-project-lite",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
[[package]]
|
| 56 |
+
name = "async-stream-impl"
|
| 57 |
+
version = "0.3.6"
|
| 58 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 59 |
+
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
| 60 |
+
dependencies = [
|
| 61 |
+
"proc-macro2",
|
| 62 |
+
"quote",
|
| 63 |
+
"syn",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
[[package]]
|
| 67 |
+
name = "atomic-waker"
|
| 68 |
+
version = "1.1.2"
|
| 69 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 70 |
+
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
| 71 |
+
|
| 72 |
+
[[package]]
|
| 73 |
+
name = "autocfg"
|
| 74 |
+
version = "1.5.0"
|
| 75 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 76 |
+
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
| 77 |
+
|
| 78 |
+
[[package]]
|
| 79 |
+
name = "axum"
|
| 80 |
+
version = "0.8.8"
|
| 81 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 82 |
+
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
|
| 83 |
+
dependencies = [
|
| 84 |
+
"axum-core",
|
| 85 |
+
"bytes",
|
| 86 |
+
"form_urlencoded",
|
| 87 |
+
"futures-util",
|
| 88 |
+
"http",
|
| 89 |
+
"http-body",
|
| 90 |
+
"http-body-util",
|
| 91 |
+
"hyper",
|
| 92 |
+
"hyper-util",
|
| 93 |
+
"itoa",
|
| 94 |
+
"matchit",
|
| 95 |
+
"memchr",
|
| 96 |
+
"mime",
|
| 97 |
+
"percent-encoding",
|
| 98 |
+
"pin-project-lite",
|
| 99 |
+
"serde_core",
|
| 100 |
+
"serde_json",
|
| 101 |
+
"serde_path_to_error",
|
| 102 |
+
"serde_urlencoded",
|
| 103 |
+
"sync_wrapper",
|
| 104 |
+
"tokio",
|
| 105 |
+
"tower",
|
| 106 |
+
"tower-layer",
|
| 107 |
+
"tower-service",
|
| 108 |
+
"tracing",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
[[package]]
|
| 112 |
+
name = "axum-core"
|
| 113 |
+
version = "0.5.6"
|
| 114 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 115 |
+
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
|
| 116 |
+
dependencies = [
|
| 117 |
+
"bytes",
|
| 118 |
+
"futures-core",
|
| 119 |
+
"http",
|
| 120 |
+
"http-body",
|
| 121 |
+
"http-body-util",
|
| 122 |
+
"mime",
|
| 123 |
+
"pin-project-lite",
|
| 124 |
+
"sync_wrapper",
|
| 125 |
+
"tower-layer",
|
| 126 |
+
"tower-service",
|
| 127 |
+
"tracing",
|
| 128 |
+
]
|
| 129 |
+
|
| 130 |
+
[[package]]
|
| 131 |
+
name = "base64"
|
| 132 |
+
version = "0.22.1"
|
| 133 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 134 |
+
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
| 135 |
+
|
| 136 |
+
[[package]]
|
| 137 |
+
name = "bitflags"
|
| 138 |
+
version = "2.10.0"
|
| 139 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 140 |
+
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
| 141 |
+
|
| 142 |
+
[[package]]
|
| 143 |
+
name = "block-buffer"
|
| 144 |
+
version = "0.10.4"
|
| 145 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 146 |
+
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
| 147 |
+
dependencies = [
|
| 148 |
+
"generic-array",
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
[[package]]
|
| 152 |
+
name = "bumpalo"
|
| 153 |
+
version = "3.19.1"
|
| 154 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 155 |
+
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
| 156 |
+
|
| 157 |
+
[[package]]
|
| 158 |
+
name = "bytes"
|
| 159 |
+
version = "1.11.0"
|
| 160 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 161 |
+
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
| 162 |
+
|
| 163 |
+
[[package]]
|
| 164 |
+
name = "cc"
|
| 165 |
+
version = "1.2.51"
|
| 166 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 167 |
+
checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
|
| 168 |
+
dependencies = [
|
| 169 |
+
"find-msvc-tools",
|
| 170 |
+
"shlex",
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
[[package]]
|
| 174 |
+
name = "cfg-if"
|
| 175 |
+
version = "1.0.4"
|
| 176 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 177 |
+
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
| 178 |
+
|
| 179 |
+
[[package]]
|
| 180 |
+
name = "cfg_aliases"
|
| 181 |
+
version = "0.2.1"
|
| 182 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 183 |
+
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
| 184 |
+
|
| 185 |
+
[[package]]
|
| 186 |
+
name = "chrono"
|
| 187 |
+
version = "0.4.42"
|
| 188 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 189 |
+
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
|
| 190 |
+
dependencies = [
|
| 191 |
+
"iana-time-zone",
|
| 192 |
+
"js-sys",
|
| 193 |
+
"num-traits",
|
| 194 |
+
"serde",
|
| 195 |
+
"wasm-bindgen",
|
| 196 |
+
"windows-link",
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
[[package]]
|
| 200 |
+
name = "core-foundation-sys"
|
| 201 |
+
version = "0.8.7"
|
| 202 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 203 |
+
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
| 204 |
+
|
| 205 |
+
[[package]]
|
| 206 |
+
name = "cpufeatures"
|
| 207 |
+
version = "0.2.17"
|
| 208 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 209 |
+
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
| 210 |
+
dependencies = [
|
| 211 |
+
"libc",
|
| 212 |
+
]
|
| 213 |
+
|
| 214 |
+
[[package]]
|
| 215 |
+
name = "crc32fast"
|
| 216 |
+
version = "1.5.0"
|
| 217 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 218 |
+
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
| 219 |
+
dependencies = [
|
| 220 |
+
"cfg-if",
|
| 221 |
+
]
|
| 222 |
+
|
| 223 |
+
[[package]]
|
| 224 |
+
name = "crossbeam-deque"
|
| 225 |
+
version = "0.8.6"
|
| 226 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 227 |
+
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
|
| 228 |
+
dependencies = [
|
| 229 |
+
"crossbeam-epoch",
|
| 230 |
+
"crossbeam-utils",
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
[[package]]
|
| 234 |
+
name = "crossbeam-epoch"
|
| 235 |
+
version = "0.9.18"
|
| 236 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 237 |
+
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
| 238 |
+
dependencies = [
|
| 239 |
+
"crossbeam-utils",
|
| 240 |
+
]
|
| 241 |
+
|
| 242 |
+
[[package]]
|
| 243 |
+
name = "crossbeam-utils"
|
| 244 |
+
version = "0.8.21"
|
| 245 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 246 |
+
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
| 247 |
+
|
| 248 |
+
[[package]]
|
| 249 |
+
name = "crypto-common"
|
| 250 |
+
version = "0.1.7"
|
| 251 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 252 |
+
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
| 253 |
+
dependencies = [
|
| 254 |
+
"generic-array",
|
| 255 |
+
"typenum",
|
| 256 |
+
]
|
| 257 |
+
|
| 258 |
+
[[package]]
|
| 259 |
+
name = "derive_arbitrary"
|
| 260 |
+
version = "1.4.2"
|
| 261 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 262 |
+
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
| 263 |
+
dependencies = [
|
| 264 |
+
"proc-macro2",
|
| 265 |
+
"quote",
|
| 266 |
+
"syn",
|
| 267 |
+
]
|
| 268 |
+
|
| 269 |
+
[[package]]
|
| 270 |
+
name = "digest"
|
| 271 |
+
version = "0.10.7"
|
| 272 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 273 |
+
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
| 274 |
+
dependencies = [
|
| 275 |
+
"block-buffer",
|
| 276 |
+
"crypto-common",
|
| 277 |
+
]
|
| 278 |
+
|
| 279 |
+
[[package]]
|
| 280 |
+
name = "displaydoc"
|
| 281 |
+
version = "0.2.5"
|
| 282 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 283 |
+
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
| 284 |
+
dependencies = [
|
| 285 |
+
"proc-macro2",
|
| 286 |
+
"quote",
|
| 287 |
+
"syn",
|
| 288 |
+
]
|
| 289 |
+
|
| 290 |
+
[[package]]
|
| 291 |
+
name = "either"
|
| 292 |
+
version = "1.15.0"
|
| 293 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 294 |
+
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
| 295 |
+
|
| 296 |
+
[[package]]
|
| 297 |
+
name = "equivalent"
|
| 298 |
+
version = "1.0.2"
|
| 299 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 300 |
+
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
| 301 |
+
|
| 302 |
+
[[package]]
|
| 303 |
+
name = "errno"
|
| 304 |
+
version = "0.3.14"
|
| 305 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 306 |
+
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
| 307 |
+
dependencies = [
|
| 308 |
+
"libc",
|
| 309 |
+
"windows-sys 0.61.2",
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
[[package]]
|
| 313 |
+
name = "find-msvc-tools"
|
| 314 |
+
version = "0.1.6"
|
| 315 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 316 |
+
checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
|
| 317 |
+
|
| 318 |
+
[[package]]
|
| 319 |
+
name = "fixedbitset"
|
| 320 |
+
version = "0.4.2"
|
| 321 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 322 |
+
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
| 323 |
+
|
| 324 |
+
[[package]]
|
| 325 |
+
name = "flate2"
|
| 326 |
+
version = "1.1.5"
|
| 327 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 328 |
+
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
|
| 329 |
+
dependencies = [
|
| 330 |
+
"crc32fast",
|
| 331 |
+
"libz-rs-sys",
|
| 332 |
+
"miniz_oxide",
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
[[package]]
|
| 336 |
+
name = "form_urlencoded"
|
| 337 |
+
version = "1.2.2"
|
| 338 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 339 |
+
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
| 340 |
+
dependencies = [
|
| 341 |
+
"percent-encoding",
|
| 342 |
+
]
|
| 343 |
+
|
| 344 |
+
[[package]]
|
| 345 |
+
name = "futures-channel"
|
| 346 |
+
version = "0.3.31"
|
| 347 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 348 |
+
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
| 349 |
+
dependencies = [
|
| 350 |
+
"futures-core",
|
| 351 |
+
]
|
| 352 |
+
|
| 353 |
+
[[package]]
|
| 354 |
+
name = "futures-core"
|
| 355 |
+
version = "0.3.31"
|
| 356 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 357 |
+
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
| 358 |
+
|
| 359 |
+
[[package]]
|
| 360 |
+
name = "futures-sink"
|
| 361 |
+
version = "0.3.31"
|
| 362 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 363 |
+
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
|
| 364 |
+
|
| 365 |
+
[[package]]
|
| 366 |
+
name = "futures-task"
|
| 367 |
+
version = "0.3.31"
|
| 368 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 369 |
+
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
|
| 370 |
+
|
| 371 |
+
[[package]]
|
| 372 |
+
name = "futures-util"
|
| 373 |
+
version = "0.3.31"
|
| 374 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 375 |
+
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
| 376 |
+
dependencies = [
|
| 377 |
+
"futures-core",
|
| 378 |
+
"futures-task",
|
| 379 |
+
"pin-project-lite",
|
| 380 |
+
"pin-utils",
|
| 381 |
+
]
|
| 382 |
+
|
| 383 |
+
[[package]]
|
| 384 |
+
name = "generic-array"
|
| 385 |
+
version = "0.14.7"
|
| 386 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 387 |
+
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
| 388 |
+
dependencies = [
|
| 389 |
+
"typenum",
|
| 390 |
+
"version_check",
|
| 391 |
+
]
|
| 392 |
+
|
| 393 |
+
[[package]]
|
| 394 |
+
name = "getrandom"
|
| 395 |
+
version = "0.2.16"
|
| 396 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 397 |
+
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
| 398 |
+
dependencies = [
|
| 399 |
+
"cfg-if",
|
| 400 |
+
"js-sys",
|
| 401 |
+
"libc",
|
| 402 |
+
"wasi",
|
| 403 |
+
"wasm-bindgen",
|
| 404 |
+
]
|
| 405 |
+
|
| 406 |
+
[[package]]
|
| 407 |
+
name = "getrandom"
|
| 408 |
+
version = "0.3.4"
|
| 409 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 410 |
+
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
| 411 |
+
dependencies = [
|
| 412 |
+
"cfg-if",
|
| 413 |
+
"js-sys",
|
| 414 |
+
"libc",
|
| 415 |
+
"r-efi",
|
| 416 |
+
"wasip2",
|
| 417 |
+
"wasm-bindgen",
|
| 418 |
+
]
|
| 419 |
+
|
| 420 |
+
[[package]]
|
| 421 |
+
name = "hashbrown"
|
| 422 |
+
version = "0.16.1"
|
| 423 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 424 |
+
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
| 425 |
+
|
| 426 |
+
[[package]]
|
| 427 |
+
name = "http"
|
| 428 |
+
version = "1.4.0"
|
| 429 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 430 |
+
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
| 431 |
+
dependencies = [
|
| 432 |
+
"bytes",
|
| 433 |
+
"itoa",
|
| 434 |
+
]
|
| 435 |
+
|
| 436 |
+
[[package]]
|
| 437 |
+
name = "http-body"
|
| 438 |
+
version = "1.0.1"
|
| 439 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 440 |
+
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
| 441 |
+
dependencies = [
|
| 442 |
+
"bytes",
|
| 443 |
+
"http",
|
| 444 |
+
]
|
| 445 |
+
|
| 446 |
+
[[package]]
|
| 447 |
+
name = "http-body-util"
|
| 448 |
+
version = "0.1.3"
|
| 449 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 450 |
+
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
|
| 451 |
+
dependencies = [
|
| 452 |
+
"bytes",
|
| 453 |
+
"futures-core",
|
| 454 |
+
"http",
|
| 455 |
+
"http-body",
|
| 456 |
+
"pin-project-lite",
|
| 457 |
+
]
|
| 458 |
+
|
| 459 |
+
[[package]]
|
| 460 |
+
name = "http-range-header"
|
| 461 |
+
version = "0.4.2"
|
| 462 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 463 |
+
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
| 464 |
+
|
| 465 |
+
[[package]]
|
| 466 |
+
name = "httparse"
|
| 467 |
+
version = "1.10.1"
|
| 468 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 469 |
+
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
| 470 |
+
|
| 471 |
+
[[package]]
|
| 472 |
+
name = "httpdate"
|
| 473 |
+
version = "1.0.3"
|
| 474 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 475 |
+
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
| 476 |
+
|
| 477 |
+
[[package]]
|
| 478 |
+
name = "hyper"
|
| 479 |
+
version = "1.8.1"
|
| 480 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 481 |
+
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
|
| 482 |
+
dependencies = [
|
| 483 |
+
"atomic-waker",
|
| 484 |
+
"bytes",
|
| 485 |
+
"futures-channel",
|
| 486 |
+
"futures-core",
|
| 487 |
+
"http",
|
| 488 |
+
"http-body",
|
| 489 |
+
"httparse",
|
| 490 |
+
"httpdate",
|
| 491 |
+
"itoa",
|
| 492 |
+
"pin-project-lite",
|
| 493 |
+
"pin-utils",
|
| 494 |
+
"smallvec",
|
| 495 |
+
"tokio",
|
| 496 |
+
"want",
|
| 497 |
+
]
|
| 498 |
+
|
| 499 |
+
[[package]]
|
| 500 |
+
name = "hyper-rustls"
|
| 501 |
+
version = "0.27.7"
|
| 502 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 503 |
+
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
| 504 |
+
dependencies = [
|
| 505 |
+
"http",
|
| 506 |
+
"hyper",
|
| 507 |
+
"hyper-util",
|
| 508 |
+
"rustls",
|
| 509 |
+
"rustls-pki-types",
|
| 510 |
+
"tokio",
|
| 511 |
+
"tokio-rustls",
|
| 512 |
+
"tower-service",
|
| 513 |
+
"webpki-roots",
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
[[package]]
|
| 517 |
+
name = "hyper-util"
|
| 518 |
+
version = "0.1.19"
|
| 519 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 520 |
+
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
| 521 |
+
dependencies = [
|
| 522 |
+
"base64",
|
| 523 |
+
"bytes",
|
| 524 |
+
"futures-channel",
|
| 525 |
+
"futures-core",
|
| 526 |
+
"futures-util",
|
| 527 |
+
"http",
|
| 528 |
+
"http-body",
|
| 529 |
+
"hyper",
|
| 530 |
+
"ipnet",
|
| 531 |
+
"libc",
|
| 532 |
+
"percent-encoding",
|
| 533 |
+
"pin-project-lite",
|
| 534 |
+
"socket2",
|
| 535 |
+
"tokio",
|
| 536 |
+
"tower-service",
|
| 537 |
+
"tracing",
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
[[package]]
|
| 541 |
+
name = "iana-time-zone"
|
| 542 |
+
version = "0.1.64"
|
| 543 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 544 |
+
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
|
| 545 |
+
dependencies = [
|
| 546 |
+
"android_system_properties",
|
| 547 |
+
"core-foundation-sys",
|
| 548 |
+
"iana-time-zone-haiku",
|
| 549 |
+
"js-sys",
|
| 550 |
+
"log",
|
| 551 |
+
"wasm-bindgen",
|
| 552 |
+
"windows-core",
|
| 553 |
+
]
|
| 554 |
+
|
| 555 |
+
[[package]]
|
| 556 |
+
name = "iana-time-zone-haiku"
|
| 557 |
+
version = "0.1.2"
|
| 558 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 559 |
+
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
| 560 |
+
dependencies = [
|
| 561 |
+
"cc",
|
| 562 |
+
]
|
| 563 |
+
|
| 564 |
+
[[package]]
|
| 565 |
+
name = "icu_collections"
|
| 566 |
+
version = "2.1.1"
|
| 567 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 568 |
+
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
|
| 569 |
+
dependencies = [
|
| 570 |
+
"displaydoc",
|
| 571 |
+
"potential_utf",
|
| 572 |
+
"yoke",
|
| 573 |
+
"zerofrom",
|
| 574 |
+
"zerovec",
|
| 575 |
+
]
|
| 576 |
+
|
| 577 |
+
[[package]]
|
| 578 |
+
name = "icu_locale_core"
|
| 579 |
+
version = "2.1.1"
|
| 580 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 581 |
+
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
|
| 582 |
+
dependencies = [
|
| 583 |
+
"displaydoc",
|
| 584 |
+
"litemap",
|
| 585 |
+
"tinystr",
|
| 586 |
+
"writeable",
|
| 587 |
+
"zerovec",
|
| 588 |
+
]
|
| 589 |
+
|
| 590 |
+
[[package]]
|
| 591 |
+
name = "icu_normalizer"
|
| 592 |
+
version = "2.1.1"
|
| 593 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 594 |
+
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
|
| 595 |
+
dependencies = [
|
| 596 |
+
"icu_collections",
|
| 597 |
+
"icu_normalizer_data",
|
| 598 |
+
"icu_properties",
|
| 599 |
+
"icu_provider",
|
| 600 |
+
"smallvec",
|
| 601 |
+
"zerovec",
|
| 602 |
+
]
|
| 603 |
+
|
| 604 |
+
[[package]]
|
| 605 |
+
name = "icu_normalizer_data"
|
| 606 |
+
version = "2.1.1"
|
| 607 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 608 |
+
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
|
| 609 |
+
|
| 610 |
+
[[package]]
|
| 611 |
+
name = "icu_properties"
|
| 612 |
+
version = "2.1.2"
|
| 613 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 614 |
+
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
|
| 615 |
+
dependencies = [
|
| 616 |
+
"icu_collections",
|
| 617 |
+
"icu_locale_core",
|
| 618 |
+
"icu_properties_data",
|
| 619 |
+
"icu_provider",
|
| 620 |
+
"zerotrie",
|
| 621 |
+
"zerovec",
|
| 622 |
+
]
|
| 623 |
+
|
| 624 |
+
[[package]]
|
| 625 |
+
name = "icu_properties_data"
|
| 626 |
+
version = "2.1.2"
|
| 627 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 628 |
+
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
|
| 629 |
+
|
| 630 |
+
[[package]]
|
| 631 |
+
name = "icu_provider"
|
| 632 |
+
version = "2.1.1"
|
| 633 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 634 |
+
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
|
| 635 |
+
dependencies = [
|
| 636 |
+
"displaydoc",
|
| 637 |
+
"icu_locale_core",
|
| 638 |
+
"writeable",
|
| 639 |
+
"yoke",
|
| 640 |
+
"zerofrom",
|
| 641 |
+
"zerotrie",
|
| 642 |
+
"zerovec",
|
| 643 |
+
]
|
| 644 |
+
|
| 645 |
+
[[package]]
|
| 646 |
+
name = "idna"
|
| 647 |
+
version = "1.1.0"
|
| 648 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 649 |
+
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
| 650 |
+
dependencies = [
|
| 651 |
+
"idna_adapter",
|
| 652 |
+
"smallvec",
|
| 653 |
+
"utf8_iter",
|
| 654 |
+
]
|
| 655 |
+
|
| 656 |
+
[[package]]
|
| 657 |
+
name = "idna_adapter"
|
| 658 |
+
version = "1.2.1"
|
| 659 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 660 |
+
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
|
| 661 |
+
dependencies = [
|
| 662 |
+
"icu_normalizer",
|
| 663 |
+
"icu_properties",
|
| 664 |
+
]
|
| 665 |
+
|
| 666 |
+
[[package]]
|
| 667 |
+
name = "indexmap"
|
| 668 |
+
version = "2.12.1"
|
| 669 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 670 |
+
checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
|
| 671 |
+
dependencies = [
|
| 672 |
+
"equivalent",
|
| 673 |
+
"hashbrown",
|
| 674 |
+
"serde",
|
| 675 |
+
"serde_core",
|
| 676 |
+
]
|
| 677 |
+
|
| 678 |
+
[[package]]
|
| 679 |
+
name = "ipnet"
|
| 680 |
+
version = "2.11.0"
|
| 681 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 682 |
+
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
| 683 |
+
|
| 684 |
+
[[package]]
|
| 685 |
+
name = "iri-string"
|
| 686 |
+
version = "0.7.10"
|
| 687 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 688 |
+
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
| 689 |
+
dependencies = [
|
| 690 |
+
"memchr",
|
| 691 |
+
"serde",
|
| 692 |
+
]
|
| 693 |
+
|
| 694 |
+
[[package]]
|
| 695 |
+
name = "itoa"
|
| 696 |
+
version = "1.0.17"
|
| 697 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 698 |
+
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
| 699 |
+
|
| 700 |
+
[[package]]
|
| 701 |
+
name = "js-sys"
|
| 702 |
+
version = "0.3.83"
|
| 703 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 704 |
+
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
|
| 705 |
+
dependencies = [
|
| 706 |
+
"once_cell",
|
| 707 |
+
"wasm-bindgen",
|
| 708 |
+
]
|
| 709 |
+
|
| 710 |
+
[[package]]
|
| 711 |
+
name = "lazy_static"
|
| 712 |
+
version = "1.5.0"
|
| 713 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 714 |
+
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
| 715 |
+
|
| 716 |
+
[[package]]
|
| 717 |
+
name = "libc"
|
| 718 |
+
version = "0.2.179"
|
| 719 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 720 |
+
checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
|
| 721 |
+
|
| 722 |
+
[[package]]
|
| 723 |
+
name = "libz-rs-sys"
|
| 724 |
+
version = "0.5.5"
|
| 725 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 726 |
+
checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
|
| 727 |
+
dependencies = [
|
| 728 |
+
"zlib-rs",
|
| 729 |
+
]
|
| 730 |
+
|
| 731 |
+
[[package]]
|
| 732 |
+
name = "litemap"
|
| 733 |
+
version = "0.8.1"
|
| 734 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 735 |
+
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
| 736 |
+
|
| 737 |
+
[[package]]
|
| 738 |
+
name = "lock_api"
|
| 739 |
+
version = "0.4.14"
|
| 740 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 741 |
+
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
| 742 |
+
dependencies = [
|
| 743 |
+
"scopeguard",
|
| 744 |
+
]
|
| 745 |
+
|
| 746 |
+
[[package]]
|
| 747 |
+
name = "log"
|
| 748 |
+
version = "0.4.29"
|
| 749 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 750 |
+
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
| 751 |
+
|
| 752 |
+
[[package]]
|
| 753 |
+
name = "lru-slab"
|
| 754 |
+
version = "0.1.2"
|
| 755 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 756 |
+
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
| 757 |
+
|
| 758 |
+
[[package]]
|
| 759 |
+
name = "matchers"
|
| 760 |
+
version = "0.2.0"
|
| 761 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 762 |
+
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
| 763 |
+
dependencies = [
|
| 764 |
+
"regex-automata",
|
| 765 |
+
]
|
| 766 |
+
|
| 767 |
+
[[package]]
|
| 768 |
+
name = "matchit"
|
| 769 |
+
version = "0.8.4"
|
| 770 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 771 |
+
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
| 772 |
+
|
| 773 |
+
[[package]]
|
| 774 |
+
name = "memchr"
|
| 775 |
+
version = "2.7.6"
|
| 776 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 777 |
+
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
| 778 |
+
|
| 779 |
+
[[package]]
|
| 780 |
+
name = "mime"
|
| 781 |
+
version = "0.3.17"
|
| 782 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 783 |
+
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
| 784 |
+
|
| 785 |
+
[[package]]
|
| 786 |
+
name = "mime_guess"
|
| 787 |
+
version = "2.0.5"
|
| 788 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 789 |
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
| 790 |
+
dependencies = [
|
| 791 |
+
"mime",
|
| 792 |
+
"unicase",
|
| 793 |
+
]
|
| 794 |
+
|
| 795 |
+
[[package]]
|
| 796 |
+
name = "miniz_oxide"
|
| 797 |
+
version = "0.8.9"
|
| 798 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 799 |
+
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
| 800 |
+
dependencies = [
|
| 801 |
+
"adler2",
|
| 802 |
+
"simd-adler32",
|
| 803 |
+
]
|
| 804 |
+
|
| 805 |
+
[[package]]
|
| 806 |
+
name = "mio"
|
| 807 |
+
version = "1.1.1"
|
| 808 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 809 |
+
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
| 810 |
+
dependencies = [
|
| 811 |
+
"libc",
|
| 812 |
+
"wasi",
|
| 813 |
+
"windows-sys 0.61.2",
|
| 814 |
+
]
|
| 815 |
+
|
| 816 |
+
[[package]]
|
| 817 |
+
name = "nu-ansi-term"
|
| 818 |
+
version = "0.50.3"
|
| 819 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 820 |
+
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
| 821 |
+
dependencies = [
|
| 822 |
+
"windows-sys 0.61.2",
|
| 823 |
+
]
|
| 824 |
+
|
| 825 |
+
[[package]]
|
| 826 |
+
name = "num-format"
|
| 827 |
+
version = "0.4.4"
|
| 828 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 829 |
+
checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
|
| 830 |
+
dependencies = [
|
| 831 |
+
"arrayvec",
|
| 832 |
+
"itoa",
|
| 833 |
+
]
|
| 834 |
+
|
| 835 |
+
[[package]]
|
| 836 |
+
name = "num-traits"
|
| 837 |
+
version = "0.2.19"
|
| 838 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 839 |
+
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
| 840 |
+
dependencies = [
|
| 841 |
+
"autocfg",
|
| 842 |
+
]
|
| 843 |
+
|
| 844 |
+
[[package]]
|
| 845 |
+
name = "once_cell"
|
| 846 |
+
version = "1.21.3"
|
| 847 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 848 |
+
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
| 849 |
+
|
| 850 |
+
[[package]]
|
| 851 |
+
name = "ordered-float"
|
| 852 |
+
version = "4.6.0"
|
| 853 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 854 |
+
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
|
| 855 |
+
dependencies = [
|
| 856 |
+
"num-traits",
|
| 857 |
+
]
|
| 858 |
+
|
| 859 |
+
[[package]]
|
| 860 |
+
name = "owo-colors"
|
| 861 |
+
version = "4.2.3"
|
| 862 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 863 |
+
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
|
| 864 |
+
|
| 865 |
+
[[package]]
|
| 866 |
+
name = "parking_lot"
|
| 867 |
+
version = "0.12.5"
|
| 868 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 869 |
+
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
| 870 |
+
dependencies = [
|
| 871 |
+
"lock_api",
|
| 872 |
+
"parking_lot_core",
|
| 873 |
+
]
|
| 874 |
+
|
| 875 |
+
[[package]]
|
| 876 |
+
name = "parking_lot_core"
|
| 877 |
+
version = "0.9.12"
|
| 878 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 879 |
+
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
| 880 |
+
dependencies = [
|
| 881 |
+
"cfg-if",
|
| 882 |
+
"libc",
|
| 883 |
+
"redox_syscall",
|
| 884 |
+
"smallvec",
|
| 885 |
+
"windows-link",
|
| 886 |
+
]
|
| 887 |
+
|
| 888 |
+
[[package]]
|
| 889 |
+
name = "percent-encoding"
|
| 890 |
+
version = "2.3.2"
|
| 891 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 892 |
+
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
| 893 |
+
|
| 894 |
+
[[package]]
|
| 895 |
+
name = "petgraph"
|
| 896 |
+
version = "0.6.5"
|
| 897 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 898 |
+
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
|
| 899 |
+
dependencies = [
|
| 900 |
+
"fixedbitset",
|
| 901 |
+
"indexmap",
|
| 902 |
+
]
|
| 903 |
+
|
| 904 |
+
[[package]]
|
| 905 |
+
name = "pin-project-lite"
|
| 906 |
+
version = "0.2.16"
|
| 907 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 908 |
+
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
| 909 |
+
|
| 910 |
+
[[package]]
|
| 911 |
+
name = "pin-utils"
|
| 912 |
+
version = "0.1.0"
|
| 913 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 914 |
+
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
| 915 |
+
|
| 916 |
+
[[package]]
|
| 917 |
+
name = "potential_utf"
|
| 918 |
+
version = "0.1.4"
|
| 919 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 920 |
+
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
|
| 921 |
+
dependencies = [
|
| 922 |
+
"zerovec",
|
| 923 |
+
]
|
| 924 |
+
|
| 925 |
+
[[package]]
|
| 926 |
+
name = "ppv-lite86"
|
| 927 |
+
version = "0.2.21"
|
| 928 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 929 |
+
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
| 930 |
+
dependencies = [
|
| 931 |
+
"zerocopy",
|
| 932 |
+
]
|
| 933 |
+
|
| 934 |
+
[[package]]
|
| 935 |
+
name = "proc-macro2"
|
| 936 |
+
version = "1.0.104"
|
| 937 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 938 |
+
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
| 939 |
+
dependencies = [
|
| 940 |
+
"unicode-ident",
|
| 941 |
+
]
|
| 942 |
+
|
| 943 |
+
[[package]]
|
| 944 |
+
name = "quinn"
|
| 945 |
+
version = "0.11.9"
|
| 946 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 947 |
+
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
| 948 |
+
dependencies = [
|
| 949 |
+
"bytes",
|
| 950 |
+
"cfg_aliases",
|
| 951 |
+
"pin-project-lite",
|
| 952 |
+
"quinn-proto",
|
| 953 |
+
"quinn-udp",
|
| 954 |
+
"rustc-hash",
|
| 955 |
+
"rustls",
|
| 956 |
+
"socket2",
|
| 957 |
+
"thiserror",
|
| 958 |
+
"tokio",
|
| 959 |
+
"tracing",
|
| 960 |
+
"web-time",
|
| 961 |
+
]
|
| 962 |
+
|
| 963 |
+
[[package]]
|
| 964 |
+
name = "quinn-proto"
|
| 965 |
+
version = "0.11.13"
|
| 966 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 967 |
+
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
| 968 |
+
dependencies = [
|
| 969 |
+
"bytes",
|
| 970 |
+
"getrandom 0.3.4",
|
| 971 |
+
"lru-slab",
|
| 972 |
+
"rand 0.9.2",
|
| 973 |
+
"ring",
|
| 974 |
+
"rustc-hash",
|
| 975 |
+
"rustls",
|
| 976 |
+
"rustls-pki-types",
|
| 977 |
+
"slab",
|
| 978 |
+
"thiserror",
|
| 979 |
+
"tinyvec",
|
| 980 |
+
"tracing",
|
| 981 |
+
"web-time",
|
| 982 |
+
]
|
| 983 |
+
|
| 984 |
+
[[package]]
|
| 985 |
+
name = "quinn-udp"
|
| 986 |
+
version = "0.5.14"
|
| 987 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 988 |
+
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
| 989 |
+
dependencies = [
|
| 990 |
+
"cfg_aliases",
|
| 991 |
+
"libc",
|
| 992 |
+
"once_cell",
|
| 993 |
+
"socket2",
|
| 994 |
+
"tracing",
|
| 995 |
+
"windows-sys 0.60.2",
|
| 996 |
+
]
|
| 997 |
+
|
| 998 |
+
[[package]]
|
| 999 |
+
name = "quote"
|
| 1000 |
+
version = "1.0.42"
|
| 1001 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1002 |
+
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
| 1003 |
+
dependencies = [
|
| 1004 |
+
"proc-macro2",
|
| 1005 |
+
]
|
| 1006 |
+
|
| 1007 |
+
[[package]]
|
| 1008 |
+
name = "r-efi"
|
| 1009 |
+
version = "5.3.0"
|
| 1010 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1011 |
+
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
| 1012 |
+
|
| 1013 |
+
[[package]]
|
| 1014 |
+
name = "rand"
|
| 1015 |
+
version = "0.8.5"
|
| 1016 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1017 |
+
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
| 1018 |
+
dependencies = [
|
| 1019 |
+
"libc",
|
| 1020 |
+
"rand_chacha 0.3.1",
|
| 1021 |
+
"rand_core 0.6.4",
|
| 1022 |
+
]
|
| 1023 |
+
|
| 1024 |
+
[[package]]
|
| 1025 |
+
name = "rand"
|
| 1026 |
+
version = "0.9.2"
|
| 1027 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1028 |
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
| 1029 |
+
dependencies = [
|
| 1030 |
+
"rand_chacha 0.9.0",
|
| 1031 |
+
"rand_core 0.9.3",
|
| 1032 |
+
]
|
| 1033 |
+
|
| 1034 |
+
[[package]]
|
| 1035 |
+
name = "rand_chacha"
|
| 1036 |
+
version = "0.3.1"
|
| 1037 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1038 |
+
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
| 1039 |
+
dependencies = [
|
| 1040 |
+
"ppv-lite86",
|
| 1041 |
+
"rand_core 0.6.4",
|
| 1042 |
+
]
|
| 1043 |
+
|
| 1044 |
+
[[package]]
|
| 1045 |
+
name = "rand_chacha"
|
| 1046 |
+
version = "0.9.0"
|
| 1047 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1048 |
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
| 1049 |
+
dependencies = [
|
| 1050 |
+
"ppv-lite86",
|
| 1051 |
+
"rand_core 0.9.3",
|
| 1052 |
+
]
|
| 1053 |
+
|
| 1054 |
+
[[package]]
|
| 1055 |
+
name = "rand_core"
|
| 1056 |
+
version = "0.6.4"
|
| 1057 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1058 |
+
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
| 1059 |
+
dependencies = [
|
| 1060 |
+
"getrandom 0.2.16",
|
| 1061 |
+
]
|
| 1062 |
+
|
| 1063 |
+
[[package]]
|
| 1064 |
+
name = "rand_core"
|
| 1065 |
+
version = "0.9.3"
|
| 1066 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1067 |
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
| 1068 |
+
dependencies = [
|
| 1069 |
+
"getrandom 0.3.4",
|
| 1070 |
+
]
|
| 1071 |
+
|
| 1072 |
+
[[package]]
|
| 1073 |
+
name = "rayon"
|
| 1074 |
+
version = "1.11.0"
|
| 1075 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1076 |
+
checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
|
| 1077 |
+
dependencies = [
|
| 1078 |
+
"either",
|
| 1079 |
+
"rayon-core",
|
| 1080 |
+
]
|
| 1081 |
+
|
| 1082 |
+
[[package]]
|
| 1083 |
+
name = "rayon-core"
|
| 1084 |
+
version = "1.13.0"
|
| 1085 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1086 |
+
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
| 1087 |
+
dependencies = [
|
| 1088 |
+
"crossbeam-deque",
|
| 1089 |
+
"crossbeam-utils",
|
| 1090 |
+
]
|
| 1091 |
+
|
| 1092 |
+
[[package]]
|
| 1093 |
+
name = "redox_syscall"
|
| 1094 |
+
version = "0.5.18"
|
| 1095 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1096 |
+
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
| 1097 |
+
dependencies = [
|
| 1098 |
+
"bitflags",
|
| 1099 |
+
]
|
| 1100 |
+
|
| 1101 |
+
[[package]]
|
| 1102 |
+
name = "regex"
|
| 1103 |
+
version = "1.12.2"
|
| 1104 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1105 |
+
checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
|
| 1106 |
+
dependencies = [
|
| 1107 |
+
"aho-corasick",
|
| 1108 |
+
"memchr",
|
| 1109 |
+
"regex-automata",
|
| 1110 |
+
"regex-syntax",
|
| 1111 |
+
]
|
| 1112 |
+
|
| 1113 |
+
[[package]]
|
| 1114 |
+
name = "regex-automata"
|
| 1115 |
+
version = "0.4.13"
|
| 1116 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1117 |
+
checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
|
| 1118 |
+
dependencies = [
|
| 1119 |
+
"aho-corasick",
|
| 1120 |
+
"memchr",
|
| 1121 |
+
"regex-syntax",
|
| 1122 |
+
]
|
| 1123 |
+
|
| 1124 |
+
[[package]]
|
| 1125 |
+
name = "regex-syntax"
|
| 1126 |
+
version = "0.8.8"
|
| 1127 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1128 |
+
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
| 1129 |
+
|
| 1130 |
+
[[package]]
|
| 1131 |
+
name = "reqwest"
|
| 1132 |
+
version = "0.12.28"
|
| 1133 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1134 |
+
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
| 1135 |
+
dependencies = [
|
| 1136 |
+
"base64",
|
| 1137 |
+
"bytes",
|
| 1138 |
+
"futures-core",
|
| 1139 |
+
"http",
|
| 1140 |
+
"http-body",
|
| 1141 |
+
"http-body-util",
|
| 1142 |
+
"hyper",
|
| 1143 |
+
"hyper-rustls",
|
| 1144 |
+
"hyper-util",
|
| 1145 |
+
"js-sys",
|
| 1146 |
+
"log",
|
| 1147 |
+
"percent-encoding",
|
| 1148 |
+
"pin-project-lite",
|
| 1149 |
+
"quinn",
|
| 1150 |
+
"rustls",
|
| 1151 |
+
"rustls-pki-types",
|
| 1152 |
+
"serde",
|
| 1153 |
+
"serde_json",
|
| 1154 |
+
"serde_urlencoded",
|
| 1155 |
+
"sync_wrapper",
|
| 1156 |
+
"tokio",
|
| 1157 |
+
"tokio-rustls",
|
| 1158 |
+
"tower",
|
| 1159 |
+
"tower-http",
|
| 1160 |
+
"tower-service",
|
| 1161 |
+
"url",
|
| 1162 |
+
"wasm-bindgen",
|
| 1163 |
+
"wasm-bindgen-futures",
|
| 1164 |
+
"web-sys",
|
| 1165 |
+
"webpki-roots",
|
| 1166 |
+
]
|
| 1167 |
+
|
| 1168 |
+
[[package]]
|
| 1169 |
+
name = "ring"
|
| 1170 |
+
version = "0.17.14"
|
| 1171 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1172 |
+
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
| 1173 |
+
dependencies = [
|
| 1174 |
+
"cc",
|
| 1175 |
+
"cfg-if",
|
| 1176 |
+
"getrandom 0.2.16",
|
| 1177 |
+
"libc",
|
| 1178 |
+
"untrusted",
|
| 1179 |
+
"windows-sys 0.52.0",
|
| 1180 |
+
]
|
| 1181 |
+
|
| 1182 |
+
[[package]]
|
| 1183 |
+
name = "rust-embed"
|
| 1184 |
+
version = "8.9.0"
|
| 1185 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1186 |
+
checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca"
|
| 1187 |
+
dependencies = [
|
| 1188 |
+
"rust-embed-impl",
|
| 1189 |
+
"rust-embed-utils",
|
| 1190 |
+
"walkdir",
|
| 1191 |
+
]
|
| 1192 |
+
|
| 1193 |
+
[[package]]
|
| 1194 |
+
name = "rust-embed-impl"
|
| 1195 |
+
version = "8.9.0"
|
| 1196 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1197 |
+
checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2"
|
| 1198 |
+
dependencies = [
|
| 1199 |
+
"proc-macro2",
|
| 1200 |
+
"quote",
|
| 1201 |
+
"rust-embed-utils",
|
| 1202 |
+
"syn",
|
| 1203 |
+
"walkdir",
|
| 1204 |
+
]
|
| 1205 |
+
|
| 1206 |
+
[[package]]
|
| 1207 |
+
name = "rust-embed-utils"
|
| 1208 |
+
version = "8.9.0"
|
| 1209 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1210 |
+
checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475"
|
| 1211 |
+
dependencies = [
|
| 1212 |
+
"sha2",
|
| 1213 |
+
"walkdir",
|
| 1214 |
+
]
|
| 1215 |
+
|
| 1216 |
+
[[package]]
|
| 1217 |
+
name = "rustc-hash"
|
| 1218 |
+
version = "2.1.1"
|
| 1219 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1220 |
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
| 1221 |
+
|
| 1222 |
+
[[package]]
|
| 1223 |
+
name = "rustls"
|
| 1224 |
+
version = "0.23.35"
|
| 1225 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1226 |
+
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
| 1227 |
+
dependencies = [
|
| 1228 |
+
"once_cell",
|
| 1229 |
+
"ring",
|
| 1230 |
+
"rustls-pki-types",
|
| 1231 |
+
"rustls-webpki",
|
| 1232 |
+
"subtle",
|
| 1233 |
+
"zeroize",
|
| 1234 |
+
]
|
| 1235 |
+
|
| 1236 |
+
[[package]]
|
| 1237 |
+
name = "rustls-pki-types"
|
| 1238 |
+
version = "1.13.2"
|
| 1239 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1240 |
+
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
| 1241 |
+
dependencies = [
|
| 1242 |
+
"web-time",
|
| 1243 |
+
"zeroize",
|
| 1244 |
+
]
|
| 1245 |
+
|
| 1246 |
+
[[package]]
|
| 1247 |
+
name = "rustls-webpki"
|
| 1248 |
+
version = "0.103.8"
|
| 1249 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1250 |
+
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
| 1251 |
+
dependencies = [
|
| 1252 |
+
"ring",
|
| 1253 |
+
"rustls-pki-types",
|
| 1254 |
+
"untrusted",
|
| 1255 |
+
]
|
| 1256 |
+
|
| 1257 |
+
[[package]]
|
| 1258 |
+
name = "rustversion"
|
| 1259 |
+
version = "1.0.22"
|
| 1260 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1261 |
+
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
| 1262 |
+
|
| 1263 |
+
[[package]]
|
| 1264 |
+
name = "ryu"
|
| 1265 |
+
version = "1.0.22"
|
| 1266 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1267 |
+
checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
|
| 1268 |
+
|
| 1269 |
+
[[package]]
|
| 1270 |
+
name = "same-file"
|
| 1271 |
+
version = "1.0.6"
|
| 1272 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1273 |
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
| 1274 |
+
dependencies = [
|
| 1275 |
+
"winapi-util",
|
| 1276 |
+
]
|
| 1277 |
+
|
| 1278 |
+
[[package]]
|
| 1279 |
+
name = "scopeguard"
|
| 1280 |
+
version = "1.2.0"
|
| 1281 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1282 |
+
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
| 1283 |
+
|
| 1284 |
+
[[package]]
|
| 1285 |
+
name = "serde"
|
| 1286 |
+
version = "1.0.228"
|
| 1287 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1288 |
+
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
| 1289 |
+
dependencies = [
|
| 1290 |
+
"serde_core",
|
| 1291 |
+
"serde_derive",
|
| 1292 |
+
]
|
| 1293 |
+
|
| 1294 |
+
[[package]]
|
| 1295 |
+
name = "serde_core"
|
| 1296 |
+
version = "1.0.228"
|
| 1297 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1298 |
+
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
| 1299 |
+
dependencies = [
|
| 1300 |
+
"serde_derive",
|
| 1301 |
+
]
|
| 1302 |
+
|
| 1303 |
+
[[package]]
|
| 1304 |
+
name = "serde_derive"
|
| 1305 |
+
version = "1.0.228"
|
| 1306 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1307 |
+
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
| 1308 |
+
dependencies = [
|
| 1309 |
+
"proc-macro2",
|
| 1310 |
+
"quote",
|
| 1311 |
+
"syn",
|
| 1312 |
+
]
|
| 1313 |
+
|
| 1314 |
+
[[package]]
|
| 1315 |
+
name = "serde_json"
|
| 1316 |
+
version = "1.0.148"
|
| 1317 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1318 |
+
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
| 1319 |
+
dependencies = [
|
| 1320 |
+
"itoa",
|
| 1321 |
+
"memchr",
|
| 1322 |
+
"serde",
|
| 1323 |
+
"serde_core",
|
| 1324 |
+
"zmij",
|
| 1325 |
+
]
|
| 1326 |
+
|
| 1327 |
+
[[package]]
|
| 1328 |
+
name = "serde_path_to_error"
|
| 1329 |
+
version = "0.1.20"
|
| 1330 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1331 |
+
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
| 1332 |
+
dependencies = [
|
| 1333 |
+
"itoa",
|
| 1334 |
+
"serde",
|
| 1335 |
+
"serde_core",
|
| 1336 |
+
]
|
| 1337 |
+
|
| 1338 |
+
[[package]]
|
| 1339 |
+
name = "serde_spanned"
|
| 1340 |
+
version = "0.6.9"
|
| 1341 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1342 |
+
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
| 1343 |
+
dependencies = [
|
| 1344 |
+
"serde",
|
| 1345 |
+
]
|
| 1346 |
+
|
| 1347 |
+
[[package]]
|
| 1348 |
+
name = "serde_urlencoded"
|
| 1349 |
+
version = "0.7.1"
|
| 1350 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1351 |
+
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
| 1352 |
+
dependencies = [
|
| 1353 |
+
"form_urlencoded",
|
| 1354 |
+
"itoa",
|
| 1355 |
+
"ryu",
|
| 1356 |
+
"serde",
|
| 1357 |
+
]
|
| 1358 |
+
|
| 1359 |
+
[[package]]
|
| 1360 |
+
name = "serde_yaml"
|
| 1361 |
+
version = "0.9.34+deprecated"
|
| 1362 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1363 |
+
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
| 1364 |
+
dependencies = [
|
| 1365 |
+
"indexmap",
|
| 1366 |
+
"itoa",
|
| 1367 |
+
"ryu",
|
| 1368 |
+
"serde",
|
| 1369 |
+
"unsafe-libyaml",
|
| 1370 |
+
]
|
| 1371 |
+
|
| 1372 |
+
[[package]]
|
| 1373 |
+
name = "sha2"
|
| 1374 |
+
version = "0.10.9"
|
| 1375 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1376 |
+
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
| 1377 |
+
dependencies = [
|
| 1378 |
+
"cfg-if",
|
| 1379 |
+
"cpufeatures",
|
| 1380 |
+
"digest",
|
| 1381 |
+
]
|
| 1382 |
+
|
| 1383 |
+
[[package]]
|
| 1384 |
+
name = "sharded-slab"
|
| 1385 |
+
version = "0.1.7"
|
| 1386 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1387 |
+
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
| 1388 |
+
dependencies = [
|
| 1389 |
+
"lazy_static",
|
| 1390 |
+
]
|
| 1391 |
+
|
| 1392 |
+
[[package]]
|
| 1393 |
+
name = "shlex"
|
| 1394 |
+
version = "1.3.0"
|
| 1395 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1396 |
+
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
| 1397 |
+
|
| 1398 |
+
[[package]]
|
| 1399 |
+
name = "signal-hook-registry"
|
| 1400 |
+
version = "1.4.8"
|
| 1401 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1402 |
+
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
| 1403 |
+
dependencies = [
|
| 1404 |
+
"errno",
|
| 1405 |
+
"libc",
|
| 1406 |
+
]
|
| 1407 |
+
|
| 1408 |
+
[[package]]
|
| 1409 |
+
name = "simd-adler32"
|
| 1410 |
+
version = "0.3.8"
|
| 1411 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1412 |
+
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
| 1413 |
+
|
| 1414 |
+
[[package]]
|
| 1415 |
+
name = "slab"
|
| 1416 |
+
version = "0.4.11"
|
| 1417 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1418 |
+
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
|
| 1419 |
+
|
| 1420 |
+
[[package]]
|
| 1421 |
+
name = "smallvec"
|
| 1422 |
+
version = "1.15.1"
|
| 1423 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1424 |
+
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
| 1425 |
+
|
| 1426 |
+
[[package]]
|
| 1427 |
+
name = "socket2"
|
| 1428 |
+
version = "0.6.1"
|
| 1429 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1430 |
+
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
|
| 1431 |
+
dependencies = [
|
| 1432 |
+
"libc",
|
| 1433 |
+
"windows-sys 0.60.2",
|
| 1434 |
+
]
|
| 1435 |
+
|
| 1436 |
+
[[package]]
|
| 1437 |
+
name = "solverforge"
|
| 1438 |
+
version = "0.5.1"
|
| 1439 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1440 |
+
checksum = "ebe455ae330096af2f36cdd131051b1c14bd995f258078c9555adcfe20678a8c"
|
| 1441 |
+
dependencies = [
|
| 1442 |
+
"solverforge-config",
|
| 1443 |
+
"solverforge-core",
|
| 1444 |
+
"solverforge-macros",
|
| 1445 |
+
"solverforge-scoring",
|
| 1446 |
+
"solverforge-solver",
|
| 1447 |
+
]
|
| 1448 |
+
|
| 1449 |
+
[[package]]
|
| 1450 |
+
name = "solverforge-config"
|
| 1451 |
+
version = "0.5.1"
|
| 1452 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1453 |
+
checksum = "9e4d590ad6e66553eb1f4013979f0def038555383f88b5b649fcbeb2da51363d"
|
| 1454 |
+
dependencies = [
|
| 1455 |
+
"serde",
|
| 1456 |
+
"serde_yaml",
|
| 1457 |
+
"solverforge-core",
|
| 1458 |
+
"thiserror",
|
| 1459 |
+
"toml",
|
| 1460 |
+
]
|
| 1461 |
+
|
| 1462 |
+
[[package]]
|
| 1463 |
+
name = "solverforge-core"
|
| 1464 |
+
version = "0.5.1"
|
| 1465 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1466 |
+
checksum = "9d58655c54f8b22ff3ddc5a51f5858187794756400de2702601364ca63f1bb98"
|
| 1467 |
+
dependencies = [
|
| 1468 |
+
"num-traits",
|
| 1469 |
+
"serde",
|
| 1470 |
+
"thiserror",
|
| 1471 |
+
]
|
| 1472 |
+
|
| 1473 |
+
[[package]]
|
| 1474 |
+
name = "solverforge-macros"
|
| 1475 |
+
version = "0.5.1"
|
| 1476 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1477 |
+
checksum = "01b0dc41ba7d1a84ee0c5d2d898700710bff6d959f9be37738b523fd65d12284"
|
| 1478 |
+
dependencies = [
|
| 1479 |
+
"proc-macro2",
|
| 1480 |
+
"quote",
|
| 1481 |
+
"syn",
|
| 1482 |
+
]
|
| 1483 |
+
|
| 1484 |
+
[[package]]
|
| 1485 |
+
name = "solverforge-scoring"
|
| 1486 |
+
version = "0.5.1"
|
| 1487 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1488 |
+
checksum = "0c559e0bfd0668b53e67ce525136468ae934ef5e07ef1e52cc734a3c36375a09"
|
| 1489 |
+
dependencies = [
|
| 1490 |
+
"solverforge-core",
|
| 1491 |
+
"thiserror",
|
| 1492 |
+
]
|
| 1493 |
+
|
| 1494 |
+
[[package]]
|
| 1495 |
+
name = "solverforge-solver"
|
| 1496 |
+
version = "0.5.1"
|
| 1497 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1498 |
+
checksum = "a7c2b34de23f929a63721203118bc5856248e768a959f87697ab6585eb5ee20a"
|
| 1499 |
+
dependencies = [
|
| 1500 |
+
"rand 0.9.2",
|
| 1501 |
+
"rand_chacha 0.9.0",
|
| 1502 |
+
"rayon",
|
| 1503 |
+
"serde",
|
| 1504 |
+
"smallvec",
|
| 1505 |
+
"solverforge-config",
|
| 1506 |
+
"solverforge-core",
|
| 1507 |
+
"solverforge-scoring",
|
| 1508 |
+
"thiserror",
|
| 1509 |
+
"tokio",
|
| 1510 |
+
"tracing",
|
| 1511 |
+
]
|
| 1512 |
+
|
| 1513 |
+
[[package]]
|
| 1514 |
+
name = "stable_deref_trait"
|
| 1515 |
+
version = "1.2.1"
|
| 1516 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1517 |
+
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
| 1518 |
+
|
| 1519 |
+
[[package]]
|
| 1520 |
+
name = "subtle"
|
| 1521 |
+
version = "2.6.1"
|
| 1522 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1523 |
+
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
| 1524 |
+
|
| 1525 |
+
[[package]]
|
| 1526 |
+
name = "syn"
|
| 1527 |
+
version = "2.0.113"
|
| 1528 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1529 |
+
checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
|
| 1530 |
+
dependencies = [
|
| 1531 |
+
"proc-macro2",
|
| 1532 |
+
"quote",
|
| 1533 |
+
"unicode-ident",
|
| 1534 |
+
]
|
| 1535 |
+
|
| 1536 |
+
[[package]]
|
| 1537 |
+
name = "sync_wrapper"
|
| 1538 |
+
version = "1.0.2"
|
| 1539 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1540 |
+
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
| 1541 |
+
dependencies = [
|
| 1542 |
+
"futures-core",
|
| 1543 |
+
]
|
| 1544 |
+
|
| 1545 |
+
[[package]]
|
| 1546 |
+
name = "synstructure"
|
| 1547 |
+
version = "0.13.2"
|
| 1548 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1549 |
+
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
| 1550 |
+
dependencies = [
|
| 1551 |
+
"proc-macro2",
|
| 1552 |
+
"quote",
|
| 1553 |
+
"syn",
|
| 1554 |
+
]
|
| 1555 |
+
|
| 1556 |
+
[[package]]
|
| 1557 |
+
name = "thiserror"
|
| 1558 |
+
version = "2.0.17"
|
| 1559 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1560 |
+
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
| 1561 |
+
dependencies = [
|
| 1562 |
+
"thiserror-impl",
|
| 1563 |
+
]
|
| 1564 |
+
|
| 1565 |
+
[[package]]
|
| 1566 |
+
name = "thiserror-impl"
|
| 1567 |
+
version = "2.0.17"
|
| 1568 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1569 |
+
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
| 1570 |
+
dependencies = [
|
| 1571 |
+
"proc-macro2",
|
| 1572 |
+
"quote",
|
| 1573 |
+
"syn",
|
| 1574 |
+
]
|
| 1575 |
+
|
| 1576 |
+
[[package]]
|
| 1577 |
+
name = "thread_local"
|
| 1578 |
+
version = "1.1.9"
|
| 1579 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1580 |
+
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
| 1581 |
+
dependencies = [
|
| 1582 |
+
"cfg-if",
|
| 1583 |
+
]
|
| 1584 |
+
|
| 1585 |
+
[[package]]
|
| 1586 |
+
name = "tinystr"
|
| 1587 |
+
version = "0.8.2"
|
| 1588 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1589 |
+
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
|
| 1590 |
+
dependencies = [
|
| 1591 |
+
"displaydoc",
|
| 1592 |
+
"zerovec",
|
| 1593 |
+
]
|
| 1594 |
+
|
| 1595 |
+
[[package]]
|
| 1596 |
+
name = "tinyvec"
|
| 1597 |
+
version = "1.10.0"
|
| 1598 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1599 |
+
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
|
| 1600 |
+
dependencies = [
|
| 1601 |
+
"tinyvec_macros",
|
| 1602 |
+
]
|
| 1603 |
+
|
| 1604 |
+
[[package]]
|
| 1605 |
+
name = "tinyvec_macros"
|
| 1606 |
+
version = "0.1.1"
|
| 1607 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1608 |
+
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
| 1609 |
+
|
| 1610 |
+
[[package]]
|
| 1611 |
+
name = "tokio"
|
| 1612 |
+
version = "1.49.0"
|
| 1613 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1614 |
+
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
| 1615 |
+
dependencies = [
|
| 1616 |
+
"bytes",
|
| 1617 |
+
"libc",
|
| 1618 |
+
"mio",
|
| 1619 |
+
"parking_lot",
|
| 1620 |
+
"pin-project-lite",
|
| 1621 |
+
"signal-hook-registry",
|
| 1622 |
+
"socket2",
|
| 1623 |
+
"tokio-macros",
|
| 1624 |
+
"windows-sys 0.61.2",
|
| 1625 |
+
]
|
| 1626 |
+
|
| 1627 |
+
[[package]]
|
| 1628 |
+
name = "tokio-macros"
|
| 1629 |
+
version = "2.6.0"
|
| 1630 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1631 |
+
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
| 1632 |
+
dependencies = [
|
| 1633 |
+
"proc-macro2",
|
| 1634 |
+
"quote",
|
| 1635 |
+
"syn",
|
| 1636 |
+
]
|
| 1637 |
+
|
| 1638 |
+
[[package]]
|
| 1639 |
+
name = "tokio-rustls"
|
| 1640 |
+
version = "0.26.4"
|
| 1641 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1642 |
+
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
| 1643 |
+
dependencies = [
|
| 1644 |
+
"rustls",
|
| 1645 |
+
"tokio",
|
| 1646 |
+
]
|
| 1647 |
+
|
| 1648 |
+
[[package]]
|
| 1649 |
+
name = "tokio-stream"
|
| 1650 |
+
version = "0.1.18"
|
| 1651 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1652 |
+
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
| 1653 |
+
dependencies = [
|
| 1654 |
+
"futures-core",
|
| 1655 |
+
"pin-project-lite",
|
| 1656 |
+
"tokio",
|
| 1657 |
+
]
|
| 1658 |
+
|
| 1659 |
+
[[package]]
|
| 1660 |
+
name = "tokio-util"
|
| 1661 |
+
version = "0.7.18"
|
| 1662 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1663 |
+
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
| 1664 |
+
dependencies = [
|
| 1665 |
+
"bytes",
|
| 1666 |
+
"futures-core",
|
| 1667 |
+
"futures-sink",
|
| 1668 |
+
"pin-project-lite",
|
| 1669 |
+
"tokio",
|
| 1670 |
+
]
|
| 1671 |
+
|
| 1672 |
+
[[package]]
|
| 1673 |
+
name = "toml"
|
| 1674 |
+
version = "0.8.23"
|
| 1675 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1676 |
+
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
| 1677 |
+
dependencies = [
|
| 1678 |
+
"serde",
|
| 1679 |
+
"serde_spanned",
|
| 1680 |
+
"toml_datetime",
|
| 1681 |
+
"toml_edit",
|
| 1682 |
+
]
|
| 1683 |
+
|
| 1684 |
+
[[package]]
|
| 1685 |
+
name = "toml_datetime"
|
| 1686 |
+
version = "0.6.11"
|
| 1687 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1688 |
+
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
| 1689 |
+
dependencies = [
|
| 1690 |
+
"serde",
|
| 1691 |
+
]
|
| 1692 |
+
|
| 1693 |
+
[[package]]
|
| 1694 |
+
name = "toml_edit"
|
| 1695 |
+
version = "0.22.27"
|
| 1696 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1697 |
+
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
| 1698 |
+
dependencies = [
|
| 1699 |
+
"indexmap",
|
| 1700 |
+
"serde",
|
| 1701 |
+
"serde_spanned",
|
| 1702 |
+
"toml_datetime",
|
| 1703 |
+
"toml_write",
|
| 1704 |
+
"winnow",
|
| 1705 |
+
]
|
| 1706 |
+
|
| 1707 |
+
[[package]]
|
| 1708 |
+
name = "toml_write"
|
| 1709 |
+
version = "0.1.2"
|
| 1710 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1711 |
+
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
| 1712 |
+
|
| 1713 |
+
[[package]]
|
| 1714 |
+
name = "tower"
|
| 1715 |
+
version = "0.5.2"
|
| 1716 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1717 |
+
checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
|
| 1718 |
+
dependencies = [
|
| 1719 |
+
"futures-core",
|
| 1720 |
+
"futures-util",
|
| 1721 |
+
"pin-project-lite",
|
| 1722 |
+
"sync_wrapper",
|
| 1723 |
+
"tokio",
|
| 1724 |
+
"tower-layer",
|
| 1725 |
+
"tower-service",
|
| 1726 |
+
"tracing",
|
| 1727 |
+
]
|
| 1728 |
+
|
| 1729 |
+
[[package]]
|
| 1730 |
+
name = "tower-http"
|
| 1731 |
+
version = "0.6.8"
|
| 1732 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1733 |
+
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
| 1734 |
+
dependencies = [
|
| 1735 |
+
"bitflags",
|
| 1736 |
+
"bytes",
|
| 1737 |
+
"futures-core",
|
| 1738 |
+
"futures-util",
|
| 1739 |
+
"http",
|
| 1740 |
+
"http-body",
|
| 1741 |
+
"http-body-util",
|
| 1742 |
+
"http-range-header",
|
| 1743 |
+
"httpdate",
|
| 1744 |
+
"iri-string",
|
| 1745 |
+
"mime",
|
| 1746 |
+
"mime_guess",
|
| 1747 |
+
"percent-encoding",
|
| 1748 |
+
"pin-project-lite",
|
| 1749 |
+
"tokio",
|
| 1750 |
+
"tokio-util",
|
| 1751 |
+
"tower",
|
| 1752 |
+
"tower-layer",
|
| 1753 |
+
"tower-service",
|
| 1754 |
+
"tracing",
|
| 1755 |
+
]
|
| 1756 |
+
|
| 1757 |
+
[[package]]
|
| 1758 |
+
name = "tower-layer"
|
| 1759 |
+
version = "0.3.3"
|
| 1760 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1761 |
+
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
| 1762 |
+
|
| 1763 |
+
[[package]]
|
| 1764 |
+
name = "tower-service"
|
| 1765 |
+
version = "0.3.3"
|
| 1766 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1767 |
+
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
| 1768 |
+
|
| 1769 |
+
[[package]]
|
| 1770 |
+
name = "tracing"
|
| 1771 |
+
version = "0.1.44"
|
| 1772 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1773 |
+
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
| 1774 |
+
dependencies = [
|
| 1775 |
+
"log",
|
| 1776 |
+
"pin-project-lite",
|
| 1777 |
+
"tracing-attributes",
|
| 1778 |
+
"tracing-core",
|
| 1779 |
+
]
|
| 1780 |
+
|
| 1781 |
+
[[package]]
|
| 1782 |
+
name = "tracing-attributes"
|
| 1783 |
+
version = "0.1.31"
|
| 1784 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1785 |
+
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
| 1786 |
+
dependencies = [
|
| 1787 |
+
"proc-macro2",
|
| 1788 |
+
"quote",
|
| 1789 |
+
"syn",
|
| 1790 |
+
]
|
| 1791 |
+
|
| 1792 |
+
[[package]]
|
| 1793 |
+
name = "tracing-core"
|
| 1794 |
+
version = "0.1.36"
|
| 1795 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1796 |
+
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
| 1797 |
+
dependencies = [
|
| 1798 |
+
"once_cell",
|
| 1799 |
+
"valuable",
|
| 1800 |
+
]
|
| 1801 |
+
|
| 1802 |
+
[[package]]
|
| 1803 |
+
name = "tracing-log"
|
| 1804 |
+
version = "0.2.0"
|
| 1805 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1806 |
+
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
| 1807 |
+
dependencies = [
|
| 1808 |
+
"log",
|
| 1809 |
+
"once_cell",
|
| 1810 |
+
"tracing-core",
|
| 1811 |
+
]
|
| 1812 |
+
|
| 1813 |
+
[[package]]
|
| 1814 |
+
name = "tracing-subscriber"
|
| 1815 |
+
version = "0.3.22"
|
| 1816 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1817 |
+
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
|
| 1818 |
+
dependencies = [
|
| 1819 |
+
"matchers",
|
| 1820 |
+
"nu-ansi-term",
|
| 1821 |
+
"once_cell",
|
| 1822 |
+
"regex-automata",
|
| 1823 |
+
"sharded-slab",
|
| 1824 |
+
"smallvec",
|
| 1825 |
+
"thread_local",
|
| 1826 |
+
"tracing",
|
| 1827 |
+
"tracing-core",
|
| 1828 |
+
"tracing-log",
|
| 1829 |
+
]
|
| 1830 |
+
|
| 1831 |
+
[[package]]
|
| 1832 |
+
name = "try-lock"
|
| 1833 |
+
version = "0.2.5"
|
| 1834 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1835 |
+
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
| 1836 |
+
|
| 1837 |
+
[[package]]
|
| 1838 |
+
name = "typenum"
|
| 1839 |
+
version = "1.19.0"
|
| 1840 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1841 |
+
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
| 1842 |
+
|
| 1843 |
+
[[package]]
|
| 1844 |
+
name = "unicase"
|
| 1845 |
+
version = "2.8.1"
|
| 1846 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1847 |
+
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
|
| 1848 |
+
|
| 1849 |
+
[[package]]
|
| 1850 |
+
name = "unicode-ident"
|
| 1851 |
+
version = "1.0.22"
|
| 1852 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1853 |
+
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
| 1854 |
+
|
| 1855 |
+
[[package]]
|
| 1856 |
+
name = "unsafe-libyaml"
|
| 1857 |
+
version = "0.2.11"
|
| 1858 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1859 |
+
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
| 1860 |
+
|
| 1861 |
+
[[package]]
|
| 1862 |
+
name = "untrusted"
|
| 1863 |
+
version = "0.9.0"
|
| 1864 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1865 |
+
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
| 1866 |
+
|
| 1867 |
+
[[package]]
|
| 1868 |
+
name = "url"
|
| 1869 |
+
version = "2.5.7"
|
| 1870 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1871 |
+
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
|
| 1872 |
+
dependencies = [
|
| 1873 |
+
"form_urlencoded",
|
| 1874 |
+
"idna",
|
| 1875 |
+
"percent-encoding",
|
| 1876 |
+
"serde",
|
| 1877 |
+
]
|
| 1878 |
+
|
| 1879 |
+
[[package]]
|
| 1880 |
+
name = "utf8_iter"
|
| 1881 |
+
version = "1.0.4"
|
| 1882 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1883 |
+
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
| 1884 |
+
|
| 1885 |
+
[[package]]
|
| 1886 |
+
name = "utoipa"
|
| 1887 |
+
version = "5.4.0"
|
| 1888 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1889 |
+
checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
|
| 1890 |
+
dependencies = [
|
| 1891 |
+
"indexmap",
|
| 1892 |
+
"serde",
|
| 1893 |
+
"serde_json",
|
| 1894 |
+
"utoipa-gen",
|
| 1895 |
+
]
|
| 1896 |
+
|
| 1897 |
+
[[package]]
|
| 1898 |
+
name = "utoipa-gen"
|
| 1899 |
+
version = "5.4.0"
|
| 1900 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1901 |
+
checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
|
| 1902 |
+
dependencies = [
|
| 1903 |
+
"proc-macro2",
|
| 1904 |
+
"quote",
|
| 1905 |
+
"regex",
|
| 1906 |
+
"syn",
|
| 1907 |
+
]
|
| 1908 |
+
|
| 1909 |
+
[[package]]
|
| 1910 |
+
name = "utoipa-swagger-ui"
|
| 1911 |
+
version = "9.0.2"
|
| 1912 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1913 |
+
checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
|
| 1914 |
+
dependencies = [
|
| 1915 |
+
"axum",
|
| 1916 |
+
"base64",
|
| 1917 |
+
"mime_guess",
|
| 1918 |
+
"regex",
|
| 1919 |
+
"rust-embed",
|
| 1920 |
+
"serde",
|
| 1921 |
+
"serde_json",
|
| 1922 |
+
"url",
|
| 1923 |
+
"utoipa",
|
| 1924 |
+
"zip",
|
| 1925 |
+
]
|
| 1926 |
+
|
| 1927 |
+
[[package]]
|
| 1928 |
+
name = "uuid"
|
| 1929 |
+
version = "1.19.0"
|
| 1930 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1931 |
+
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
| 1932 |
+
dependencies = [
|
| 1933 |
+
"getrandom 0.3.4",
|
| 1934 |
+
"js-sys",
|
| 1935 |
+
"serde_core",
|
| 1936 |
+
"wasm-bindgen",
|
| 1937 |
+
]
|
| 1938 |
+
|
| 1939 |
+
[[package]]
|
| 1940 |
+
name = "valuable"
|
| 1941 |
+
version = "0.1.1"
|
| 1942 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1943 |
+
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
| 1944 |
+
|
| 1945 |
+
[[package]]
|
| 1946 |
+
name = "vehicle-routing"
|
| 1947 |
+
version = "0.4.1"
|
| 1948 |
+
dependencies = [
|
| 1949 |
+
"async-stream",
|
| 1950 |
+
"axum",
|
| 1951 |
+
"chrono",
|
| 1952 |
+
"num-format",
|
| 1953 |
+
"ordered-float",
|
| 1954 |
+
"owo-colors",
|
| 1955 |
+
"parking_lot",
|
| 1956 |
+
"petgraph",
|
| 1957 |
+
"rand 0.8.5",
|
| 1958 |
+
"reqwest",
|
| 1959 |
+
"serde",
|
| 1960 |
+
"serde_json",
|
| 1961 |
+
"solverforge",
|
| 1962 |
+
"tokio",
|
| 1963 |
+
"tokio-stream",
|
| 1964 |
+
"tower",
|
| 1965 |
+
"tower-http",
|
| 1966 |
+
"tracing",
|
| 1967 |
+
"tracing-subscriber",
|
| 1968 |
+
"utoipa",
|
| 1969 |
+
"utoipa-swagger-ui",
|
| 1970 |
+
"uuid",
|
| 1971 |
+
]
|
| 1972 |
+
|
| 1973 |
+
[[package]]
|
| 1974 |
+
name = "version_check"
|
| 1975 |
+
version = "0.9.5"
|
| 1976 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1977 |
+
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
| 1978 |
+
|
| 1979 |
+
[[package]]
|
| 1980 |
+
name = "walkdir"
|
| 1981 |
+
version = "2.5.0"
|
| 1982 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1983 |
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
| 1984 |
+
dependencies = [
|
| 1985 |
+
"same-file",
|
| 1986 |
+
"winapi-util",
|
| 1987 |
+
]
|
| 1988 |
+
|
| 1989 |
+
[[package]]
|
| 1990 |
+
name = "want"
|
| 1991 |
+
version = "0.3.1"
|
| 1992 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1993 |
+
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
| 1994 |
+
dependencies = [
|
| 1995 |
+
"try-lock",
|
| 1996 |
+
]
|
| 1997 |
+
|
| 1998 |
+
[[package]]
|
| 1999 |
+
name = "wasi"
|
| 2000 |
+
version = "0.11.1+wasi-snapshot-preview1"
|
| 2001 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2002 |
+
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
| 2003 |
+
|
| 2004 |
+
[[package]]
|
| 2005 |
+
name = "wasip2"
|
| 2006 |
+
version = "1.0.1+wasi-0.2.4"
|
| 2007 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2008 |
+
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
| 2009 |
+
dependencies = [
|
| 2010 |
+
"wit-bindgen",
|
| 2011 |
+
]
|
| 2012 |
+
|
| 2013 |
+
[[package]]
|
| 2014 |
+
name = "wasm-bindgen"
|
| 2015 |
+
version = "0.2.106"
|
| 2016 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2017 |
+
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
|
| 2018 |
+
dependencies = [
|
| 2019 |
+
"cfg-if",
|
| 2020 |
+
"once_cell",
|
| 2021 |
+
"rustversion",
|
| 2022 |
+
"wasm-bindgen-macro",
|
| 2023 |
+
"wasm-bindgen-shared",
|
| 2024 |
+
]
|
| 2025 |
+
|
| 2026 |
+
[[package]]
|
| 2027 |
+
name = "wasm-bindgen-futures"
|
| 2028 |
+
version = "0.4.56"
|
| 2029 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2030 |
+
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
|
| 2031 |
+
dependencies = [
|
| 2032 |
+
"cfg-if",
|
| 2033 |
+
"js-sys",
|
| 2034 |
+
"once_cell",
|
| 2035 |
+
"wasm-bindgen",
|
| 2036 |
+
"web-sys",
|
| 2037 |
+
]
|
| 2038 |
+
|
| 2039 |
+
[[package]]
|
| 2040 |
+
name = "wasm-bindgen-macro"
|
| 2041 |
+
version = "0.2.106"
|
| 2042 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2043 |
+
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
|
| 2044 |
+
dependencies = [
|
| 2045 |
+
"quote",
|
| 2046 |
+
"wasm-bindgen-macro-support",
|
| 2047 |
+
]
|
| 2048 |
+
|
| 2049 |
+
[[package]]
|
| 2050 |
+
name = "wasm-bindgen-macro-support"
|
| 2051 |
+
version = "0.2.106"
|
| 2052 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2053 |
+
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
|
| 2054 |
+
dependencies = [
|
| 2055 |
+
"bumpalo",
|
| 2056 |
+
"proc-macro2",
|
| 2057 |
+
"quote",
|
| 2058 |
+
"syn",
|
| 2059 |
+
"wasm-bindgen-shared",
|
| 2060 |
+
]
|
| 2061 |
+
|
| 2062 |
+
[[package]]
|
| 2063 |
+
name = "wasm-bindgen-shared"
|
| 2064 |
+
version = "0.2.106"
|
| 2065 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2066 |
+
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
|
| 2067 |
+
dependencies = [
|
| 2068 |
+
"unicode-ident",
|
| 2069 |
+
]
|
| 2070 |
+
|
| 2071 |
+
[[package]]
|
| 2072 |
+
name = "web-sys"
|
| 2073 |
+
version = "0.3.83"
|
| 2074 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2075 |
+
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
|
| 2076 |
+
dependencies = [
|
| 2077 |
+
"js-sys",
|
| 2078 |
+
"wasm-bindgen",
|
| 2079 |
+
]
|
| 2080 |
+
|
| 2081 |
+
[[package]]
|
| 2082 |
+
name = "web-time"
|
| 2083 |
+
version = "1.1.0"
|
| 2084 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2085 |
+
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
| 2086 |
+
dependencies = [
|
| 2087 |
+
"js-sys",
|
| 2088 |
+
"wasm-bindgen",
|
| 2089 |
+
]
|
| 2090 |
+
|
| 2091 |
+
[[package]]
|
| 2092 |
+
name = "webpki-roots"
|
| 2093 |
+
version = "1.0.5"
|
| 2094 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2095 |
+
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
|
| 2096 |
+
dependencies = [
|
| 2097 |
+
"rustls-pki-types",
|
| 2098 |
+
]
|
| 2099 |
+
|
| 2100 |
+
[[package]]
|
| 2101 |
+
name = "winapi-util"
|
| 2102 |
+
version = "0.1.11"
|
| 2103 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2104 |
+
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
| 2105 |
+
dependencies = [
|
| 2106 |
+
"windows-sys 0.61.2",
|
| 2107 |
+
]
|
| 2108 |
+
|
| 2109 |
+
[[package]]
|
| 2110 |
+
name = "windows-core"
|
| 2111 |
+
version = "0.62.2"
|
| 2112 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2113 |
+
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
| 2114 |
+
dependencies = [
|
| 2115 |
+
"windows-implement",
|
| 2116 |
+
"windows-interface",
|
| 2117 |
+
"windows-link",
|
| 2118 |
+
"windows-result",
|
| 2119 |
+
"windows-strings",
|
| 2120 |
+
]
|
| 2121 |
+
|
| 2122 |
+
[[package]]
|
| 2123 |
+
name = "windows-implement"
|
| 2124 |
+
version = "0.60.2"
|
| 2125 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2126 |
+
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
| 2127 |
+
dependencies = [
|
| 2128 |
+
"proc-macro2",
|
| 2129 |
+
"quote",
|
| 2130 |
+
"syn",
|
| 2131 |
+
]
|
| 2132 |
+
|
| 2133 |
+
[[package]]
|
| 2134 |
+
name = "windows-interface"
|
| 2135 |
+
version = "0.59.3"
|
| 2136 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2137 |
+
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
| 2138 |
+
dependencies = [
|
| 2139 |
+
"proc-macro2",
|
| 2140 |
+
"quote",
|
| 2141 |
+
"syn",
|
| 2142 |
+
]
|
| 2143 |
+
|
| 2144 |
+
[[package]]
|
| 2145 |
+
name = "windows-link"
|
| 2146 |
+
version = "0.2.1"
|
| 2147 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2148 |
+
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
| 2149 |
+
|
| 2150 |
+
[[package]]
|
| 2151 |
+
name = "windows-result"
|
| 2152 |
+
version = "0.4.1"
|
| 2153 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2154 |
+
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
| 2155 |
+
dependencies = [
|
| 2156 |
+
"windows-link",
|
| 2157 |
+
]
|
| 2158 |
+
|
| 2159 |
+
[[package]]
|
| 2160 |
+
name = "windows-strings"
|
| 2161 |
+
version = "0.5.1"
|
| 2162 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2163 |
+
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
| 2164 |
+
dependencies = [
|
| 2165 |
+
"windows-link",
|
| 2166 |
+
]
|
| 2167 |
+
|
| 2168 |
+
[[package]]
|
| 2169 |
+
name = "windows-sys"
|
| 2170 |
+
version = "0.52.0"
|
| 2171 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2172 |
+
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
| 2173 |
+
dependencies = [
|
| 2174 |
+
"windows-targets 0.52.6",
|
| 2175 |
+
]
|
| 2176 |
+
|
| 2177 |
+
[[package]]
|
| 2178 |
+
name = "windows-sys"
|
| 2179 |
+
version = "0.60.2"
|
| 2180 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2181 |
+
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
| 2182 |
+
dependencies = [
|
| 2183 |
+
"windows-targets 0.53.5",
|
| 2184 |
+
]
|
| 2185 |
+
|
| 2186 |
+
[[package]]
|
| 2187 |
+
name = "windows-sys"
|
| 2188 |
+
version = "0.61.2"
|
| 2189 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2190 |
+
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
| 2191 |
+
dependencies = [
|
| 2192 |
+
"windows-link",
|
| 2193 |
+
]
|
| 2194 |
+
|
| 2195 |
+
[[package]]
|
| 2196 |
+
name = "windows-targets"
|
| 2197 |
+
version = "0.52.6"
|
| 2198 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2199 |
+
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
| 2200 |
+
dependencies = [
|
| 2201 |
+
"windows_aarch64_gnullvm 0.52.6",
|
| 2202 |
+
"windows_aarch64_msvc 0.52.6",
|
| 2203 |
+
"windows_i686_gnu 0.52.6",
|
| 2204 |
+
"windows_i686_gnullvm 0.52.6",
|
| 2205 |
+
"windows_i686_msvc 0.52.6",
|
| 2206 |
+
"windows_x86_64_gnu 0.52.6",
|
| 2207 |
+
"windows_x86_64_gnullvm 0.52.6",
|
| 2208 |
+
"windows_x86_64_msvc 0.52.6",
|
| 2209 |
+
]
|
| 2210 |
+
|
| 2211 |
+
[[package]]
|
| 2212 |
+
name = "windows-targets"
|
| 2213 |
+
version = "0.53.5"
|
| 2214 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2215 |
+
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
| 2216 |
+
dependencies = [
|
| 2217 |
+
"windows-link",
|
| 2218 |
+
"windows_aarch64_gnullvm 0.53.1",
|
| 2219 |
+
"windows_aarch64_msvc 0.53.1",
|
| 2220 |
+
"windows_i686_gnu 0.53.1",
|
| 2221 |
+
"windows_i686_gnullvm 0.53.1",
|
| 2222 |
+
"windows_i686_msvc 0.53.1",
|
| 2223 |
+
"windows_x86_64_gnu 0.53.1",
|
| 2224 |
+
"windows_x86_64_gnullvm 0.53.1",
|
| 2225 |
+
"windows_x86_64_msvc 0.53.1",
|
| 2226 |
+
]
|
| 2227 |
+
|
| 2228 |
+
[[package]]
|
| 2229 |
+
name = "windows_aarch64_gnullvm"
|
| 2230 |
+
version = "0.52.6"
|
| 2231 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2232 |
+
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
| 2233 |
+
|
| 2234 |
+
[[package]]
|
| 2235 |
+
name = "windows_aarch64_gnullvm"
|
| 2236 |
+
version = "0.53.1"
|
| 2237 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2238 |
+
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
| 2239 |
+
|
| 2240 |
+
[[package]]
|
| 2241 |
+
name = "windows_aarch64_msvc"
|
| 2242 |
+
version = "0.52.6"
|
| 2243 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2244 |
+
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
| 2245 |
+
|
| 2246 |
+
[[package]]
|
| 2247 |
+
name = "windows_aarch64_msvc"
|
| 2248 |
+
version = "0.53.1"
|
| 2249 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2250 |
+
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
| 2251 |
+
|
| 2252 |
+
[[package]]
|
| 2253 |
+
name = "windows_i686_gnu"
|
| 2254 |
+
version = "0.52.6"
|
| 2255 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2256 |
+
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
| 2257 |
+
|
| 2258 |
+
[[package]]
|
| 2259 |
+
name = "windows_i686_gnu"
|
| 2260 |
+
version = "0.53.1"
|
| 2261 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2262 |
+
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
| 2263 |
+
|
| 2264 |
+
[[package]]
|
| 2265 |
+
name = "windows_i686_gnullvm"
|
| 2266 |
+
version = "0.52.6"
|
| 2267 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2268 |
+
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
| 2269 |
+
|
| 2270 |
+
[[package]]
|
| 2271 |
+
name = "windows_i686_gnullvm"
|
| 2272 |
+
version = "0.53.1"
|
| 2273 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2274 |
+
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
| 2275 |
+
|
| 2276 |
+
[[package]]
|
| 2277 |
+
name = "windows_i686_msvc"
|
| 2278 |
+
version = "0.52.6"
|
| 2279 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2280 |
+
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
| 2281 |
+
|
| 2282 |
+
[[package]]
|
| 2283 |
+
name = "windows_i686_msvc"
|
| 2284 |
+
version = "0.53.1"
|
| 2285 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2286 |
+
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
| 2287 |
+
|
| 2288 |
+
[[package]]
|
| 2289 |
+
name = "windows_x86_64_gnu"
|
| 2290 |
+
version = "0.52.6"
|
| 2291 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2292 |
+
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
| 2293 |
+
|
| 2294 |
+
[[package]]
|
| 2295 |
+
name = "windows_x86_64_gnu"
|
| 2296 |
+
version = "0.53.1"
|
| 2297 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2298 |
+
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
| 2299 |
+
|
| 2300 |
+
[[package]]
|
| 2301 |
+
name = "windows_x86_64_gnullvm"
|
| 2302 |
+
version = "0.52.6"
|
| 2303 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2304 |
+
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
| 2305 |
+
|
| 2306 |
+
[[package]]
|
| 2307 |
+
name = "windows_x86_64_gnullvm"
|
| 2308 |
+
version = "0.53.1"
|
| 2309 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2310 |
+
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
| 2311 |
+
|
| 2312 |
+
[[package]]
|
| 2313 |
+
name = "windows_x86_64_msvc"
|
| 2314 |
+
version = "0.52.6"
|
| 2315 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2316 |
+
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
| 2317 |
+
|
| 2318 |
+
[[package]]
|
| 2319 |
+
name = "windows_x86_64_msvc"
|
| 2320 |
+
version = "0.53.1"
|
| 2321 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2322 |
+
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
| 2323 |
+
|
| 2324 |
+
[[package]]
|
| 2325 |
+
name = "winnow"
|
| 2326 |
+
version = "0.7.14"
|
| 2327 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2328 |
+
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
| 2329 |
+
dependencies = [
|
| 2330 |
+
"memchr",
|
| 2331 |
+
]
|
| 2332 |
+
|
| 2333 |
+
[[package]]
|
| 2334 |
+
name = "wit-bindgen"
|
| 2335 |
+
version = "0.46.0"
|
| 2336 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2337 |
+
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
| 2338 |
+
|
| 2339 |
+
[[package]]
|
| 2340 |
+
name = "writeable"
|
| 2341 |
+
version = "0.6.2"
|
| 2342 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2343 |
+
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
| 2344 |
+
|
| 2345 |
+
[[package]]
|
| 2346 |
+
name = "yoke"
|
| 2347 |
+
version = "0.8.1"
|
| 2348 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2349 |
+
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
|
| 2350 |
+
dependencies = [
|
| 2351 |
+
"stable_deref_trait",
|
| 2352 |
+
"yoke-derive",
|
| 2353 |
+
"zerofrom",
|
| 2354 |
+
]
|
| 2355 |
+
|
| 2356 |
+
[[package]]
|
| 2357 |
+
name = "yoke-derive"
|
| 2358 |
+
version = "0.8.1"
|
| 2359 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2360 |
+
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
| 2361 |
+
dependencies = [
|
| 2362 |
+
"proc-macro2",
|
| 2363 |
+
"quote",
|
| 2364 |
+
"syn",
|
| 2365 |
+
"synstructure",
|
| 2366 |
+
]
|
| 2367 |
+
|
| 2368 |
+
[[package]]
|
| 2369 |
+
name = "zerocopy"
|
| 2370 |
+
version = "0.8.31"
|
| 2371 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2372 |
+
checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
|
| 2373 |
+
dependencies = [
|
| 2374 |
+
"zerocopy-derive",
|
| 2375 |
+
]
|
| 2376 |
+
|
| 2377 |
+
[[package]]
|
| 2378 |
+
name = "zerocopy-derive"
|
| 2379 |
+
version = "0.8.31"
|
| 2380 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2381 |
+
checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
|
| 2382 |
+
dependencies = [
|
| 2383 |
+
"proc-macro2",
|
| 2384 |
+
"quote",
|
| 2385 |
+
"syn",
|
| 2386 |
+
]
|
| 2387 |
+
|
| 2388 |
+
[[package]]
|
| 2389 |
+
name = "zerofrom"
|
| 2390 |
+
version = "0.1.6"
|
| 2391 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2392 |
+
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
|
| 2393 |
+
dependencies = [
|
| 2394 |
+
"zerofrom-derive",
|
| 2395 |
+
]
|
| 2396 |
+
|
| 2397 |
+
[[package]]
|
| 2398 |
+
name = "zerofrom-derive"
|
| 2399 |
+
version = "0.1.6"
|
| 2400 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2401 |
+
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
| 2402 |
+
dependencies = [
|
| 2403 |
+
"proc-macro2",
|
| 2404 |
+
"quote",
|
| 2405 |
+
"syn",
|
| 2406 |
+
"synstructure",
|
| 2407 |
+
]
|
| 2408 |
+
|
| 2409 |
+
[[package]]
|
| 2410 |
+
name = "zeroize"
|
| 2411 |
+
version = "1.8.2"
|
| 2412 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2413 |
+
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
| 2414 |
+
|
| 2415 |
+
[[package]]
|
| 2416 |
+
name = "zerotrie"
|
| 2417 |
+
version = "0.2.3"
|
| 2418 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2419 |
+
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
|
| 2420 |
+
dependencies = [
|
| 2421 |
+
"displaydoc",
|
| 2422 |
+
"yoke",
|
| 2423 |
+
"zerofrom",
|
| 2424 |
+
]
|
| 2425 |
+
|
| 2426 |
+
[[package]]
|
| 2427 |
+
name = "zerovec"
|
| 2428 |
+
version = "0.11.5"
|
| 2429 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2430 |
+
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
|
| 2431 |
+
dependencies = [
|
| 2432 |
+
"yoke",
|
| 2433 |
+
"zerofrom",
|
| 2434 |
+
"zerovec-derive",
|
| 2435 |
+
]
|
| 2436 |
+
|
| 2437 |
+
[[package]]
|
| 2438 |
+
name = "zerovec-derive"
|
| 2439 |
+
version = "0.11.2"
|
| 2440 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2441 |
+
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
| 2442 |
+
dependencies = [
|
| 2443 |
+
"proc-macro2",
|
| 2444 |
+
"quote",
|
| 2445 |
+
"syn",
|
| 2446 |
+
]
|
| 2447 |
+
|
| 2448 |
+
[[package]]
|
| 2449 |
+
name = "zip"
|
| 2450 |
+
version = "3.0.0"
|
| 2451 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2452 |
+
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
|
| 2453 |
+
dependencies = [
|
| 2454 |
+
"arbitrary",
|
| 2455 |
+
"crc32fast",
|
| 2456 |
+
"flate2",
|
| 2457 |
+
"indexmap",
|
| 2458 |
+
"memchr",
|
| 2459 |
+
"zopfli",
|
| 2460 |
+
]
|
| 2461 |
+
|
| 2462 |
+
[[package]]
|
| 2463 |
+
name = "zlib-rs"
|
| 2464 |
+
version = "0.5.5"
|
| 2465 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2466 |
+
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
|
| 2467 |
+
|
| 2468 |
+
[[package]]
|
| 2469 |
+
name = "zmij"
|
| 2470 |
+
version = "1.0.10"
|
| 2471 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2472 |
+
checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
|
| 2473 |
+
|
| 2474 |
+
[[package]]
|
| 2475 |
+
name = "zopfli"
|
| 2476 |
+
version = "0.8.3"
|
| 2477 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 2478 |
+
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
| 2479 |
+
dependencies = [
|
| 2480 |
+
"bumpalo",
|
| 2481 |
+
"crc32fast",
|
| 2482 |
+
"log",
|
| 2483 |
+
"simd-adler32",
|
| 2484 |
+
]
|
Cargo.toml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[package]
|
| 2 |
+
name = "vehicle-routing"
|
| 3 |
+
version = "0.4.1"
|
| 4 |
+
edition = "2021"
|
| 5 |
+
description = "Vehicle routing quickstart for SolverForge"
|
| 6 |
+
publish = false
|
| 7 |
+
|
| 8 |
+
[dependencies]
|
| 9 |
+
solverforge = { version = "0.5.1", features = ["serde"] }
|
| 10 |
+
rand = "0.8"
|
| 11 |
+
|
| 12 |
+
axum = "0.8"
|
| 13 |
+
tokio = { version = "1", features = ["full"] }
|
| 14 |
+
tower-http = { version = "0.6", features = ["fs", "cors"] }
|
| 15 |
+
tower = "0.5"
|
| 16 |
+
serde = { version = "1", features = ["derive"] }
|
| 17 |
+
serde_json = "1"
|
| 18 |
+
uuid = { version = "1", features = ["v4", "serde"] }
|
| 19 |
+
parking_lot = "0.12"
|
| 20 |
+
tracing = "0.1"
|
| 21 |
+
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
| 22 |
+
owo-colors = "4.2"
|
| 23 |
+
num-format = "0.4.4"
|
| 24 |
+
chrono = { version = "0.4", features = ["serde"] }
|
| 25 |
+
utoipa = { version = "5", features = ["axum_extras"] }
|
| 26 |
+
utoipa-swagger-ui = { version = "9", features = ["axum"] }
|
| 27 |
+
petgraph = "0.6"
|
| 28 |
+
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
| 29 |
+
ordered-float = "4"
|
| 30 |
+
async-stream = "0.3"
|
| 31 |
+
tokio-stream = "0.1"
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-stage build for SolverForge Employee Scheduling (Rust)
|
| 2 |
+
#
|
| 3 |
+
# Build context: rust/employee-scheduling/
|
| 4 |
+
# Uses published solverforge crate from crates.io
|
| 5 |
+
|
| 6 |
+
FROM rust:1.83-alpine AS builder
|
| 7 |
+
|
| 8 |
+
# Install build dependencies
|
| 9 |
+
RUN apk add --no-cache musl-dev
|
| 10 |
+
|
| 11 |
+
WORKDIR /build
|
| 12 |
+
|
| 13 |
+
# Copy workspace files
|
| 14 |
+
COPY Cargo.toml Cargo.lock ./
|
| 15 |
+
COPY src/ ./src/
|
| 16 |
+
COPY static/ ./static/
|
| 17 |
+
|
| 18 |
+
# Build release binary with musl target for static linking
|
| 19 |
+
RUN cargo build --release --target x86_64-unknown-linux-musl
|
| 20 |
+
|
| 21 |
+
# Runtime stage - minimal Alpine image
|
| 22 |
+
FROM alpine:latest
|
| 23 |
+
|
| 24 |
+
RUN apk add --no-cache ca-certificates
|
| 25 |
+
|
| 26 |
+
WORKDIR /app
|
| 27 |
+
|
| 28 |
+
# Copy binary from builder (musl static binary)
|
| 29 |
+
COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/employee-scheduling ./employee-scheduling
|
| 30 |
+
|
| 31 |
+
# Copy static files
|
| 32 |
+
COPY --from=builder /build/static/ ./static/
|
| 33 |
+
|
| 34 |
+
# Copy solver config
|
| 35 |
+
COPY --from=builder /build/solver.toml ./solver.toml
|
| 36 |
+
|
| 37 |
+
# Expose port 7860 (HF Spaces default)
|
| 38 |
+
EXPOSE 7860
|
| 39 |
+
|
| 40 |
+
# Run the application
|
| 41 |
+
CMD ["./vehicle-routing1"]
|
README.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
| 1 |
---
|
| 2 |
-
title: Vehicle Routing Rust Pre
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
-
short_description:
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Vehicle Routing (Rust Pre-Release)
|
| 3 |
+
emoji: 🛻
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
+
short_description: SolverForge Quickstart for Vehicle Routing in Rust
|
| 11 |
---
|
| 12 |
|
| 13 |
+
|
| 14 |
+
Visit [solverforge.org](https://www.solverforge.org).
|
src/api.rs
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! REST API for Vehicle Routing Problem.
|
| 2 |
+
//!
|
| 3 |
+
//! Provides endpoints for:
|
| 4 |
+
//! - Demo data retrieval
|
| 5 |
+
//! - Route plan management (create, get, stop)
|
| 6 |
+
//! - Route geometry for map visualization
|
| 7 |
+
//! - Swagger UI at /q/swagger-ui
|
| 8 |
+
|
| 9 |
+
use axum::{
|
| 10 |
+
body::Body,
|
| 11 |
+
extract::{Path, State},
|
| 12 |
+
http::{header, StatusCode},
|
| 13 |
+
response::{IntoResponse, Response},
|
| 14 |
+
routing::{delete, get, post, put},
|
| 15 |
+
Json, Router,
|
| 16 |
+
};
|
| 17 |
+
use chrono::{NaiveDateTime, NaiveTime};
|
| 18 |
+
use serde::{Deserialize, Serialize};
|
| 19 |
+
use std::collections::HashMap;
|
| 20 |
+
use std::sync::Arc;
|
| 21 |
+
use tower_http::cors::{Any, CorsLayer};
|
| 22 |
+
use utoipa::{OpenApi, ToSchema};
|
| 23 |
+
use utoipa_swagger_ui::SwaggerUi;
|
| 24 |
+
use uuid::Uuid;
|
| 25 |
+
|
| 26 |
+
use crate::demo_data::{available_datasets, generate_by_name};
|
| 27 |
+
use crate::domain::{Vehicle, VehicleRoutePlan, Visit};
|
| 28 |
+
use crate::geometry::{encode_routes, EncodedSegment};
|
| 29 |
+
use crate::solver::{SolverConfig, SolverService, SolverStatus};
|
| 30 |
+
use solverforge::prelude::HardSoftScore;
|
| 31 |
+
use std::time::Duration;
|
| 32 |
+
|
| 33 |
+
// ============================================================================
|
| 34 |
+
// Date/Time Utilities
|
| 35 |
+
// ============================================================================
|
| 36 |
+
|
| 37 |
+
/// Reference date for time calculations (matches Python frontend).
|
| 38 |
+
const BASE_DATE: &str = "2025-01-05";
|
| 39 |
+
|
| 40 |
+
/// Converts seconds from midnight to ISO datetime string.
|
| 41 |
+
///
|
| 42 |
+
/// # Examples
|
| 43 |
+
///
|
| 44 |
+
/// ```
|
| 45 |
+
/// use vehicle_routing::api::seconds_to_iso;
|
| 46 |
+
///
|
| 47 |
+
/// assert_eq!(seconds_to_iso(0), "2025-01-05T00:00:00");
|
| 48 |
+
/// assert_eq!(seconds_to_iso(8 * 3600), "2025-01-05T08:00:00");
|
| 49 |
+
/// assert_eq!(seconds_to_iso(8 * 3600 + 30 * 60 + 45), "2025-01-05T08:30:45");
|
| 50 |
+
/// ```
|
| 51 |
+
pub fn seconds_to_iso(seconds: i64) -> String {
|
| 52 |
+
let hours = (seconds / 3600) % 24;
|
| 53 |
+
let mins = (seconds % 3600) / 60;
|
| 54 |
+
let secs = seconds % 60;
|
| 55 |
+
format!("{}T{:02}:{:02}:{:02}", BASE_DATE, hours, mins, secs)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/// Parses ISO datetime string to seconds from midnight.
|
| 59 |
+
///
|
| 60 |
+
/// # Examples
|
| 61 |
+
///
|
| 62 |
+
/// ```
|
| 63 |
+
/// use vehicle_routing::api::iso_to_seconds;
|
| 64 |
+
///
|
| 65 |
+
/// assert_eq!(iso_to_seconds("2025-01-05T08:00:00"), 8 * 3600);
|
| 66 |
+
/// assert_eq!(iso_to_seconds("2025-01-05T08:30:45"), 8 * 3600 + 30 * 60 + 45);
|
| 67 |
+
/// ```
|
| 68 |
+
pub fn iso_to_seconds(iso: &str) -> i64 {
|
| 69 |
+
if let Ok(dt) = NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S") {
|
| 70 |
+
let midnight = NaiveDateTime::new(dt.date(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
|
| 71 |
+
(dt - midnight).num_seconds()
|
| 72 |
+
} else {
|
| 73 |
+
0
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/// Application state shared across handlers.
|
| 78 |
+
pub struct AppState {
|
| 79 |
+
pub solver: SolverService,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
impl AppState {
|
| 83 |
+
pub fn new() -> Self {
|
| 84 |
+
Self {
|
| 85 |
+
solver: SolverService::new(),
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
impl Default for AppState {
|
| 91 |
+
fn default() -> Self {
|
| 92 |
+
Self::new()
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/// Creates the API router with CORS and Swagger UI enabled.
|
| 97 |
+
pub fn create_router() -> Router {
|
| 98 |
+
let state = Arc::new(AppState::new());
|
| 99 |
+
|
| 100 |
+
let cors = CorsLayer::new()
|
| 101 |
+
.allow_origin(Any)
|
| 102 |
+
.allow_methods(Any)
|
| 103 |
+
.allow_headers(Any);
|
| 104 |
+
|
| 105 |
+
Router::new()
|
| 106 |
+
// Health & Info
|
| 107 |
+
.route("/health", get(health))
|
| 108 |
+
.route("/info", get(info))
|
| 109 |
+
// Demo data
|
| 110 |
+
.route("/demo-data", get(list_demo_data))
|
| 111 |
+
.route("/demo-data/{name}", get(get_demo_data))
|
| 112 |
+
.route("/demo-data/{name}/stream", get(get_demo_data_stream))
|
| 113 |
+
// Route plans
|
| 114 |
+
.route("/route-plans", post(create_route_plan))
|
| 115 |
+
.route("/route-plans", get(list_route_plans))
|
| 116 |
+
.route("/route-plans/{id}", get(get_route_plan))
|
| 117 |
+
.route("/route-plans/{id}/status", get(get_route_plan_status))
|
| 118 |
+
.route("/route-plans/{id}", delete(stop_solving))
|
| 119 |
+
.route("/route-plans/{id}/geometry", get(get_route_geometry))
|
| 120 |
+
// Analysis and recommendations
|
| 121 |
+
.route("/route-plans/analyze", put(analyze_route_plan))
|
| 122 |
+
.route("/route-plans/recommendation", post(recommend_assignment))
|
| 123 |
+
.route("/route-plans/recommendation/apply", post(apply_recommendation))
|
| 124 |
+
// Swagger UI at /q/swagger-ui (Quarkus-style path)
|
| 125 |
+
.merge(SwaggerUi::new("/q/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
|
| 126 |
+
.layer(cors)
|
| 127 |
+
.with_state(state)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// ============================================================================
|
| 131 |
+
// Health & Info
|
| 132 |
+
// ============================================================================
|
| 133 |
+
|
| 134 |
+
/// Health check response.
|
| 135 |
+
#[derive(Debug, Serialize, ToSchema)]
|
| 136 |
+
pub struct HealthResponse {
|
| 137 |
+
/// Status indicator ("UP" when healthy).
|
| 138 |
+
pub status: &'static str,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/// GET /health - Health check endpoint.
|
| 142 |
+
#[utoipa::path(
|
| 143 |
+
get,
|
| 144 |
+
path = "/health",
|
| 145 |
+
responses((status = 200, description = "Service is healthy", body = HealthResponse))
|
| 146 |
+
)]
|
| 147 |
+
async fn health() -> Json<HealthResponse> {
|
| 148 |
+
Json(HealthResponse { status: "UP" })
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/// Application info response.
|
| 152 |
+
#[derive(Debug, Serialize, ToSchema)]
|
| 153 |
+
#[serde(rename_all = "camelCase")]
|
| 154 |
+
pub struct InfoResponse {
|
| 155 |
+
/// Application name.
|
| 156 |
+
pub name: &'static str,
|
| 157 |
+
/// Application version.
|
| 158 |
+
pub version: &'static str,
|
| 159 |
+
/// Solver engine name.
|
| 160 |
+
pub solver_engine: &'static str,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/// GET /info - Application info endpoint.
|
| 164 |
+
#[utoipa::path(
|
| 165 |
+
get,
|
| 166 |
+
path = "/info",
|
| 167 |
+
responses((status = 200, description = "Application info", body = InfoResponse))
|
| 168 |
+
)]
|
| 169 |
+
async fn info() -> Json<InfoResponse> {
|
| 170 |
+
Json(InfoResponse {
|
| 171 |
+
name: "Vehicle Routing",
|
| 172 |
+
version: env!("CARGO_PKG_VERSION"),
|
| 173 |
+
solver_engine: "SolverForge-RS",
|
| 174 |
+
})
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// ============================================================================
|
| 178 |
+
// Demo Data
|
| 179 |
+
// ============================================================================
|
| 180 |
+
|
| 181 |
+
/// GET /demo-data - List available demo datasets.
|
| 182 |
+
#[utoipa::path(
|
| 183 |
+
get,
|
| 184 |
+
path = "/demo-data",
|
| 185 |
+
responses((status = 200, description = "List of demo dataset names", body = Vec<String>))
|
| 186 |
+
)]
|
| 187 |
+
async fn list_demo_data() -> Json<Vec<&'static str>> {
|
| 188 |
+
Json(available_datasets().to_vec())
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/// GET /demo-data/{name} - Get a specific demo dataset.
|
| 192 |
+
#[utoipa::path(
|
| 193 |
+
get,
|
| 194 |
+
path = "/demo-data/{name}",
|
| 195 |
+
params(("name" = String, Path, description = "Demo dataset name")),
|
| 196 |
+
responses(
|
| 197 |
+
(status = 200, description = "Demo data retrieved", body = RoutePlanDto),
|
| 198 |
+
(status = 404, description = "Dataset not found")
|
| 199 |
+
)
|
| 200 |
+
)]
|
| 201 |
+
async fn get_demo_data(Path(name): Path<String>) -> Result<Json<RoutePlanDto>, StatusCode> {
|
| 202 |
+
match generate_by_name(&name) {
|
| 203 |
+
Some(plan) => Ok(Json(RoutePlanDto::from_plan(&plan, None))),
|
| 204 |
+
None => Err(StatusCode::NOT_FOUND),
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/// GET /demo-data/{name}/stream - Get demo data with SSE progress updates.
|
| 209 |
+
///
|
| 210 |
+
/// Returns Server-Sent Events (SSE) stream with progress and final solution.
|
| 211 |
+
/// Downloads OSM road network and computes real driving times.
|
| 212 |
+
/// Compatible with frontend's EventSource API.
|
| 213 |
+
///
|
| 214 |
+
/// Progress phases:
|
| 215 |
+
/// - `network` (0-15%): Loading road network from cache or downloading
|
| 216 |
+
/// - `matrix` (15-75%): Computing travel time matrix (Dijkstra per location)
|
| 217 |
+
/// - `geometry` (75-95%): Computing route geometries for visualization
|
| 218 |
+
/// - `complete` (100%): Ready
|
| 219 |
+
async fn get_demo_data_stream(Path(name): Path<String>) -> impl IntoResponse {
|
| 220 |
+
use crate::routing::{BoundingBox, RoadNetwork};
|
| 221 |
+
|
| 222 |
+
// Generate the demo data
|
| 223 |
+
let mut plan = match generate_by_name(&name) {
|
| 224 |
+
Some(p) => p,
|
| 225 |
+
None => {
|
| 226 |
+
let error = r#"data: {"event":"error","message":"Demo data not found"}"#;
|
| 227 |
+
return Response::builder()
|
| 228 |
+
.status(StatusCode::OK)
|
| 229 |
+
.header(header::CONTENT_TYPE, "text/event-stream")
|
| 230 |
+
.header(header::CACHE_CONTROL, "no-cache")
|
| 231 |
+
.body(Body::from(format!("{}\n\n", error)))
|
| 232 |
+
.unwrap();
|
| 233 |
+
}
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
// Build bounding box from plan
|
| 237 |
+
let bbox = BoundingBox::new(
|
| 238 |
+
plan.south_west_corner[0],
|
| 239 |
+
plan.south_west_corner[1],
|
| 240 |
+
plan.north_east_corner[0],
|
| 241 |
+
plan.north_east_corner[1],
|
| 242 |
+
)
|
| 243 |
+
.expand(0.05);
|
| 244 |
+
|
| 245 |
+
// Extract coordinates for routing
|
| 246 |
+
let coords: Vec<(f64, f64)> = plan
|
| 247 |
+
.locations
|
| 248 |
+
.iter()
|
| 249 |
+
.map(|l| (l.latitude, l.longitude))
|
| 250 |
+
.collect();
|
| 251 |
+
let n = coords.len();
|
| 252 |
+
|
| 253 |
+
// Build SSE stream with granular progress
|
| 254 |
+
let stream = async_stream::stream! {
|
| 255 |
+
// Phase 1: Network loading (0-15%)
|
| 256 |
+
yield Ok::<_, std::convert::Infallible>(
|
| 257 |
+
format!("data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Loading road network...\",\"percent\":5,\"detail\":\"{} locations\"}}\n\n", n)
|
| 258 |
+
);
|
| 259 |
+
|
| 260 |
+
let network = match RoadNetwork::load_or_fetch(&bbox).await {
|
| 261 |
+
Ok(net) => {
|
| 262 |
+
yield Ok(format!(
|
| 263 |
+
"data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Road network ready\",\"percent\":15,\"detail\":\"{} nodes, {} edges\"}}\n\n",
|
| 264 |
+
net.node_count(), net.edge_count()
|
| 265 |
+
));
|
| 266 |
+
net
|
| 267 |
+
}
|
| 268 |
+
Err(e) => {
|
| 269 |
+
tracing::warn!("Road routing failed, using haversine: {}", e);
|
| 270 |
+
plan.finalize();
|
| 271 |
+
yield Ok("data: {\"event\":\"progress\",\"phase\":\"fallback\",\"message\":\"Using straight-line distances\",\"percent\":95}\n\n".to_string());
|
| 272 |
+
|
| 273 |
+
// Build response DTO and complete
|
| 274 |
+
let dto = RoutePlanDto::from_plan(&plan, None);
|
| 275 |
+
let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
|
| 276 |
+
yield Ok(format!(
|
| 277 |
+
"data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\
|
| 278 |
+
data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
|
| 279 |
+
solution_json
|
| 280 |
+
));
|
| 281 |
+
return;
|
| 282 |
+
}
|
| 283 |
+
};
|
| 284 |
+
|
| 285 |
+
// Phase 2: Matrix computation (15-75%) via channel for real-time progress
|
| 286 |
+
let (matrix_tx, mut matrix_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>();
|
| 287 |
+
let network_for_matrix = std::sync::Arc::clone(&network);
|
| 288 |
+
let coords_for_matrix = coords.clone();
|
| 289 |
+
|
| 290 |
+
let matrix_handle = tokio::task::spawn_blocking(move || {
|
| 291 |
+
network_for_matrix.compute_matrix_with_progress(&coords_for_matrix, |row, total| {
|
| 292 |
+
let _ = matrix_tx.send((row, total));
|
| 293 |
+
})
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
// Stream matrix progress
|
| 297 |
+
while let Some((row, total)) = matrix_rx.recv().await {
|
| 298 |
+
// Progress from 15% to 75% (60% range)
|
| 299 |
+
let pct = 15 + (row + 1) * 60 / total;
|
| 300 |
+
yield Ok(format!(
|
| 301 |
+
"data: {{\"event\":\"progress\",\"phase\":\"matrix\",\"message\":\"Computing routes\",\"percent\":{},\"detail\":\"{}/{} locations\"}}\n\n",
|
| 302 |
+
pct, row + 1, total
|
| 303 |
+
));
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// Get matrix result
|
| 307 |
+
let matrix = match matrix_handle.await {
|
| 308 |
+
Ok(m) => m,
|
| 309 |
+
Err(e) => {
|
| 310 |
+
tracing::error!("Matrix computation failed: {}", e);
|
| 311 |
+
plan.finalize();
|
| 312 |
+
let dto = RoutePlanDto::from_plan(&plan, None);
|
| 313 |
+
let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
|
| 314 |
+
yield Ok(format!(
|
| 315 |
+
"data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready (fallback)\",\"percent\":100}}\n\n\
|
| 316 |
+
data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
|
| 317 |
+
solution_json
|
| 318 |
+
));
|
| 319 |
+
return;
|
| 320 |
+
}
|
| 321 |
+
};
|
| 322 |
+
plan.travel_time_matrix = matrix;
|
| 323 |
+
|
| 324 |
+
// Phase 3: Geometry computation (75-95%) via channel
|
| 325 |
+
let (geo_tx, mut geo_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>();
|
| 326 |
+
let network_for_geo = std::sync::Arc::clone(&network);
|
| 327 |
+
let coords_for_geo = coords.clone();
|
| 328 |
+
|
| 329 |
+
let geo_handle = tokio::task::spawn_blocking(move || {
|
| 330 |
+
network_for_geo.compute_all_geometries_with_progress(&coords_for_geo, |row, total| {
|
| 331 |
+
let _ = geo_tx.send((row, total));
|
| 332 |
+
})
|
| 333 |
+
});
|
| 334 |
+
|
| 335 |
+
// Stream geometry progress
|
| 336 |
+
while let Some((row, total)) = geo_rx.recv().await {
|
| 337 |
+
// Progress from 75% to 95% (20% range)
|
| 338 |
+
let pct = 75 + (row + 1) * 20 / total;
|
| 339 |
+
yield Ok(format!(
|
| 340 |
+
"data: {{\"event\":\"progress\",\"phase\":\"geometry\",\"message\":\"Generating routes\",\"percent\":{},\"detail\":\"{}/{} paths\"}}\n\n",
|
| 341 |
+
pct, row + 1, total
|
| 342 |
+
));
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
// Get geometry result
|
| 346 |
+
let geometries = match geo_handle.await {
|
| 347 |
+
Ok(g) => g,
|
| 348 |
+
Err(e) => {
|
| 349 |
+
tracing::error!("Geometry computation failed: {}", e);
|
| 350 |
+
std::collections::HashMap::new()
|
| 351 |
+
}
|
| 352 |
+
};
|
| 353 |
+
plan.route_geometries = geometries;
|
| 354 |
+
|
| 355 |
+
// Build response DTO
|
| 356 |
+
let dto = RoutePlanDto::from_plan(&plan, None);
|
| 357 |
+
let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
|
| 358 |
+
|
| 359 |
+
// Complete (100%)
|
| 360 |
+
yield Ok(format!(
|
| 361 |
+
"data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\
|
| 362 |
+
data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
|
| 363 |
+
solution_json
|
| 364 |
+
));
|
| 365 |
+
};
|
| 366 |
+
|
| 367 |
+
let body = Body::from_stream(stream);
|
| 368 |
+
|
| 369 |
+
Response::builder()
|
| 370 |
+
.status(StatusCode::OK)
|
| 371 |
+
.header(header::CONTENT_TYPE, "text/event-stream")
|
| 372 |
+
.header(header::CACHE_CONTROL, "no-cache")
|
| 373 |
+
.header(header::CONNECTION, "keep-alive")
|
| 374 |
+
.body(body)
|
| 375 |
+
.unwrap()
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
// ============================================================================
|
| 379 |
+
// DTOs (Python API Compatible)
|
| 380 |
+
// ============================================================================
|
| 381 |
+
|
| 382 |
+
/// Visit DTO matching Python API structure.
|
| 383 |
+
///
|
| 384 |
+
/// All times are ISO datetime strings (e.g., "2025-01-05T08:30:00").
|
| 385 |
+
/// Location is `[latitude, longitude]` array.
|
| 386 |
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
| 387 |
+
#[serde(rename_all = "camelCase")]
|
| 388 |
+
pub struct VisitDto {
|
| 389 |
+
/// Unique visit identifier.
|
| 390 |
+
pub id: String,
|
| 391 |
+
/// Customer name.
|
| 392 |
+
pub name: String,
|
| 393 |
+
/// Location as `[latitude, longitude]`.
|
| 394 |
+
pub location: [f64; 2],
|
| 395 |
+
/// Quantity demanded.
|
| 396 |
+
pub demand: i32,
|
| 397 |
+
/// Earliest service start time (ISO datetime).
|
| 398 |
+
pub min_start_time: String,
|
| 399 |
+
/// Latest service end time (ISO datetime).
|
| 400 |
+
pub max_end_time: String,
|
| 401 |
+
/// Service duration in seconds.
|
| 402 |
+
pub service_duration: i32,
|
| 403 |
+
/// Assigned vehicle ID (null if unassigned).
|
| 404 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 405 |
+
pub vehicle: Option<String>,
|
| 406 |
+
/// Previous visit in route (null if first or unassigned).
|
| 407 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 408 |
+
pub previous_visit: Option<String>,
|
| 409 |
+
/// Next visit in route (null if last or unassigned).
|
| 410 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 411 |
+
pub next_visit: Option<String>,
|
| 412 |
+
/// Arrival time at visit (ISO datetime).
|
| 413 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 414 |
+
pub arrival_time: Option<String>,
|
| 415 |
+
/// Service start time (ISO datetime).
|
| 416 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 417 |
+
pub start_service_time: Option<String>,
|
| 418 |
+
/// Departure time from visit (ISO datetime).
|
| 419 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 420 |
+
pub departure_time: Option<String>,
|
| 421 |
+
/// Driving time from previous stop in seconds.
|
| 422 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 423 |
+
pub driving_time_seconds_from_previous_standstill: Option<i32>,
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/// Vehicle DTO matching Python API structure.
|
| 427 |
+
///
|
| 428 |
+
/// Visits are referenced by ID only; full visit data is in the plan's `visits` array.
|
| 429 |
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
| 430 |
+
#[serde(rename_all = "camelCase")]
|
| 431 |
+
pub struct VehicleDto {
|
| 432 |
+
/// Unique vehicle identifier.
|
| 433 |
+
pub id: String,
|
| 434 |
+
/// Vehicle name for display.
|
| 435 |
+
pub name: String,
|
| 436 |
+
/// Maximum capacity.
|
| 437 |
+
pub capacity: i32,
|
| 438 |
+
/// Home depot location as `[latitude, longitude]`.
|
| 439 |
+
pub home_location: [f64; 2],
|
| 440 |
+
/// Departure time from depot (ISO datetime).
|
| 441 |
+
pub departure_time: String,
|
| 442 |
+
/// Visit IDs in route order.
|
| 443 |
+
pub visits: Vec<String>,
|
| 444 |
+
/// Total demand of assigned visits.
|
| 445 |
+
pub total_demand: i32,
|
| 446 |
+
/// Total driving time in seconds.
|
| 447 |
+
pub total_driving_time_seconds: i32,
|
| 448 |
+
/// Arrival time back at depot (ISO datetime).
|
| 449 |
+
pub arrival_time: String,
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/// Termination configuration for the solver.
|
| 453 |
+
///
|
| 454 |
+
/// Supports multiple termination conditions that combine with OR logic.
|
| 455 |
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
|
| 456 |
+
#[serde(rename_all = "camelCase")]
|
| 457 |
+
pub struct TerminationConfigDto {
|
| 458 |
+
/// Stop after this many seconds.
|
| 459 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 460 |
+
pub seconds_spent_limit: Option<u64>,
|
| 461 |
+
/// Stop after this many seconds without improvement.
|
| 462 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 463 |
+
pub unimproved_seconds_spent_limit: Option<u64>,
|
| 464 |
+
/// Stop after this many steps.
|
| 465 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 466 |
+
pub step_count_limit: Option<u64>,
|
| 467 |
+
/// Stop after this many steps without improvement.
|
| 468 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 469 |
+
pub unimproved_step_count_limit: Option<u64>,
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
/// Full route plan DTO matching Python API structure.
|
| 473 |
+
///
|
| 474 |
+
/// Contains ALL visits in a flat list; assignment is indicated by `vehicle` field.
|
| 475 |
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
| 476 |
+
#[serde(rename_all = "camelCase")]
|
| 477 |
+
pub struct RoutePlanDto {
|
| 478 |
+
/// Problem name.
|
| 479 |
+
pub name: String,
|
| 480 |
+
/// South-west corner of bounding box as `[latitude, longitude]`.
|
| 481 |
+
pub south_west_corner: [f64; 2],
|
| 482 |
+
/// North-east corner of bounding box as `[latitude, longitude]`.
|
| 483 |
+
pub north_east_corner: [f64; 2],
|
| 484 |
+
/// Earliest vehicle departure time (ISO datetime).
|
| 485 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 486 |
+
pub start_date_time: Option<String>,
|
| 487 |
+
/// Latest vehicle arrival time (ISO datetime).
|
| 488 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 489 |
+
pub end_date_time: Option<String>,
|
| 490 |
+
/// Total driving time across all vehicles in seconds.
|
| 491 |
+
pub total_driving_time_seconds: i32,
|
| 492 |
+
/// All vehicles.
|
| 493 |
+
pub vehicles: Vec<VehicleDto>,
|
| 494 |
+
/// All visits (assigned and unassigned).
|
| 495 |
+
pub visits: Vec<VisitDto>,
|
| 496 |
+
/// Current score (e.g., "0hard/-14400soft").
|
| 497 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 498 |
+
pub score: Option<String>,
|
| 499 |
+
/// Solver status ("NOT_SOLVING", "SOLVING_ACTIVE", etc.).
|
| 500 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 501 |
+
pub solver_status: Option<String>,
|
| 502 |
+
/// Termination configuration.
|
| 503 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 504 |
+
pub termination: Option<TerminationConfigDto>,
|
| 505 |
+
/// Precomputed travel time matrix (optional, from real roads).
|
| 506 |
+
/// Row/column order: depot locations first, then visit locations.
|
| 507 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 508 |
+
pub travel_time_matrix: Option<Vec<Vec<i64>>>,
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
impl RoutePlanDto {
|
| 512 |
+
/// Converts domain model to DTO for API responses.
|
| 513 |
+
///
|
| 514 |
+
/// Builds flat visit list with vehicle assignments and timing info.
|
| 515 |
+
pub fn from_plan(plan: &VehicleRoutePlan, status: Option<SolverStatus>) -> Self {
|
| 516 |
+
// Build vehicle ID lookup: visit_idx -> (vehicle_id, position in route)
|
| 517 |
+
let mut visit_vehicle: HashMap<usize, (String, usize)> = HashMap::new();
|
| 518 |
+
for v in &plan.vehicles {
|
| 519 |
+
for (pos, &visit_idx) in v.visits.iter().enumerate() {
|
| 520 |
+
visit_vehicle.insert(visit_idx, (v.id.to_string(), pos));
|
| 521 |
+
}
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
// Build visit ID lookup for next/previous references
|
| 525 |
+
let visit_id = |idx: usize| -> String { format!("v{}", idx) };
|
| 526 |
+
|
| 527 |
+
// Calculate timing for all vehicles
|
| 528 |
+
let mut visit_timings: HashMap<usize, (i64, i64, i64, i32)> = HashMap::new(); // (arrival, service_start, departure, driving_time)
|
| 529 |
+
for v in &plan.vehicles {
|
| 530 |
+
let timings = plan.calculate_route_times(v);
|
| 531 |
+
let mut prev_loc = v.home_location.index;
|
| 532 |
+
|
| 533 |
+
for timing in timings.iter() {
|
| 534 |
+
let driving_time = plan.travel_time(prev_loc, plan.visits[timing.visit_idx].location.index);
|
| 535 |
+
let service_start = timing.arrival.max(plan.visits[timing.visit_idx].min_start_time);
|
| 536 |
+
visit_timings.insert(
|
| 537 |
+
timing.visit_idx,
|
| 538 |
+
(timing.arrival, service_start, timing.departure, driving_time as i32),
|
| 539 |
+
);
|
| 540 |
+
prev_loc = plan.visits[timing.visit_idx].location.index;
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
// Build ALL visits with assignment info
|
| 545 |
+
let visits: Vec<VisitDto> = plan
|
| 546 |
+
.visits
|
| 547 |
+
.iter()
|
| 548 |
+
.filter_map(|visit| {
|
| 549 |
+
let loc = plan.locations.get(visit.location.index)?;
|
| 550 |
+
let (vehicle_id, vehicle_pos) = visit_vehicle.get(&visit.index).cloned().unzip();
|
| 551 |
+
let vehicle_for_visit = vehicle_id.as_ref().and_then(|vid| {
|
| 552 |
+
plan.vehicles.iter().find(|v| v.id.to_string() == *vid)
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
// Get previous/next visit IDs
|
| 556 |
+
let (prev_visit, next_visit) = if let (Some(v), Some(pos)) = (vehicle_for_visit, vehicle_pos) {
|
| 557 |
+
let prev = if pos > 0 { Some(visit_id(v.visits[pos - 1])) } else { None };
|
| 558 |
+
let next = if pos + 1 < v.visits.len() { Some(visit_id(v.visits[pos + 1])) } else { None };
|
| 559 |
+
(prev, next)
|
| 560 |
+
} else {
|
| 561 |
+
(None, None)
|
| 562 |
+
};
|
| 563 |
+
|
| 564 |
+
let timing = visit_timings.get(&visit.index);
|
| 565 |
+
|
| 566 |
+
Some(VisitDto {
|
| 567 |
+
id: visit_id(visit.index),
|
| 568 |
+
name: visit.name.clone(),
|
| 569 |
+
location: [loc.latitude, loc.longitude],
|
| 570 |
+
demand: visit.demand,
|
| 571 |
+
min_start_time: seconds_to_iso(visit.min_start_time),
|
| 572 |
+
max_end_time: seconds_to_iso(visit.max_end_time),
|
| 573 |
+
service_duration: visit.service_duration as i32,
|
| 574 |
+
vehicle: vehicle_id,
|
| 575 |
+
previous_visit: prev_visit,
|
| 576 |
+
next_visit,
|
| 577 |
+
arrival_time: timing.map(|t| seconds_to_iso(t.0)),
|
| 578 |
+
start_service_time: timing.map(|t| seconds_to_iso(t.1)),
|
| 579 |
+
departure_time: timing.map(|t| seconds_to_iso(t.2)),
|
| 580 |
+
driving_time_seconds_from_previous_standstill: timing.map(|t| t.3),
|
| 581 |
+
})
|
| 582 |
+
})
|
| 583 |
+
.collect();
|
| 584 |
+
|
| 585 |
+
// Build vehicles with visit ID references
|
| 586 |
+
let vehicles: Vec<VehicleDto> = plan
|
| 587 |
+
.vehicles
|
| 588 |
+
.iter()
|
| 589 |
+
.map(|v| {
|
| 590 |
+
let home_loc = plan
|
| 591 |
+
.locations
|
| 592 |
+
.get(v.home_location.index)
|
| 593 |
+
.map(|l| [l.latitude, l.longitude])
|
| 594 |
+
.unwrap_or([0.0, 0.0]);
|
| 595 |
+
|
| 596 |
+
let total_driving = plan.total_driving_time(v);
|
| 597 |
+
let route_times = plan.calculate_route_times(v);
|
| 598 |
+
|
| 599 |
+
// Calculate arrival time back at depot
|
| 600 |
+
let arrival = if v.visits.is_empty() {
|
| 601 |
+
v.departure_time
|
| 602 |
+
} else if let Some(last_timing) = route_times.last() {
|
| 603 |
+
let last_visit = &plan.visits[last_timing.visit_idx];
|
| 604 |
+
let return_travel = plan.travel_time(last_visit.location.index, v.home_location.index);
|
| 605 |
+
last_timing.departure + return_travel
|
| 606 |
+
} else {
|
| 607 |
+
v.departure_time
|
| 608 |
+
};
|
| 609 |
+
|
| 610 |
+
// Compute total demand by summing visit demands
|
| 611 |
+
let total_demand: i32 = v
|
| 612 |
+
.visits
|
| 613 |
+
.iter()
|
| 614 |
+
.filter_map(|&idx| plan.visits.get(idx))
|
| 615 |
+
.map(|visit| visit.demand)
|
| 616 |
+
.sum();
|
| 617 |
+
|
| 618 |
+
VehicleDto {
|
| 619 |
+
id: v.id.to_string(),
|
| 620 |
+
name: v.name.clone(),
|
| 621 |
+
capacity: v.capacity,
|
| 622 |
+
home_location: home_loc,
|
| 623 |
+
departure_time: seconds_to_iso(v.departure_time),
|
| 624 |
+
visits: v.visits.iter().map(|&idx| visit_id(idx)).collect(),
|
| 625 |
+
total_demand,
|
| 626 |
+
total_driving_time_seconds: total_driving as i32,
|
| 627 |
+
arrival_time: seconds_to_iso(arrival),
|
| 628 |
+
}
|
| 629 |
+
})
|
| 630 |
+
.collect();
|
| 631 |
+
|
| 632 |
+
// Calculate plan-level times
|
| 633 |
+
let start_dt = plan.vehicles.iter().map(|v| v.departure_time).min();
|
| 634 |
+
let end_dt = vehicles.iter().map(|v| iso_to_seconds(&v.arrival_time)).max();
|
| 635 |
+
|
| 636 |
+
Self {
|
| 637 |
+
name: plan.name.clone(),
|
| 638 |
+
south_west_corner: plan.south_west_corner,
|
| 639 |
+
north_east_corner: plan.north_east_corner,
|
| 640 |
+
start_date_time: start_dt.map(seconds_to_iso),
|
| 641 |
+
end_date_time: end_dt.map(seconds_to_iso),
|
| 642 |
+
total_driving_time_seconds: plan.total_driving_time_all() as i32,
|
| 643 |
+
vehicles,
|
| 644 |
+
visits,
|
| 645 |
+
score: plan.score.map(|s| format!("{}", s)),
|
| 646 |
+
solver_status: status.map(|s| s.as_str().to_string()),
|
| 647 |
+
termination: None,
|
| 648 |
+
travel_time_matrix: if plan.travel_time_matrix.is_empty() {
|
| 649 |
+
None
|
| 650 |
+
} else {
|
| 651 |
+
Some(plan.travel_time_matrix.clone())
|
| 652 |
+
},
|
| 653 |
+
}
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
/// Converts DTO to domain model for solving.
|
| 657 |
+
pub fn to_domain(&self) -> VehicleRoutePlan {
|
| 658 |
+
use crate::domain::Location;
|
| 659 |
+
|
| 660 |
+
// Build locations (depots first, then visit locations)
|
| 661 |
+
let mut locations = Vec::new();
|
| 662 |
+
let mut depot_indices: HashMap<(i64, i64), usize> = HashMap::new();
|
| 663 |
+
|
| 664 |
+
// Add unique depot locations
|
| 665 |
+
for vdto in &self.vehicles {
|
| 666 |
+
let key = (
|
| 667 |
+
(vdto.home_location[0] * 1e6) as i64,
|
| 668 |
+
(vdto.home_location[1] * 1e6) as i64,
|
| 669 |
+
);
|
| 670 |
+
depot_indices.entry(key).or_insert_with(|| {
|
| 671 |
+
let idx = locations.len();
|
| 672 |
+
locations.push(Location::new(idx, vdto.home_location[0], vdto.home_location[1]));
|
| 673 |
+
idx
|
| 674 |
+
});
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
// Build visit ID to index mapping
|
| 678 |
+
let visit_id_to_idx: HashMap<&str, usize> = self
|
| 679 |
+
.visits
|
| 680 |
+
.iter()
|
| 681 |
+
.enumerate()
|
| 682 |
+
.map(|(i, v)| (v.id.as_str(), i))
|
| 683 |
+
.collect();
|
| 684 |
+
|
| 685 |
+
// Add visit locations
|
| 686 |
+
let visit_start_idx = locations.len();
|
| 687 |
+
for (i, vdto) in self.visits.iter().enumerate() {
|
| 688 |
+
locations.push(Location::new(
|
| 689 |
+
visit_start_idx + i,
|
| 690 |
+
vdto.location[0],
|
| 691 |
+
vdto.location[1],
|
| 692 |
+
));
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
// Build visits - now needs Location object, not index
|
| 696 |
+
let visits: Vec<Visit> = self
|
| 697 |
+
.visits
|
| 698 |
+
.iter()
|
| 699 |
+
.enumerate()
|
| 700 |
+
.map(|(i, vdto)| {
|
| 701 |
+
let loc = locations[visit_start_idx + i].clone();
|
| 702 |
+
Visit::new(i, &vdto.name, loc)
|
| 703 |
+
.with_demand(vdto.demand)
|
| 704 |
+
.with_time_window(
|
| 705 |
+
iso_to_seconds(&vdto.min_start_time),
|
| 706 |
+
iso_to_seconds(&vdto.max_end_time),
|
| 707 |
+
)
|
| 708 |
+
.with_service_duration(vdto.service_duration as i64)
|
| 709 |
+
})
|
| 710 |
+
.collect();
|
| 711 |
+
|
| 712 |
+
// Build vehicles - now needs Location object, not index
|
| 713 |
+
let vehicles: Vec<Vehicle> = self
|
| 714 |
+
.vehicles
|
| 715 |
+
.iter()
|
| 716 |
+
.enumerate()
|
| 717 |
+
.map(|(i, vdto)| {
|
| 718 |
+
let key = (
|
| 719 |
+
(vdto.home_location[0] * 1e6) as i64,
|
| 720 |
+
(vdto.home_location[1] * 1e6) as i64,
|
| 721 |
+
);
|
| 722 |
+
let home_idx = depot_indices[&key];
|
| 723 |
+
let home_loc = locations[home_idx].clone();
|
| 724 |
+
|
| 725 |
+
// Map visit IDs to indices
|
| 726 |
+
let visit_indices: Vec<usize> = vdto
|
| 727 |
+
.visits
|
| 728 |
+
.iter()
|
| 729 |
+
.filter_map(|vid| visit_id_to_idx.get(vid.as_str()).copied())
|
| 730 |
+
.collect();
|
| 731 |
+
|
| 732 |
+
let mut v = Vehicle::new(i, &vdto.name, vdto.capacity, home_loc);
|
| 733 |
+
v.departure_time = iso_to_seconds(&vdto.departure_time);
|
| 734 |
+
v.visits = visit_indices;
|
| 735 |
+
v
|
| 736 |
+
})
|
| 737 |
+
.collect();
|
| 738 |
+
|
| 739 |
+
let mut plan = VehicleRoutePlan::new(&self.name, locations, visits, vehicles);
|
| 740 |
+
plan.south_west_corner = self.south_west_corner;
|
| 741 |
+
plan.north_east_corner = self.north_east_corner;
|
| 742 |
+
|
| 743 |
+
// Use provided matrix (from real roads) if available, otherwise compute haversine
|
| 744 |
+
if let Some(matrix) = &self.travel_time_matrix {
|
| 745 |
+
plan.travel_time_matrix = matrix.clone();
|
| 746 |
+
} else {
|
| 747 |
+
plan.finalize();
|
| 748 |
+
}
|
| 749 |
+
plan
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
// ============================================================================
|
| 754 |
+
// Route Plan Handlers
|
| 755 |
+
// ============================================================================
|
| 756 |
+
|
| 757 |
+
/// POST /route-plans - Create and start solving a route plan.
|
| 758 |
+
#[utoipa::path(
|
| 759 |
+
post,
|
| 760 |
+
path = "/route-plans",
|
| 761 |
+
request_body = RoutePlanDto,
|
| 762 |
+
responses((status = 200, description = "Job ID", body = String))
|
| 763 |
+
)]
|
| 764 |
+
async fn create_route_plan(
|
| 765 |
+
State(state): State<Arc<AppState>>,
|
| 766 |
+
Json(dto): Json<RoutePlanDto>,
|
| 767 |
+
) -> Result<String, StatusCode> {
|
| 768 |
+
let id = Uuid::new_v4().to_string();
|
| 769 |
+
let mut plan = dto.to_domain();
|
| 770 |
+
|
| 771 |
+
// Initialize road routing (uses cached network - instant after first download)
|
| 772 |
+
if let Err(e) = plan.init_routing().await {
|
| 773 |
+
tracing::error!("Road routing initialization failed: {}", e);
|
| 774 |
+
return Err(StatusCode::SERVICE_UNAVAILABLE);
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
// Convert termination config from DTO
|
| 778 |
+
let config = if let Some(term) = &dto.termination {
|
| 779 |
+
SolverConfig {
|
| 780 |
+
time_limit: term.seconds_spent_limit.map(Duration::from_secs),
|
| 781 |
+
unimproved_time_limit: term.unimproved_seconds_spent_limit.map(Duration::from_secs),
|
| 782 |
+
step_limit: term.step_count_limit,
|
| 783 |
+
unimproved_step_limit: term.unimproved_step_count_limit,
|
| 784 |
+
}
|
| 785 |
+
} else {
|
| 786 |
+
SolverConfig::default_config()
|
| 787 |
+
};
|
| 788 |
+
|
| 789 |
+
let job = state.solver.create_job_with_config(id.clone(), plan, config);
|
| 790 |
+
state.solver.start_solving(job);
|
| 791 |
+
Ok(id)
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
/// GET /route-plans - List all route plan IDs.
|
| 795 |
+
#[utoipa::path(
|
| 796 |
+
get,
|
| 797 |
+
path = "/route-plans",
|
| 798 |
+
responses((status = 200, description = "List of job IDs", body = Vec<String>))
|
| 799 |
+
)]
|
| 800 |
+
async fn list_route_plans(State(state): State<Arc<AppState>>) -> Json<Vec<String>> {
|
| 801 |
+
Json(state.solver.list_jobs())
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
/// GET /route-plans/{id} - Get current route plan state.
|
| 805 |
+
#[utoipa::path(
|
| 806 |
+
get,
|
| 807 |
+
path = "/route-plans/{id}",
|
| 808 |
+
params(("id" = String, Path, description = "Route plan ID")),
|
| 809 |
+
responses(
|
| 810 |
+
(status = 200, description = "Route plan retrieved", body = RoutePlanDto),
|
| 811 |
+
(status = 404, description = "Not found")
|
| 812 |
+
)
|
| 813 |
+
)]
|
| 814 |
+
async fn get_route_plan(
|
| 815 |
+
State(state): State<Arc<AppState>>,
|
| 816 |
+
Path(id): Path<String>,
|
| 817 |
+
) -> Result<Json<RoutePlanDto>, StatusCode> {
|
| 818 |
+
match state.solver.get_job(&id) {
|
| 819 |
+
Some(job) => {
|
| 820 |
+
let guard = job.read();
|
| 821 |
+
Ok(Json(RoutePlanDto::from_plan(
|
| 822 |
+
&guard.plan,
|
| 823 |
+
Some(guard.status),
|
| 824 |
+
)))
|
| 825 |
+
}
|
| 826 |
+
None => Err(StatusCode::NOT_FOUND),
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
/// Status response.
|
| 831 |
+
#[derive(Debug, Serialize, ToSchema)]
|
| 832 |
+
#[serde(rename_all = "camelCase")]
|
| 833 |
+
pub struct StatusResponse {
|
| 834 |
+
/// Current score.
|
| 835 |
+
pub score: Option<String>,
|
| 836 |
+
/// Solver status.
|
| 837 |
+
pub solver_status: String,
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
/// GET /route-plans/{id}/status - Get route plan status only.
|
| 841 |
+
#[utoipa::path(
|
| 842 |
+
get,
|
| 843 |
+
path = "/route-plans/{id}/status",
|
| 844 |
+
params(("id" = String, Path, description = "Route plan ID")),
|
| 845 |
+
responses(
|
| 846 |
+
(status = 200, description = "Status retrieved", body = StatusResponse),
|
| 847 |
+
(status = 404, description = "Not found")
|
| 848 |
+
)
|
| 849 |
+
)]
|
| 850 |
+
async fn get_route_plan_status(
|
| 851 |
+
State(state): State<Arc<AppState>>,
|
| 852 |
+
Path(id): Path<String>,
|
| 853 |
+
) -> Result<Json<StatusResponse>, StatusCode> {
|
| 854 |
+
match state.solver.get_job(&id) {
|
| 855 |
+
Some(job) => {
|
| 856 |
+
let guard = job.read();
|
| 857 |
+
Ok(Json(StatusResponse {
|
| 858 |
+
score: guard.plan.score.map(|s| format!("{}", s)),
|
| 859 |
+
solver_status: guard.status.as_str().to_string(),
|
| 860 |
+
}))
|
| 861 |
+
}
|
| 862 |
+
None => Err(StatusCode::NOT_FOUND),
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
/// DELETE /route-plans/{id} - Stop solving and get final solution.
|
| 867 |
+
#[utoipa::path(
|
| 868 |
+
delete,
|
| 869 |
+
path = "/route-plans/{id}",
|
| 870 |
+
params(("id" = String, Path, description = "Route plan ID")),
|
| 871 |
+
responses(
|
| 872 |
+
(status = 200, description = "Solving stopped", body = RoutePlanDto),
|
| 873 |
+
(status = 404, description = "Not found")
|
| 874 |
+
)
|
| 875 |
+
)]
|
| 876 |
+
async fn stop_solving(
|
| 877 |
+
State(state): State<Arc<AppState>>,
|
| 878 |
+
Path(id): Path<String>,
|
| 879 |
+
) -> Result<Json<RoutePlanDto>, StatusCode> {
|
| 880 |
+
state.solver.stop_solving(&id);
|
| 881 |
+
match state.solver.remove_job(&id) {
|
| 882 |
+
Some(job) => {
|
| 883 |
+
let guard = job.read();
|
| 884 |
+
Ok(Json(RoutePlanDto::from_plan(
|
| 885 |
+
&guard.plan,
|
| 886 |
+
Some(SolverStatus::NotSolving),
|
| 887 |
+
)))
|
| 888 |
+
}
|
| 889 |
+
None => Err(StatusCode::NOT_FOUND),
|
| 890 |
+
}
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
/// Geometry response with encoded polylines for map rendering.
|
| 894 |
+
#[derive(Debug, Serialize, ToSchema)]
|
| 895 |
+
#[serde(rename_all = "camelCase")]
|
| 896 |
+
pub struct GeometryResponse {
|
| 897 |
+
/// Encoded route segments per vehicle.
|
| 898 |
+
pub segments: Vec<EncodedSegment>,
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
/// GET /route-plans/{id}/geometry - Get encoded polylines for routes.
|
| 902 |
+
#[utoipa::path(
|
| 903 |
+
get,
|
| 904 |
+
path = "/route-plans/{id}/geometry",
|
| 905 |
+
params(("id" = String, Path, description = "Route plan ID")),
|
| 906 |
+
responses(
|
| 907 |
+
(status = 200, description = "Geometry retrieved", body = GeometryResponse),
|
| 908 |
+
(status = 404, description = "Not found")
|
| 909 |
+
)
|
| 910 |
+
)]
|
| 911 |
+
async fn get_route_geometry(
|
| 912 |
+
State(state): State<Arc<AppState>>,
|
| 913 |
+
Path(id): Path<String>,
|
| 914 |
+
) -> Result<Json<GeometryResponse>, StatusCode> {
|
| 915 |
+
match state.solver.get_job(&id) {
|
| 916 |
+
Some(job) => {
|
| 917 |
+
let guard = job.read();
|
| 918 |
+
let segments = encode_routes(&guard.plan);
|
| 919 |
+
Ok(Json(GeometryResponse { segments }))
|
| 920 |
+
}
|
| 921 |
+
None => Err(StatusCode::NOT_FOUND),
|
| 922 |
+
}
|
| 923 |
+
}
|
| 924 |
+
|
| 925 |
+
// ============================================================================
|
| 926 |
+
// Score Analysis
|
| 927 |
+
// ============================================================================
|
| 928 |
+
|
| 929 |
+
/// Match analysis for a constraint violation.
|
| 930 |
+
#[derive(Debug, Clone, Serialize, ToSchema)]
|
| 931 |
+
pub struct MatchAnalysisDto {
|
| 932 |
+
/// Constraint name.
|
| 933 |
+
pub name: String,
|
| 934 |
+
/// Score impact of this match.
|
| 935 |
+
pub score: String,
|
| 936 |
+
/// Description of the match.
|
| 937 |
+
pub justification: String,
|
| 938 |
+
}
|
| 939 |
+
|
| 940 |
+
/// Constraint analysis showing all matches.
|
| 941 |
+
#[derive(Debug, Clone, Serialize, ToSchema)]
|
| 942 |
+
pub struct ConstraintAnalysisDto {
|
| 943 |
+
/// Constraint name.
|
| 944 |
+
pub name: String,
|
| 945 |
+
/// Constraint weight (score per violation).
|
| 946 |
+
pub weight: String,
|
| 947 |
+
/// Total score from this constraint.
|
| 948 |
+
pub score: String,
|
| 949 |
+
/// Individual matches.
|
| 950 |
+
pub matches: Vec<MatchAnalysisDto>,
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
/// Response from score analysis endpoint.
|
| 954 |
+
#[derive(Debug, Serialize, ToSchema)]
|
| 955 |
+
pub struct AnalyzeResponse {
|
| 956 |
+
/// Per-constraint breakdown.
|
| 957 |
+
pub constraints: Vec<ConstraintAnalysisDto>,
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
/// PUT /route-plans/analyze - Analyze constraint violations.
|
| 961 |
+
#[utoipa::path(
|
| 962 |
+
put,
|
| 963 |
+
path = "/route-plans/analyze",
|
| 964 |
+
request_body = RoutePlanDto,
|
| 965 |
+
responses((status = 200, description = "Constraint analysis", body = AnalyzeResponse))
|
| 966 |
+
)]
|
| 967 |
+
async fn analyze_route_plan(Json(dto): Json<RoutePlanDto>) -> Json<AnalyzeResponse> {
|
| 968 |
+
use crate::constraints::{calculate_late_minutes, calculate_excess_capacity};
|
| 969 |
+
|
| 970 |
+
let plan = dto.to_domain();
|
| 971 |
+
|
| 972 |
+
// Calculate constraint scores
|
| 973 |
+
let cap_total: i64 = plan.vehicles.iter()
|
| 974 |
+
.map(|v| calculate_excess_capacity(&plan, v) as i64)
|
| 975 |
+
.sum();
|
| 976 |
+
|
| 977 |
+
let tw_total: i64 = plan.vehicles.iter()
|
| 978 |
+
.map(|v| calculate_late_minutes(&plan, v))
|
| 979 |
+
.sum();
|
| 980 |
+
|
| 981 |
+
let travel_total: i64 = plan.vehicles.iter()
|
| 982 |
+
.map(|v| plan.total_driving_time(v))
|
| 983 |
+
.sum();
|
| 984 |
+
|
| 985 |
+
let cap_score = HardSoftScore::of_hard(-cap_total);
|
| 986 |
+
let tw_score = HardSoftScore::of_hard(-tw_total);
|
| 987 |
+
let travel_score = HardSoftScore::of_soft(-travel_total);
|
| 988 |
+
|
| 989 |
+
// Helper to compute total demand
|
| 990 |
+
let total_demand = |v: &Vehicle| -> i32 {
|
| 991 |
+
v.visits.iter()
|
| 992 |
+
.filter_map(|&idx| plan.visits.get(idx))
|
| 993 |
+
.map(|visit| visit.demand)
|
| 994 |
+
.sum()
|
| 995 |
+
};
|
| 996 |
+
|
| 997 |
+
// Build detailed matches for capacity constraint
|
| 998 |
+
let cap_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter()
|
| 999 |
+
.filter(|v| total_demand(v) > v.capacity)
|
| 1000 |
+
.map(|v| {
|
| 1001 |
+
let demand = total_demand(v);
|
| 1002 |
+
let excess = demand - v.capacity;
|
| 1003 |
+
MatchAnalysisDto {
|
| 1004 |
+
name: "Vehicle capacity".to_string(),
|
| 1005 |
+
score: format!("{}hard/0soft", -excess),
|
| 1006 |
+
justification: format!("{} is over capacity by {} (demand {} > capacity {})",
|
| 1007 |
+
v.name, excess, demand, v.capacity),
|
| 1008 |
+
}
|
| 1009 |
+
})
|
| 1010 |
+
.collect();
|
| 1011 |
+
|
| 1012 |
+
// Build detailed matches for time window constraint
|
| 1013 |
+
let mut tw_matches: Vec<MatchAnalysisDto> = Vec::new();
|
| 1014 |
+
for vehicle in &plan.vehicles {
|
| 1015 |
+
let timings = plan.calculate_route_times(vehicle);
|
| 1016 |
+
for timing in &timings {
|
| 1017 |
+
if let Some(visit) = plan.get_visit(timing.visit_idx) {
|
| 1018 |
+
if timing.departure > visit.max_end_time {
|
| 1019 |
+
let late_secs = timing.departure - visit.max_end_time;
|
| 1020 |
+
let late_mins = (late_secs + 59) / 60;
|
| 1021 |
+
tw_matches.push(MatchAnalysisDto {
|
| 1022 |
+
name: "Service finished after max end time".to_string(),
|
| 1023 |
+
score: format!("{}hard/0soft", -late_mins),
|
| 1024 |
+
justification: format!("{} finishes {} mins late (ends at {}, max {})",
|
| 1025 |
+
visit.name, late_mins,
|
| 1026 |
+
seconds_to_iso(timing.departure),
|
| 1027 |
+
seconds_to_iso(visit.max_end_time)),
|
| 1028 |
+
});
|
| 1029 |
+
}
|
| 1030 |
+
}
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
|
| 1034 |
+
// Build matches for travel time
|
| 1035 |
+
let travel_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter()
|
| 1036 |
+
.filter(|v| !v.visits.is_empty())
|
| 1037 |
+
.map(|v| {
|
| 1038 |
+
let time = plan.total_driving_time(v);
|
| 1039 |
+
MatchAnalysisDto {
|
| 1040 |
+
name: "Minimize travel time".to_string(),
|
| 1041 |
+
score: format!("0hard/{}soft", -time),
|
| 1042 |
+
justification: format!("{} drives {} seconds", v.name, time),
|
| 1043 |
+
}
|
| 1044 |
+
})
|
| 1045 |
+
.collect();
|
| 1046 |
+
|
| 1047 |
+
let constraints = vec![
|
| 1048 |
+
ConstraintAnalysisDto {
|
| 1049 |
+
name: "Vehicle capacity".to_string(),
|
| 1050 |
+
weight: "1hard/0soft".to_string(),
|
| 1051 |
+
score: format!("{}", cap_score),
|
| 1052 |
+
matches: cap_matches,
|
| 1053 |
+
},
|
| 1054 |
+
ConstraintAnalysisDto {
|
| 1055 |
+
name: "Service finished after max end time".to_string(),
|
| 1056 |
+
weight: "1hard/0soft".to_string(),
|
| 1057 |
+
score: format!("{}", tw_score),
|
| 1058 |
+
matches: tw_matches,
|
| 1059 |
+
},
|
| 1060 |
+
ConstraintAnalysisDto {
|
| 1061 |
+
name: "Minimize travel time".to_string(),
|
| 1062 |
+
weight: "0hard/1soft".to_string(),
|
| 1063 |
+
score: format!("{}", travel_score),
|
| 1064 |
+
matches: travel_matches,
|
| 1065 |
+
},
|
| 1066 |
+
];
|
| 1067 |
+
|
| 1068 |
+
Json(AnalyzeResponse { constraints })
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
// ============================================================================
|
| 1072 |
+
// Recommendation
|
| 1073 |
+
// ============================================================================
|
| 1074 |
+
|
| 1075 |
+
/// Recommended assignment for a visit.
|
| 1076 |
+
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
| 1077 |
+
#[serde(rename_all = "camelCase")]
|
| 1078 |
+
pub struct VehicleRecommendation {
|
| 1079 |
+
/// Vehicle ID to assign to.
|
| 1080 |
+
pub vehicle_id: String,
|
| 1081 |
+
/// Position in vehicle's route.
|
| 1082 |
+
pub index: usize,
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
/// Recommendation response with score impact.
|
| 1086 |
+
#[derive(Debug, Clone, Serialize, ToSchema)]
|
| 1087 |
+
#[serde(rename_all = "camelCase")]
|
| 1088 |
+
pub struct RecommendedAssignment {
|
| 1089 |
+
/// The recommendation.
|
| 1090 |
+
pub proposition: VehicleRecommendation,
|
| 1091 |
+
/// Score difference if applied.
|
| 1092 |
+
pub score_diff: String,
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
/// Request for visit recommendations.
|
| 1096 |
+
#[derive(Debug, Deserialize, ToSchema)]
|
| 1097 |
+
#[serde(rename_all = "camelCase")]
|
| 1098 |
+
pub struct RecommendationRequest {
|
| 1099 |
+
/// Current solution.
|
| 1100 |
+
pub solution: RoutePlanDto,
|
| 1101 |
+
/// Visit ID to find recommendations for.
|
| 1102 |
+
pub visit_id: String,
|
| 1103 |
+
}
|
| 1104 |
+
|
| 1105 |
+
/// Request to apply a recommendation.
|
| 1106 |
+
#[derive(Debug, Deserialize, ToSchema)]
|
| 1107 |
+
#[serde(rename_all = "camelCase")]
|
| 1108 |
+
pub struct ApplyRecommendationRequest {
|
| 1109 |
+
/// Current solution.
|
| 1110 |
+
pub solution: RoutePlanDto,
|
| 1111 |
+
/// Visit ID to assign.
|
| 1112 |
+
pub visit_id: String,
|
| 1113 |
+
/// Vehicle ID to assign to.
|
| 1114 |
+
pub vehicle_id: String,
|
| 1115 |
+
/// Position in vehicle's route.
|
| 1116 |
+
pub index: usize,
|
| 1117 |
+
}
|
| 1118 |
+
|
| 1119 |
+
/// POST /route-plans/recommendation - Get recommendations for assigning a visit.
|
| 1120 |
+
#[utoipa::path(
|
| 1121 |
+
post,
|
| 1122 |
+
path = "/route-plans/recommendation",
|
| 1123 |
+
request_body = RecommendationRequest,
|
| 1124 |
+
responses((status = 200, description = "Recommendations", body = Vec<RecommendedAssignment>))
|
| 1125 |
+
)]
|
| 1126 |
+
async fn recommend_assignment(Json(request): Json<RecommendationRequest>) -> Json<Vec<RecommendedAssignment>> {
|
| 1127 |
+
use crate::constraints::calculate_score;
|
| 1128 |
+
|
| 1129 |
+
let mut plan = request.solution.to_domain();
|
| 1130 |
+
|
| 1131 |
+
// Find the visit index by ID
|
| 1132 |
+
let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX);
|
| 1133 |
+
if visit_id_num >= plan.visits.len() {
|
| 1134 |
+
return Json(vec![]);
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
// Remove visit from any current assignment
|
| 1138 |
+
for vehicle in &mut plan.vehicles {
|
| 1139 |
+
vehicle.visits.retain(|&v| v != visit_id_num);
|
| 1140 |
+
}
|
| 1141 |
+
plan.finalize();
|
| 1142 |
+
|
| 1143 |
+
// Get baseline score
|
| 1144 |
+
let baseline = calculate_score(&plan);
|
| 1145 |
+
|
| 1146 |
+
// Try inserting at each position in each vehicle
|
| 1147 |
+
let mut recommendations: Vec<(RecommendedAssignment, HardSoftScore)> = Vec::new();
|
| 1148 |
+
|
| 1149 |
+
for (v_idx, vehicle) in plan.vehicles.iter().enumerate() {
|
| 1150 |
+
for insert_pos in 0..=vehicle.visits.len() {
|
| 1151 |
+
// Clone and insert
|
| 1152 |
+
let mut test_plan = plan.clone();
|
| 1153 |
+
test_plan.vehicles[v_idx].visits.insert(insert_pos, visit_id_num);
|
| 1154 |
+
test_plan.finalize();
|
| 1155 |
+
|
| 1156 |
+
let new_score = calculate_score(&test_plan);
|
| 1157 |
+
let diff = new_score - baseline;
|
| 1158 |
+
|
| 1159 |
+
recommendations.push((
|
| 1160 |
+
RecommendedAssignment {
|
| 1161 |
+
proposition: VehicleRecommendation {
|
| 1162 |
+
vehicle_id: vehicle.id.to_string(),
|
| 1163 |
+
index: insert_pos,
|
| 1164 |
+
},
|
| 1165 |
+
score_diff: format!("{}", diff),
|
| 1166 |
+
},
|
| 1167 |
+
diff,
|
| 1168 |
+
));
|
| 1169 |
+
}
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
// Sort by score (best first) and take top 5
|
| 1173 |
+
recommendations.sort_by(|a, b| b.1.cmp(&a.1));
|
| 1174 |
+
let top5: Vec<RecommendedAssignment> = recommendations.into_iter().take(5).map(|(r, _)| r).collect();
|
| 1175 |
+
|
| 1176 |
+
Json(top5)
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
/// POST /route-plans/recommendation/apply - Apply a recommendation.
|
| 1180 |
+
#[utoipa::path(
|
| 1181 |
+
post,
|
| 1182 |
+
path = "/route-plans/recommendation/apply",
|
| 1183 |
+
request_body = ApplyRecommendationRequest,
|
| 1184 |
+
responses((status = 200, description = "Updated solution", body = RoutePlanDto))
|
| 1185 |
+
)]
|
| 1186 |
+
async fn apply_recommendation(Json(request): Json<ApplyRecommendationRequest>) -> Json<RoutePlanDto> {
|
| 1187 |
+
let mut plan = request.solution.to_domain();
|
| 1188 |
+
|
| 1189 |
+
// Find the visit index by ID
|
| 1190 |
+
let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX);
|
| 1191 |
+
let vehicle_id_num: usize = request.vehicle_id.parse().unwrap_or(usize::MAX);
|
| 1192 |
+
|
| 1193 |
+
// Remove visit from any current assignment
|
| 1194 |
+
for vehicle in &mut plan.vehicles {
|
| 1195 |
+
vehicle.visits.retain(|&v| v != visit_id_num);
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
// Insert at specified position
|
| 1199 |
+
if let Some(vehicle) = plan.vehicles.iter_mut().find(|v| v.id == vehicle_id_num) {
|
| 1200 |
+
let insert_idx = request.index.min(vehicle.visits.len());
|
| 1201 |
+
vehicle.visits.insert(insert_idx, visit_id_num);
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
plan.finalize();
|
| 1205 |
+
|
| 1206 |
+
// Recalculate score
|
| 1207 |
+
use crate::constraints::calculate_score;
|
| 1208 |
+
plan.score = Some(calculate_score(&plan));
|
| 1209 |
+
|
| 1210 |
+
Json(RoutePlanDto::from_plan(&plan, None))
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
// ============================================================================
|
| 1214 |
+
// OpenAPI Documentation
|
| 1215 |
+
// ============================================================================
|
| 1216 |
+
|
| 1217 |
+
#[derive(OpenApi)]
|
| 1218 |
+
#[openapi(
|
| 1219 |
+
paths(
|
| 1220 |
+
health,
|
| 1221 |
+
info,
|
| 1222 |
+
list_demo_data,
|
| 1223 |
+
get_demo_data,
|
| 1224 |
+
create_route_plan,
|
| 1225 |
+
list_route_plans,
|
| 1226 |
+
get_route_plan,
|
| 1227 |
+
get_route_plan_status,
|
| 1228 |
+
stop_solving,
|
| 1229 |
+
get_route_geometry,
|
| 1230 |
+
analyze_route_plan,
|
| 1231 |
+
recommend_assignment,
|
| 1232 |
+
apply_recommendation,
|
| 1233 |
+
),
|
| 1234 |
+
components(schemas(
|
| 1235 |
+
HealthResponse,
|
| 1236 |
+
InfoResponse,
|
| 1237 |
+
VisitDto,
|
| 1238 |
+
VehicleDto,
|
| 1239 |
+
RoutePlanDto,
|
| 1240 |
+
TerminationConfigDto,
|
| 1241 |
+
StatusResponse,
|
| 1242 |
+
GeometryResponse,
|
| 1243 |
+
MatchAnalysisDto,
|
| 1244 |
+
ConstraintAnalysisDto,
|
| 1245 |
+
AnalyzeResponse,
|
| 1246 |
+
VehicleRecommendation,
|
| 1247 |
+
RecommendedAssignment,
|
| 1248 |
+
RecommendationRequest,
|
| 1249 |
+
ApplyRecommendationRequest,
|
| 1250 |
+
))
|
| 1251 |
+
)]
|
| 1252 |
+
struct ApiDoc;
|
src/console.rs
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Colorful console output for solver metrics.
|
| 2 |
+
|
| 3 |
+
use num_format::{Locale, ToFormattedString};
|
| 4 |
+
use owo_colors::OwoColorize;
|
| 5 |
+
use std::time::{Duration, Instant};
|
| 6 |
+
|
| 7 |
+
/// ASCII art banner for solver startup.
|
| 8 |
+
pub fn print_banner() {
|
| 9 |
+
let banner = r#"
|
| 10 |
+
____ _ _____
|
| 11 |
+
/ ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___
|
| 12 |
+
\___ \ / _ \| \ \ / / _ \ '__| |_ / _ \| '__/ _` |/ _ \
|
| 13 |
+
___) | (_) | |\ V / __/ | | _| (_) | | | (_| | __/
|
| 14 |
+
|____/ \___/|_| \_/ \___|_| |_| \___/|_| \__, |\___|
|
| 15 |
+
|___/
|
| 16 |
+
"#;
|
| 17 |
+
println!("{}", banner.cyan().bold());
|
| 18 |
+
println!(
|
| 19 |
+
" {} {}\n",
|
| 20 |
+
format!("v{}", env!("CARGO_PKG_VERSION")).bright_black(),
|
| 21 |
+
"Vehicle Routing".bright_cyan()
|
| 22 |
+
);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/// Prints "Solving started" message.
|
| 26 |
+
pub fn print_solving_started(
|
| 27 |
+
time_spent_ms: u64,
|
| 28 |
+
best_score: &str,
|
| 29 |
+
entity_count: usize,
|
| 30 |
+
variable_count: usize,
|
| 31 |
+
value_count: usize,
|
| 32 |
+
) {
|
| 33 |
+
println!(
|
| 34 |
+
"{} {} {} time spent ({}), best score ({}), random ({})",
|
| 35 |
+
timestamp().bright_black(),
|
| 36 |
+
"INFO".bright_green(),
|
| 37 |
+
"[Solver]".bright_cyan(),
|
| 38 |
+
format!("{}ms", time_spent_ms).yellow(),
|
| 39 |
+
format_score(best_score),
|
| 40 |
+
"StdRng".white()
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
// Problem scale
|
| 44 |
+
let scale = calculate_problem_scale(entity_count, value_count);
|
| 45 |
+
println!(
|
| 46 |
+
"{} {} {} entity count ({}), variable count ({}), value count ({}), problem scale ({})",
|
| 47 |
+
timestamp().bright_black(),
|
| 48 |
+
"INFO".bright_green(),
|
| 49 |
+
"[Solver]".bright_cyan(),
|
| 50 |
+
entity_count.to_formatted_string(&Locale::en).bright_yellow(),
|
| 51 |
+
variable_count.to_formatted_string(&Locale::en).bright_yellow(),
|
| 52 |
+
value_count.to_formatted_string(&Locale::en).bright_yellow(),
|
| 53 |
+
scale.bright_magenta()
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/// Prints a phase start message.
|
| 58 |
+
pub fn print_phase_start(phase_name: &str, phase_index: usize) {
|
| 59 |
+
println!(
|
| 60 |
+
"{} {} {} {} phase ({}) started",
|
| 61 |
+
timestamp().bright_black(),
|
| 62 |
+
"INFO".bright_green(),
|
| 63 |
+
format!("[{}]", phase_name).bright_cyan(),
|
| 64 |
+
phase_name.white().bold(),
|
| 65 |
+
phase_index.to_string().yellow()
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/// Prints a phase end message with metrics.
|
| 70 |
+
pub fn print_phase_end(
|
| 71 |
+
phase_name: &str,
|
| 72 |
+
phase_index: usize,
|
| 73 |
+
duration: Duration,
|
| 74 |
+
steps_accepted: u64,
|
| 75 |
+
moves_evaluated: u64,
|
| 76 |
+
best_score: &str,
|
| 77 |
+
) {
|
| 78 |
+
let moves_per_sec = if duration.as_secs_f64() > 0.0 {
|
| 79 |
+
(moves_evaluated as f64 / duration.as_secs_f64()) as u64
|
| 80 |
+
} else {
|
| 81 |
+
0
|
| 82 |
+
};
|
| 83 |
+
let acceptance_rate = if moves_evaluated > 0 {
|
| 84 |
+
(steps_accepted as f64 / moves_evaluated as f64) * 100.0
|
| 85 |
+
} else {
|
| 86 |
+
0.0
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
println!(
|
| 90 |
+
"{} {} {} {} phase ({}) ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), step total ({}, {:.1}% accepted)",
|
| 91 |
+
timestamp().bright_black(),
|
| 92 |
+
"INFO".bright_green(),
|
| 93 |
+
format!("[{}]", phase_name).bright_cyan(),
|
| 94 |
+
phase_name.white().bold(),
|
| 95 |
+
phase_index.to_string().yellow(),
|
| 96 |
+
format_duration(duration).yellow(),
|
| 97 |
+
format_score(best_score),
|
| 98 |
+
moves_per_sec.to_formatted_string(&Locale::en).bright_magenta().bold(),
|
| 99 |
+
steps_accepted.to_formatted_string(&Locale::en).white(),
|
| 100 |
+
acceptance_rate
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/// Prints a step progress update with moves/sec prominently displayed.
|
| 105 |
+
pub fn print_step_progress(
|
| 106 |
+
step: u64,
|
| 107 |
+
elapsed: Duration,
|
| 108 |
+
moves_evaluated: u64,
|
| 109 |
+
score: &str,
|
| 110 |
+
) {
|
| 111 |
+
let moves_per_sec = if elapsed.as_secs_f64() > 0.0 {
|
| 112 |
+
(moves_evaluated as f64 / elapsed.as_secs_f64()) as u64
|
| 113 |
+
} else {
|
| 114 |
+
0
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
println!(
|
| 118 |
+
" {} Step {:>7} │ {} │ {}/sec │ {}",
|
| 119 |
+
"→".bright_blue(),
|
| 120 |
+
step.to_formatted_string(&Locale::en).white(),
|
| 121 |
+
format!("{:>6}", format_duration(elapsed)).bright_black(),
|
| 122 |
+
format!("{:>8}", moves_per_sec.to_formatted_string(&Locale::en)).bright_magenta().bold(),
|
| 123 |
+
format_score(score)
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/// Prints solver completion summary.
|
| 128 |
+
pub fn print_solving_ended(
|
| 129 |
+
total_duration: Duration,
|
| 130 |
+
total_moves: u64,
|
| 131 |
+
phase_count: usize,
|
| 132 |
+
final_score: &str,
|
| 133 |
+
is_feasible: bool,
|
| 134 |
+
) {
|
| 135 |
+
let moves_per_sec = if total_duration.as_secs_f64() > 0.0 {
|
| 136 |
+
(total_moves as f64 / total_duration.as_secs_f64()) as u64
|
| 137 |
+
} else {
|
| 138 |
+
0
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
println!(
|
| 142 |
+
"{} {} {} Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), phase total ({})",
|
| 143 |
+
timestamp().bright_black(),
|
| 144 |
+
"INFO".bright_green(),
|
| 145 |
+
"[Solver]".bright_cyan(),
|
| 146 |
+
format_duration(total_duration).yellow(),
|
| 147 |
+
format_score(final_score),
|
| 148 |
+
moves_per_sec.to_formatted_string(&Locale::en).bright_magenta().bold(),
|
| 149 |
+
phase_count.to_string().white()
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
// Pretty summary box (60 chars wide, 56 char content area)
|
| 153 |
+
println!();
|
| 154 |
+
println!("{}", "╔═════════════════════��════════════════════════════════════╗".bright_cyan());
|
| 155 |
+
|
| 156 |
+
let status_text = if is_feasible {
|
| 157 |
+
"✓ FEASIBLE SOLUTION FOUND"
|
| 158 |
+
} else {
|
| 159 |
+
"✗ INFEASIBLE (hard constraints violated)"
|
| 160 |
+
};
|
| 161 |
+
let status_colored = if is_feasible {
|
| 162 |
+
status_text.bright_green().bold().to_string()
|
| 163 |
+
} else {
|
| 164 |
+
status_text.bright_red().bold().to_string()
|
| 165 |
+
};
|
| 166 |
+
let status_padding = 56 - status_text.chars().count();
|
| 167 |
+
let left_pad = status_padding / 2;
|
| 168 |
+
let right_pad = status_padding - left_pad;
|
| 169 |
+
println!(
|
| 170 |
+
"{}{}{}{}{}",
|
| 171 |
+
"║".bright_cyan(),
|
| 172 |
+
" ".repeat(left_pad),
|
| 173 |
+
status_colored,
|
| 174 |
+
" ".repeat(right_pad),
|
| 175 |
+
"║".bright_cyan()
|
| 176 |
+
);
|
| 177 |
+
|
| 178 |
+
println!("{}", "╠══════════════════════════════════════════════════════════╣".bright_cyan());
|
| 179 |
+
|
| 180 |
+
let score_str = final_score;
|
| 181 |
+
println!(
|
| 182 |
+
"{} {:<18}{:>36} {}",
|
| 183 |
+
"║".bright_cyan(),
|
| 184 |
+
"Final Score:",
|
| 185 |
+
score_str,
|
| 186 |
+
"║".bright_cyan()
|
| 187 |
+
);
|
| 188 |
+
|
| 189 |
+
let time_str = format!("{:.2}s", total_duration.as_secs_f64());
|
| 190 |
+
println!(
|
| 191 |
+
"{} {:<18}{:>36} {}",
|
| 192 |
+
"║".bright_cyan(),
|
| 193 |
+
"Solving Time:",
|
| 194 |
+
time_str,
|
| 195 |
+
"║".bright_cyan()
|
| 196 |
+
);
|
| 197 |
+
|
| 198 |
+
let speed_str = format!("{}/sec", moves_per_sec.to_formatted_string(&Locale::en));
|
| 199 |
+
println!(
|
| 200 |
+
"{} {:<18}{:>36} {}",
|
| 201 |
+
"║".bright_cyan(),
|
| 202 |
+
"Move Speed:",
|
| 203 |
+
speed_str,
|
| 204 |
+
"║".bright_cyan()
|
| 205 |
+
);
|
| 206 |
+
|
| 207 |
+
println!("{}", "╚══════════════════════════════════════════════════════════╝".bright_cyan());
|
| 208 |
+
println!();
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/// Prints VRP-specific configuration.
|
| 212 |
+
pub fn print_config(vehicles: usize, visits: usize, locations: usize) {
|
| 213 |
+
println!(
|
| 214 |
+
"{} {} {} Problem: vehicles ({}), visits ({}), locations ({})",
|
| 215 |
+
timestamp().bright_black(),
|
| 216 |
+
"INFO".bright_green(),
|
| 217 |
+
"[Solver]".bright_cyan(),
|
| 218 |
+
vehicles.to_formatted_string(&Locale::en).bright_yellow(),
|
| 219 |
+
visits.to_formatted_string(&Locale::en).bright_yellow(),
|
| 220 |
+
locations.to_formatted_string(&Locale::en).bright_yellow()
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/// Formats a duration nicely.
|
| 225 |
+
fn format_duration(d: Duration) -> String {
|
| 226 |
+
let total_ms = d.as_millis();
|
| 227 |
+
if total_ms < 1000 {
|
| 228 |
+
format!("{}ms", total_ms)
|
| 229 |
+
} else if total_ms < 60_000 {
|
| 230 |
+
format!("{:.2}s", d.as_secs_f64())
|
| 231 |
+
} else {
|
| 232 |
+
let mins = total_ms / 60_000;
|
| 233 |
+
let secs = (total_ms % 60_000) / 1000;
|
| 234 |
+
format!("{}m {}s", mins, secs)
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
/// Formats a score with colors based on feasibility.
|
| 239 |
+
fn format_score(score: &str) -> String {
|
| 240 |
+
// Parse HardSoftScore format like "-2hard/5soft" or "0hard/10soft"
|
| 241 |
+
if score.contains("hard") {
|
| 242 |
+
let parts: Vec<&str> = score.split('/').collect();
|
| 243 |
+
if parts.len() == 2 {
|
| 244 |
+
let hard = parts[0].trim_end_matches("hard");
|
| 245 |
+
let soft = parts[1].trim_end_matches("soft");
|
| 246 |
+
|
| 247 |
+
let hard_num: f64 = hard.parse().unwrap_or(0.0);
|
| 248 |
+
let soft_num: f64 = soft.parse().unwrap_or(0.0);
|
| 249 |
+
|
| 250 |
+
let hard_str = if hard_num < 0.0 {
|
| 251 |
+
format!("{}hard", hard).bright_red().to_string()
|
| 252 |
+
} else {
|
| 253 |
+
format!("{}hard", hard).bright_green().to_string()
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
let soft_str = if soft_num < 0.0 {
|
| 257 |
+
format!("{}soft", soft).yellow().to_string()
|
| 258 |
+
} else if soft_num > 0.0 {
|
| 259 |
+
format!("{}soft", soft).bright_green().to_string()
|
| 260 |
+
} else {
|
| 261 |
+
format!("{}soft", soft).white().to_string()
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
return format!("{}/{}", hard_str, soft_str);
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Simple score
|
| 269 |
+
if let Ok(n) = score.parse::<i32>() {
|
| 270 |
+
if n < 0 {
|
| 271 |
+
return score.bright_red().to_string();
|
| 272 |
+
} else if n > 0 {
|
| 273 |
+
return score.bright_green().to_string();
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
score.white().to_string()
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/// Returns a timestamp string.
|
| 281 |
+
fn timestamp() -> String {
|
| 282 |
+
std::time::SystemTime::now()
|
| 283 |
+
.duration_since(std::time::UNIX_EPOCH)
|
| 284 |
+
.map(|d| {
|
| 285 |
+
let secs = d.as_secs();
|
| 286 |
+
let millis = d.subsec_millis();
|
| 287 |
+
format!("{}.{:03}", secs, millis)
|
| 288 |
+
})
|
| 289 |
+
.unwrap_or_else(|_| "0.000".to_string())
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
/// Calculates an approximate problem scale.
|
| 293 |
+
fn calculate_problem_scale(entity_count: usize, value_count: usize) -> String {
|
| 294 |
+
if entity_count == 0 || value_count == 0 {
|
| 295 |
+
return "0".to_string();
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// value_count ^ entity_count
|
| 299 |
+
let log_scale = (entity_count as f64) * (value_count as f64).log10();
|
| 300 |
+
let exponent = log_scale.floor() as i32;
|
| 301 |
+
let mantissa = 10f64.powf(log_scale - exponent as f64);
|
| 302 |
+
|
| 303 |
+
format!("{:.3} × 10^{}", mantissa, exponent)
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/// A timer for tracking phase/step durations.
|
| 307 |
+
pub struct PhaseTimer {
|
| 308 |
+
start: Instant,
|
| 309 |
+
phase_name: String,
|
| 310 |
+
phase_index: usize,
|
| 311 |
+
steps_accepted: u64,
|
| 312 |
+
moves_evaluated: u64,
|
| 313 |
+
last_score: String,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
impl PhaseTimer {
|
| 317 |
+
pub fn start(phase_name: impl Into<String>, phase_index: usize) -> Self {
|
| 318 |
+
let name = phase_name.into();
|
| 319 |
+
print_phase_start(&name, phase_index);
|
| 320 |
+
Self {
|
| 321 |
+
start: Instant::now(),
|
| 322 |
+
phase_name: name,
|
| 323 |
+
phase_index,
|
| 324 |
+
steps_accepted: 0,
|
| 325 |
+
moves_evaluated: 0,
|
| 326 |
+
last_score: String::new(),
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
pub fn record_accepted(&mut self, score: &str) {
|
| 331 |
+
self.steps_accepted += 1;
|
| 332 |
+
self.last_score = score.to_string();
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
pub fn record_move(&mut self) {
|
| 336 |
+
self.moves_evaluated += 1;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
pub fn elapsed(&self) -> Duration {
|
| 340 |
+
self.start.elapsed()
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
pub fn moves_evaluated(&self) -> u64 {
|
| 344 |
+
self.moves_evaluated
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
pub fn finish(self) {
|
| 348 |
+
print_phase_end(
|
| 349 |
+
&self.phase_name,
|
| 350 |
+
self.phase_index,
|
| 351 |
+
self.start.elapsed(),
|
| 352 |
+
self.steps_accepted,
|
| 353 |
+
self.moves_evaluated,
|
| 354 |
+
&self.last_score,
|
| 355 |
+
);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
pub fn steps_accepted(&self) -> u64 {
|
| 359 |
+
self.steps_accepted
|
| 360 |
+
}
|
| 361 |
+
}
|
src/constraints.rs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Score calculator for Vehicle Routing Problem.
|
| 2 |
+
//!
|
| 3 |
+
//! # Constraints
|
| 4 |
+
//!
|
| 5 |
+
//! - **Vehicle capacity** (hard): Total demand must not exceed vehicle capacity
|
| 6 |
+
//! - **Time windows** (hard): Service must complete before max end time
|
| 7 |
+
//! - **Minimize travel time** (soft): Reduce total driving time
|
| 8 |
+
//!
|
| 9 |
+
//! # Design
|
| 10 |
+
//!
|
| 11 |
+
//! Uses a simple score calculator function with full solution access.
|
| 12 |
+
//! No global state or RwLock overhead - direct array indexing into the plan's
|
| 13 |
+
//! travel time matrix and visits.
|
| 14 |
+
|
| 15 |
+
use solverforge::prelude::*;
|
| 16 |
+
|
| 17 |
+
use crate::domain::{Vehicle, VehicleRoutePlan};
|
| 18 |
+
|
| 19 |
+
/// Calculates the score for a vehicle routing solution.
|
| 20 |
+
///
|
| 21 |
+
/// # Hard constraints
|
| 22 |
+
/// - Vehicle capacity: penalize excess demand
|
| 23 |
+
/// - Time windows: penalize late arrivals
|
| 24 |
+
///
|
| 25 |
+
/// # Soft constraints
|
| 26 |
+
/// - Minimize total travel time (in minutes)
|
| 27 |
+
///
|
| 28 |
+
/// # Examples
|
| 29 |
+
///
|
| 30 |
+
/// ```
|
| 31 |
+
/// use vehicle_routing::constraints::calculate_score;
|
| 32 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 33 |
+
/// use solverforge::prelude::Score; // For is_feasible()
|
| 34 |
+
///
|
| 35 |
+
/// let depot = Location::new(0, 0.0, 0.0);
|
| 36 |
+
/// let locations = vec![depot.clone()];
|
| 37 |
+
/// let visits = vec![Visit::new(0, "A", depot.clone()).with_demand(5)];
|
| 38 |
+
/// let mut vehicle = Vehicle::new(0, "V1", 10, depot);
|
| 39 |
+
/// vehicle.visits = vec![0];
|
| 40 |
+
///
|
| 41 |
+
/// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
|
| 42 |
+
/// plan.finalize();
|
| 43 |
+
///
|
| 44 |
+
/// let score = calculate_score(&plan);
|
| 45 |
+
/// assert!(score.is_feasible()); // Demand 5 <= capacity 10
|
| 46 |
+
/// ```
|
| 47 |
+
pub fn calculate_score(plan: &VehicleRoutePlan) -> HardSoftScore {
|
| 48 |
+
let mut hard = 0i64;
|
| 49 |
+
let mut soft = 0i64;
|
| 50 |
+
|
| 51 |
+
for vehicle in &plan.vehicles {
|
| 52 |
+
// =====================================================================
|
| 53 |
+
// HARD: Vehicle Capacity
|
| 54 |
+
// =====================================================================
|
| 55 |
+
let total_demand: i32 = vehicle
|
| 56 |
+
.visits
|
| 57 |
+
.iter()
|
| 58 |
+
.filter_map(|&idx| plan.visits.get(idx))
|
| 59 |
+
.map(|v| v.demand)
|
| 60 |
+
.sum();
|
| 61 |
+
|
| 62 |
+
if total_demand > vehicle.capacity {
|
| 63 |
+
hard -= (total_demand - vehicle.capacity) as i64;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// =====================================================================
|
| 67 |
+
// HARD: Time Windows
|
| 68 |
+
// =====================================================================
|
| 69 |
+
let late_minutes = calculate_late_minutes_for_vehicle(plan, vehicle);
|
| 70 |
+
if late_minutes > 0 {
|
| 71 |
+
hard -= late_minutes;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// =====================================================================
|
| 75 |
+
// SOFT: Minimize Travel Time
|
| 76 |
+
// =====================================================================
|
| 77 |
+
let driving_seconds = plan.total_driving_time(vehicle);
|
| 78 |
+
soft -= driving_seconds / 60; // Convert to minutes
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
HardSoftScore::of(hard, soft)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/// Calculates total late minutes for a vehicle's route.
|
| 85 |
+
///
|
| 86 |
+
/// A visit is late if service finishes after `max_end_time`.
|
| 87 |
+
fn calculate_late_minutes_for_vehicle(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 {
|
| 88 |
+
if vehicle.visits.is_empty() {
|
| 89 |
+
return 0;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
let mut total_late = 0i64;
|
| 93 |
+
let mut current_time = vehicle.departure_time;
|
| 94 |
+
let mut current_loc_idx = vehicle.home_location.index;
|
| 95 |
+
|
| 96 |
+
for &visit_idx in &vehicle.visits {
|
| 97 |
+
let Some(visit) = plan.visits.get(visit_idx) else {
|
| 98 |
+
continue;
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
// Travel to this visit
|
| 102 |
+
let travel = plan.travel_time(current_loc_idx, visit.location.index);
|
| 103 |
+
let arrival = current_time + travel;
|
| 104 |
+
|
| 105 |
+
// Service starts at max(arrival, min_start_time)
|
| 106 |
+
let service_start = arrival.max(visit.min_start_time);
|
| 107 |
+
let service_end = service_start + visit.service_duration;
|
| 108 |
+
|
| 109 |
+
// Check if late (service finishes after max_end_time)
|
| 110 |
+
if service_end > visit.max_end_time {
|
| 111 |
+
let late_seconds = service_end - visit.max_end_time;
|
| 112 |
+
// Round up to minutes
|
| 113 |
+
total_late += (late_seconds + 59) / 60;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
current_time = service_end;
|
| 117 |
+
current_loc_idx = visit.location.index;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
total_late
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// ============================================================================
|
| 124 |
+
// Helper functions (for analyze endpoint)
|
| 125 |
+
// ============================================================================
|
| 126 |
+
|
| 127 |
+
/// Calculates total late minutes for a vehicle's route (public API).
|
| 128 |
+
///
|
| 129 |
+
/// # Examples
|
| 130 |
+
///
|
| 131 |
+
/// ```
|
| 132 |
+
/// use vehicle_routing::constraints::calculate_late_minutes;
|
| 133 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 134 |
+
///
|
| 135 |
+
/// let depot = Location::new(0, 0.0, 0.0);
|
| 136 |
+
/// let customer = Location::new(1, 0.0, 1.0); // ~111 km away, ~2.2 hours at 50 km/h
|
| 137 |
+
///
|
| 138 |
+
/// let locations = vec![depot.clone(), customer.clone()];
|
| 139 |
+
/// let visits = vec![
|
| 140 |
+
/// Visit::new(0, "A", customer)
|
| 141 |
+
/// .with_time_window(0, 8 * 3600 + 30 * 60) // Must finish by 8:30am
|
| 142 |
+
/// .with_service_duration(300), // 5 min service
|
| 143 |
+
/// ];
|
| 144 |
+
/// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
|
| 145 |
+
/// vehicle.departure_time = 8 * 3600; // Depart at 8am
|
| 146 |
+
/// vehicle.visits = vec![0];
|
| 147 |
+
///
|
| 148 |
+
/// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]);
|
| 149 |
+
/// plan.finalize();
|
| 150 |
+
///
|
| 151 |
+
/// // Vehicle departs 8am, travels ~2.2 hours, arrives ~10:13am
|
| 152 |
+
/// // Service ends ~10:18am, but max_end is 8:30am
|
| 153 |
+
/// // Late by ~108 minutes
|
| 154 |
+
/// let late = calculate_late_minutes(&plan, &vehicle);
|
| 155 |
+
/// assert!(late > 100);
|
| 156 |
+
/// ```
|
| 157 |
+
#[inline]
|
| 158 |
+
pub fn calculate_late_minutes(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 {
|
| 159 |
+
calculate_late_minutes_for_vehicle(plan, vehicle)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/// Calculates excess demand for a vehicle (0 if under capacity).
|
| 163 |
+
///
|
| 164 |
+
/// # Examples
|
| 165 |
+
///
|
| 166 |
+
/// ```
|
| 167 |
+
/// use vehicle_routing::constraints::calculate_excess_capacity;
|
| 168 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 169 |
+
///
|
| 170 |
+
/// let depot = Location::new(0, 0.0, 0.0);
|
| 171 |
+
/// let locations = vec![depot.clone()];
|
| 172 |
+
/// let visits = vec![
|
| 173 |
+
/// Visit::new(0, "A", depot.clone()).with_demand(60),
|
| 174 |
+
/// Visit::new(1, "B", depot.clone()).with_demand(50),
|
| 175 |
+
/// ];
|
| 176 |
+
/// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
|
| 177 |
+
/// vehicle.visits = vec![0, 1]; // Total demand = 110
|
| 178 |
+
///
|
| 179 |
+
/// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]);
|
| 180 |
+
/// plan.finalize();
|
| 181 |
+
///
|
| 182 |
+
/// // Excess = 110 - 100 = 10
|
| 183 |
+
/// assert_eq!(calculate_excess_capacity(&plan, &vehicle), 10);
|
| 184 |
+
/// ```
|
| 185 |
+
#[inline]
|
| 186 |
+
pub fn calculate_excess_capacity(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i32 {
|
| 187 |
+
let total_demand: i32 = vehicle
|
| 188 |
+
.visits
|
| 189 |
+
.iter()
|
| 190 |
+
.filter_map(|&idx| plan.visits.get(idx))
|
| 191 |
+
.map(|v| v.demand)
|
| 192 |
+
.sum();
|
| 193 |
+
|
| 194 |
+
(total_demand - vehicle.capacity).max(0)
|
| 195 |
+
}
|
src/demo_data.rs
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Demo data generators for Vehicle Routing Problem.
|
| 2 |
+
//!
|
| 3 |
+
//! Provides realistic demo datasets for three cities:
|
| 4 |
+
//! - Philadelphia (49 visits, 6 vehicles)
|
| 5 |
+
//! - Hartford (30 visits, 6 vehicles)
|
| 6 |
+
//! - Firenze (48 visits, 6 vehicles)
|
| 7 |
+
//!
|
| 8 |
+
//! Uses real street addresses and weighted customer types:
|
| 9 |
+
//! - Residential (50%): 17:00-20:00, demand 1-2
|
| 10 |
+
//! - Business (30%): 09:00-17:00, demand 3-6
|
| 11 |
+
//! - Restaurant (20%): 06:00-10:00, demand 5-10
|
| 12 |
+
|
| 13 |
+
use rand::rngs::StdRng;
|
| 14 |
+
use rand::{Rng, SeedableRng};
|
| 15 |
+
|
| 16 |
+
use crate::domain::{Location, Vehicle, VehicleRoutePlan, Visit};
|
| 17 |
+
|
| 18 |
+
/// Vehicle names using phonetic alphabet.
|
| 19 |
+
const VEHICLE_NAMES: [&str; 10] = [
|
| 20 |
+
"Alpha", "Bravo", "Charlie", "Delta", "Echo",
|
| 21 |
+
"Foxtrot", "Golf", "Hotel", "India", "Juliet",
|
| 22 |
+
];
|
| 23 |
+
|
| 24 |
+
/// Customer type with time window and demand characteristics.
|
| 25 |
+
#[derive(Clone, Copy)]
|
| 26 |
+
enum CustomerType {
|
| 27 |
+
/// Evening deliveries (17:00-20:00), small orders
|
| 28 |
+
Residential,
|
| 29 |
+
/// Business hours (09:00-17:00), medium orders
|
| 30 |
+
Business,
|
| 31 |
+
/// Early morning (06:00-10:00), large orders
|
| 32 |
+
Restaurant,
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
impl CustomerType {
|
| 36 |
+
fn time_window(&self) -> (i64, i64) {
|
| 37 |
+
match self {
|
| 38 |
+
CustomerType::Residential => (17 * 3600, 20 * 3600),
|
| 39 |
+
CustomerType::Business => (9 * 3600, 17 * 3600),
|
| 40 |
+
CustomerType::Restaurant => (6 * 3600, 10 * 3600),
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
fn demand_range(&self) -> (i32, i32) {
|
| 45 |
+
match self {
|
| 46 |
+
CustomerType::Residential => (1, 2),
|
| 47 |
+
CustomerType::Business => (3, 6),
|
| 48 |
+
CustomerType::Restaurant => (5, 10),
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
fn service_duration_range(&self) -> (i64, i64) {
|
| 53 |
+
match self {
|
| 54 |
+
CustomerType::Residential => (5 * 60, 10 * 60),
|
| 55 |
+
CustomerType::Business => (15 * 60, 30 * 60),
|
| 56 |
+
CustomerType::Restaurant => (20 * 60, 40 * 60),
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/// Weighted random selection: 50% residential, 30% business, 20% restaurant.
|
| 61 |
+
fn random(rng: &mut StdRng) -> Self {
|
| 62 |
+
let r: u32 = rng.gen_range(1..=100);
|
| 63 |
+
if r <= 50 {
|
| 64 |
+
CustomerType::Residential
|
| 65 |
+
} else if r <= 80 {
|
| 66 |
+
CustomerType::Business
|
| 67 |
+
} else {
|
| 68 |
+
CustomerType::Restaurant
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/// Location data with name, coordinates, and optional type.
|
| 74 |
+
struct LocationData {
|
| 75 |
+
name: &'static str,
|
| 76 |
+
lat: f64,
|
| 77 |
+
lng: f64,
|
| 78 |
+
customer_type: Option<CustomerType>,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/// Demo dataset configuration.
|
| 82 |
+
struct DemoConfig {
|
| 83 |
+
seed: u64,
|
| 84 |
+
visit_count: usize,
|
| 85 |
+
vehicle_count: usize,
|
| 86 |
+
vehicle_start_time: i64,
|
| 87 |
+
min_capacity: i32,
|
| 88 |
+
max_capacity: i32,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
// ============================================================================
|
| 92 |
+
// Philadelphia Data
|
| 93 |
+
// ============================================================================
|
| 94 |
+
|
| 95 |
+
const PHILADELPHIA_DEPOTS: &[LocationData] = &[
|
| 96 |
+
LocationData { name: "Central Depot - City Hall", lat: 39.9526, lng: -75.1652, customer_type: None },
|
| 97 |
+
LocationData { name: "South Philly Depot", lat: 39.9256, lng: -75.1697, customer_type: None },
|
| 98 |
+
LocationData { name: "University City Depot", lat: 39.9522, lng: -75.1932, customer_type: None },
|
| 99 |
+
LocationData { name: "North Philly Depot", lat: 39.9907, lng: -75.1556, customer_type: None },
|
| 100 |
+
LocationData { name: "Fishtown Depot", lat: 39.9712, lng: -75.1340, customer_type: None },
|
| 101 |
+
LocationData { name: "West Philly Depot", lat: 39.9601, lng: -75.2175, customer_type: None },
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
+
const PHILADELPHIA_VISITS: &[LocationData] = &[
|
| 105 |
+
// Restaurants
|
| 106 |
+
LocationData { name: "Reading Terminal Market", lat: 39.9535, lng: -75.1589, customer_type: Some(CustomerType::Restaurant) },
|
| 107 |
+
LocationData { name: "Parc Restaurant", lat: 39.9493, lng: -75.1727, customer_type: Some(CustomerType::Restaurant) },
|
| 108 |
+
LocationData { name: "Zahav", lat: 39.9430, lng: -75.1474, customer_type: Some(CustomerType::Restaurant) },
|
| 109 |
+
LocationData { name: "Vetri Cucina", lat: 39.9499, lng: -75.1659, customer_type: Some(CustomerType::Restaurant) },
|
| 110 |
+
LocationData { name: "Talula's Garden", lat: 39.9470, lng: -75.1709, customer_type: Some(CustomerType::Restaurant) },
|
| 111 |
+
LocationData { name: "Fork", lat: 39.9493, lng: -75.1539, customer_type: Some(CustomerType::Restaurant) },
|
| 112 |
+
LocationData { name: "Morimoto", lat: 39.9488, lng: -75.1559, customer_type: Some(CustomerType::Restaurant) },
|
| 113 |
+
LocationData { name: "Vernick Food & Drink", lat: 39.9508, lng: -75.1718, customer_type: Some(CustomerType::Restaurant) },
|
| 114 |
+
LocationData { name: "Friday Saturday Sunday", lat: 39.9492, lng: -75.1715, customer_type: Some(CustomerType::Restaurant) },
|
| 115 |
+
LocationData { name: "Royal Izakaya", lat: 39.9410, lng: -75.1509, customer_type: Some(CustomerType::Restaurant) },
|
| 116 |
+
LocationData { name: "Laurel", lat: 39.9392, lng: -75.1538, customer_type: Some(CustomerType::Restaurant) },
|
| 117 |
+
LocationData { name: "Marigold Kitchen", lat: 39.9533, lng: -75.1920, customer_type: Some(CustomerType::Restaurant) },
|
| 118 |
+
// Businesses
|
| 119 |
+
LocationData { name: "Comcast Center", lat: 39.9543, lng: -75.1690, customer_type: Some(CustomerType::Business) },
|
| 120 |
+
LocationData { name: "Liberty Place", lat: 39.9520, lng: -75.1685, customer_type: Some(CustomerType::Business) },
|
| 121 |
+
LocationData { name: "BNY Mellon Center", lat: 39.9505, lng: -75.1660, customer_type: Some(CustomerType::Business) },
|
| 122 |
+
LocationData { name: "One Liberty Place", lat: 39.9520, lng: -75.1685, customer_type: Some(CustomerType::Business) },
|
| 123 |
+
LocationData { name: "Aramark Tower", lat: 39.9550, lng: -75.1705, customer_type: Some(CustomerType::Business) },
|
| 124 |
+
LocationData { name: "PSFS Building", lat: 39.9510, lng: -75.1618, customer_type: Some(CustomerType::Business) },
|
| 125 |
+
LocationData { name: "Three Logan Square", lat: 39.9567, lng: -75.1720, customer_type: Some(CustomerType::Business) },
|
| 126 |
+
LocationData { name: "Two Commerce Square", lat: 39.9551, lng: -75.1675, customer_type: Some(CustomerType::Business) },
|
| 127 |
+
LocationData { name: "Penn Medicine", lat: 39.9495, lng: -75.1935, customer_type: Some(CustomerType::Business) },
|
| 128 |
+
LocationData { name: "Children's Hospital", lat: 39.9482, lng: -75.1950, customer_type: Some(CustomerType::Business) },
|
| 129 |
+
LocationData { name: "Drexel University", lat: 39.9566, lng: -75.1899, customer_type: Some(CustomerType::Business) },
|
| 130 |
+
LocationData { name: "Temple University", lat: 39.9812, lng: -75.1554, customer_type: Some(CustomerType::Business) },
|
| 131 |
+
LocationData { name: "Jefferson Hospital", lat: 39.9487, lng: -75.1577, customer_type: Some(CustomerType::Business) },
|
| 132 |
+
LocationData { name: "Pennsylvania Hospital", lat: 39.9445, lng: -75.1545, customer_type: Some(CustomerType::Business) },
|
| 133 |
+
LocationData { name: "FMC Tower", lat: 39.9499, lng: -75.1780, customer_type: Some(CustomerType::Business) },
|
| 134 |
+
LocationData { name: "Cira Centre", lat: 39.9560, lng: -75.1822, customer_type: Some(CustomerType::Business) },
|
| 135 |
+
// Residential
|
| 136 |
+
LocationData { name: "Rittenhouse Square", lat: 39.9496, lng: -75.1718, customer_type: Some(CustomerType::Residential) },
|
| 137 |
+
LocationData { name: "Washington Square West", lat: 39.9468, lng: -75.1545, customer_type: Some(CustomerType::Residential) },
|
| 138 |
+
LocationData { name: "Society Hill", lat: 39.9425, lng: -75.1478, customer_type: Some(CustomerType::Residential) },
|
| 139 |
+
LocationData { name: "Old City", lat: 39.9510, lng: -75.1450, customer_type: Some(CustomerType::Residential) },
|
| 140 |
+
LocationData { name: "Northern Liberties", lat: 39.9650, lng: -75.1420, customer_type: Some(CustomerType::Residential) },
|
| 141 |
+
LocationData { name: "Fishtown", lat: 39.9712, lng: -75.1340, customer_type: Some(CustomerType::Residential) },
|
| 142 |
+
LocationData { name: "Queen Village", lat: 39.9380, lng: -75.1520, customer_type: Some(CustomerType::Residential) },
|
| 143 |
+
LocationData { name: "Bella Vista", lat: 39.9395, lng: -75.1598, customer_type: Some(CustomerType::Residential) },
|
| 144 |
+
LocationData { name: "Graduate Hospital", lat: 39.9425, lng: -75.1768, customer_type: Some(CustomerType::Residential) },
|
| 145 |
+
LocationData { name: "Fairmount", lat: 39.9680, lng: -75.1750, customer_type: Some(CustomerType::Residential) },
|
| 146 |
+
LocationData { name: "Spring Garden", lat: 39.9620, lng: -75.1620, customer_type: Some(CustomerType::Residential) },
|
| 147 |
+
LocationData { name: "Art Museum Area", lat: 39.9656, lng: -75.1810, customer_type: Some(CustomerType::Residential) },
|
| 148 |
+
LocationData { name: "Brewerytown", lat: 39.9750, lng: -75.1850, customer_type: Some(CustomerType::Residential) },
|
| 149 |
+
LocationData { name: "East Passyunk", lat: 39.9310, lng: -75.1605, customer_type: Some(CustomerType::Residential) },
|
| 150 |
+
LocationData { name: "Point Breeze", lat: 39.9285, lng: -75.1780, customer_type: Some(CustomerType::Residential) },
|
| 151 |
+
LocationData { name: "Pennsport", lat: 39.9320, lng: -75.1450, customer_type: Some(CustomerType::Residential) },
|
| 152 |
+
LocationData { name: "Powelton Village", lat: 39.9610, lng: -75.1950, customer_type: Some(CustomerType::Residential) },
|
| 153 |
+
LocationData { name: "Spruce Hill", lat: 39.9530, lng: -75.2100, customer_type: Some(CustomerType::Residential) },
|
| 154 |
+
LocationData { name: "Cedar Park", lat: 39.9490, lng: -75.2200, customer_type: Some(CustomerType::Residential) },
|
| 155 |
+
LocationData { name: "Kensington", lat: 39.9850, lng: -75.1280, customer_type: Some(CustomerType::Residential) },
|
| 156 |
+
LocationData { name: "Port Richmond", lat: 39.9870, lng: -75.1120, customer_type: Some(CustomerType::Residential) },
|
| 157 |
+
];
|
| 158 |
+
|
| 159 |
+
// ============================================================================
|
| 160 |
+
// Hartford Data
|
| 161 |
+
// ============================================================================
|
| 162 |
+
|
| 163 |
+
const HARTFORD_DEPOTS: &[LocationData] = &[
|
| 164 |
+
LocationData { name: "Downtown Hartford Depot", lat: 41.7658, lng: -72.6734, customer_type: None },
|
| 165 |
+
LocationData { name: "Asylum Hill Depot", lat: 41.7700, lng: -72.6900, customer_type: None },
|
| 166 |
+
LocationData { name: "South End Depot", lat: 41.7400, lng: -72.6750, customer_type: None },
|
| 167 |
+
LocationData { name: "West End Depot", lat: 41.7680, lng: -72.7100, customer_type: None },
|
| 168 |
+
LocationData { name: "Barry Square Depot", lat: 41.7450, lng: -72.6800, customer_type: None },
|
| 169 |
+
LocationData { name: "Clay Arsenal Depot", lat: 41.7750, lng: -72.6850, customer_type: None },
|
| 170 |
+
];
|
| 171 |
+
|
| 172 |
+
const HARTFORD_VISITS: &[LocationData] = &[
|
| 173 |
+
// Restaurants
|
| 174 |
+
LocationData { name: "Max Downtown", lat: 41.7670, lng: -72.6730, customer_type: Some(CustomerType::Restaurant) },
|
| 175 |
+
LocationData { name: "Trumbull Kitchen", lat: 41.7650, lng: -72.6750, customer_type: Some(CustomerType::Restaurant) },
|
| 176 |
+
LocationData { name: "Salute", lat: 41.7630, lng: -72.6740, customer_type: Some(CustomerType::Restaurant) },
|
| 177 |
+
LocationData { name: "Peppercorns Grill", lat: 41.7690, lng: -72.6680, customer_type: Some(CustomerType::Restaurant) },
|
| 178 |
+
LocationData { name: "Feng Asian Bistro", lat: 41.7640, lng: -72.6725, customer_type: Some(CustomerType::Restaurant) },
|
| 179 |
+
LocationData { name: "On20", lat: 41.7655, lng: -72.6728, customer_type: Some(CustomerType::Restaurant) },
|
| 180 |
+
LocationData { name: "First and Last Tavern", lat: 41.7620, lng: -72.7050, customer_type: Some(CustomerType::Restaurant) },
|
| 181 |
+
LocationData { name: "Agave Grill", lat: 41.7580, lng: -72.6820, customer_type: Some(CustomerType::Restaurant) },
|
| 182 |
+
LocationData { name: "Bear's Smokehouse", lat: 41.7550, lng: -72.6780, customer_type: Some(CustomerType::Restaurant) },
|
| 183 |
+
LocationData { name: "City Steam Brewery", lat: 41.7630, lng: -72.6750, customer_type: Some(CustomerType::Restaurant) },
|
| 184 |
+
// Businesses
|
| 185 |
+
LocationData { name: "Travelers Tower", lat: 41.7658, lng: -72.6734, customer_type: Some(CustomerType::Business) },
|
| 186 |
+
LocationData { name: "Hartford Steam Boiler", lat: 41.7680, lng: -72.6700, customer_type: Some(CustomerType::Business) },
|
| 187 |
+
LocationData { name: "Aetna Building", lat: 41.7700, lng: -72.6900, customer_type: Some(CustomerType::Business) },
|
| 188 |
+
LocationData { name: "Connecticut Convention Center", lat: 41.7615, lng: -72.6820, customer_type: Some(CustomerType::Business) },
|
| 189 |
+
LocationData { name: "Hartford Hospital", lat: 41.7547, lng: -72.6858, customer_type: Some(CustomerType::Business) },
|
| 190 |
+
LocationData { name: "Connecticut Children's", lat: 41.7560, lng: -72.6850, customer_type: Some(CustomerType::Business) },
|
| 191 |
+
LocationData { name: "Trinity College", lat: 41.7474, lng: -72.6909, customer_type: Some(CustomerType::Business) },
|
| 192 |
+
LocationData { name: "Connecticut Science Center", lat: 41.7650, lng: -72.6695, customer_type: Some(CustomerType::Business) },
|
| 193 |
+
// Residential
|
| 194 |
+
LocationData { name: "West End Hartford", lat: 41.7680, lng: -72.7000, customer_type: Some(CustomerType::Residential) },
|
| 195 |
+
LocationData { name: "Asylum Hill", lat: 41.7720, lng: -72.6850, customer_type: Some(CustomerType::Residential) },
|
| 196 |
+
LocationData { name: "Frog Hollow", lat: 41.7580, lng: -72.6900, customer_type: Some(CustomerType::Residential) },
|
| 197 |
+
LocationData { name: "Barry Square", lat: 41.7450, lng: -72.6800, customer_type: Some(CustomerType::Residential) },
|
| 198 |
+
LocationData { name: "South End", lat: 41.7400, lng: -72.6750, customer_type: Some(CustomerType::Residential) },
|
| 199 |
+
LocationData { name: "Blue Hills", lat: 41.7850, lng: -72.7050, customer_type: Some(CustomerType::Residential) },
|
| 200 |
+
LocationData { name: "Parkville", lat: 41.7650, lng: -72.7100, customer_type: Some(CustomerType::Residential) },
|
| 201 |
+
LocationData { name: "Behind the Rocks", lat: 41.7550, lng: -72.7050, customer_type: Some(CustomerType::Residential) },
|
| 202 |
+
LocationData { name: "Charter Oak", lat: 41.7495, lng: -72.6650, customer_type: Some(CustomerType::Residential) },
|
| 203 |
+
LocationData { name: "Sheldon Charter Oak", lat: 41.7510, lng: -72.6700, customer_type: Some(CustomerType::Residential) },
|
| 204 |
+
LocationData { name: "Clay Arsenal", lat: 41.7750, lng: -72.6850, customer_type: Some(CustomerType::Residential) },
|
| 205 |
+
LocationData { name: "Upper Albany", lat: 41.7780, lng: -72.6950, customer_type: Some(CustomerType::Residential) },
|
| 206 |
+
];
|
| 207 |
+
|
| 208 |
+
// ============================================================================
|
| 209 |
+
// Firenze Data
|
| 210 |
+
// ============================================================================
|
| 211 |
+
|
| 212 |
+
const FIRENZE_DEPOTS: &[LocationData] = &[
|
| 213 |
+
LocationData { name: "Centro Storico Depot", lat: 43.7696, lng: 11.2558, customer_type: None },
|
| 214 |
+
LocationData { name: "Santa Maria Novella Depot", lat: 43.7745, lng: 11.2487, customer_type: None },
|
| 215 |
+
LocationData { name: "Campo di Marte Depot", lat: 43.7820, lng: 11.2820, customer_type: None },
|
| 216 |
+
LocationData { name: "Rifredi Depot", lat: 43.7950, lng: 11.2410, customer_type: None },
|
| 217 |
+
LocationData { name: "Novoli Depot", lat: 43.7880, lng: 11.2220, customer_type: None },
|
| 218 |
+
LocationData { name: "Gavinana Depot", lat: 43.7520, lng: 11.2680, customer_type: None },
|
| 219 |
+
];
|
| 220 |
+
|
| 221 |
+
const FIRENZE_VISITS: &[LocationData] = &[
|
| 222 |
+
// Restaurants
|
| 223 |
+
LocationData { name: "Trattoria Mario", lat: 43.7750, lng: 11.2530, customer_type: Some(CustomerType::Restaurant) },
|
| 224 |
+
LocationData { name: "Buca Mario", lat: 43.7698, lng: 11.2505, customer_type: Some(CustomerType::Restaurant) },
|
| 225 |
+
LocationData { name: "Il Latini", lat: 43.7705, lng: 11.2495, customer_type: Some(CustomerType::Restaurant) },
|
| 226 |
+
LocationData { name: "Osteria dell'Enoteca", lat: 43.7680, lng: 11.2545, customer_type: Some(CustomerType::Restaurant) },
|
| 227 |
+
LocationData { name: "Trattoria Sostanza", lat: 43.7735, lng: 11.2470, customer_type: Some(CustomerType::Restaurant) },
|
| 228 |
+
LocationData { name: "All'Antico Vinaio", lat: 43.7690, lng: 11.2570, customer_type: Some(CustomerType::Restaurant) },
|
| 229 |
+
LocationData { name: "Mercato Centrale", lat: 43.7762, lng: 11.2540, customer_type: Some(CustomerType::Restaurant) },
|
| 230 |
+
LocationData { name: "Cibreo", lat: 43.7702, lng: 11.2670, customer_type: Some(CustomerType::Restaurant) },
|
| 231 |
+
LocationData { name: "Ora d'Aria", lat: 43.7710, lng: 11.2610, customer_type: Some(CustomerType::Restaurant) },
|
| 232 |
+
LocationData { name: "Buca Lapi", lat: 43.7720, lng: 11.2535, customer_type: Some(CustomerType::Restaurant) },
|
| 233 |
+
LocationData { name: "Il Palagio", lat: 43.7680, lng: 11.2550, customer_type: Some(CustomerType::Restaurant) },
|
| 234 |
+
LocationData { name: "Enoteca Pinchiorri", lat: 43.7695, lng: 11.2620, customer_type: Some(CustomerType::Restaurant) },
|
| 235 |
+
LocationData { name: "La Giostra", lat: 43.7745, lng: 11.2650, customer_type: Some(CustomerType::Restaurant) },
|
| 236 |
+
LocationData { name: "Fishing Lab", lat: 43.7730, lng: 11.2560, customer_type: Some(CustomerType::Restaurant) },
|
| 237 |
+
LocationData { name: "Trattoria Cammillo", lat: 43.7665, lng: 11.2520, customer_type: Some(CustomerType::Restaurant) },
|
| 238 |
+
// Businesses
|
| 239 |
+
LocationData { name: "Palazzo Vecchio", lat: 43.7693, lng: 11.2563, customer_type: Some(CustomerType::Business) },
|
| 240 |
+
LocationData { name: "Uffizi Gallery", lat: 43.7677, lng: 11.2553, customer_type: Some(CustomerType::Business) },
|
| 241 |
+
LocationData { name: "Gucci Garden", lat: 43.7692, lng: 11.2556, customer_type: Some(CustomerType::Business) },
|
| 242 |
+
LocationData { name: "Ferragamo Museum", lat: 43.7700, lng: 11.2530, customer_type: Some(CustomerType::Business) },
|
| 243 |
+
LocationData { name: "Ospedale Santa Maria", lat: 43.7830, lng: 11.2690, customer_type: Some(CustomerType::Business) },
|
| 244 |
+
LocationData { name: "Universita degli Studi", lat: 43.7765, lng: 11.2555, customer_type: Some(CustomerType::Business) },
|
| 245 |
+
LocationData { name: "Palazzo Strozzi", lat: 43.7706, lng: 11.2515, customer_type: Some(CustomerType::Business) },
|
| 246 |
+
LocationData { name: "Biblioteca Nazionale", lat: 43.7660, lng: 11.2650, customer_type: Some(CustomerType::Business) },
|
| 247 |
+
LocationData { name: "Teatro del Maggio", lat: 43.7780, lng: 11.2470, customer_type: Some(CustomerType::Business) },
|
| 248 |
+
LocationData { name: "Palazzo Pitti", lat: 43.7650, lng: 11.2500, customer_type: Some(CustomerType::Business) },
|
| 249 |
+
LocationData { name: "Accademia Gallery", lat: 43.7768, lng: 11.2590, customer_type: Some(CustomerType::Business) },
|
| 250 |
+
LocationData { name: "Ospedale Meyer", lat: 43.7910, lng: 11.2520, customer_type: Some(CustomerType::Business) },
|
| 251 |
+
LocationData { name: "Polo Universitario", lat: 43.7920, lng: 11.2180, customer_type: Some(CustomerType::Business) },
|
| 252 |
+
// Residential
|
| 253 |
+
LocationData { name: "Santo Spirito", lat: 43.7665, lng: 11.2470, customer_type: Some(CustomerType::Residential) },
|
| 254 |
+
LocationData { name: "San Frediano", lat: 43.7680, lng: 11.2420, customer_type: Some(CustomerType::Residential) },
|
| 255 |
+
LocationData { name: "Santa Croce", lat: 43.7688, lng: 11.2620, customer_type: Some(CustomerType::Residential) },
|
| 256 |
+
LocationData { name: "San Lorenzo", lat: 43.7755, lng: 11.2540, customer_type: Some(CustomerType::Residential) },
|
| 257 |
+
LocationData { name: "San Marco", lat: 43.7780, lng: 11.2585, customer_type: Some(CustomerType::Residential) },
|
| 258 |
+
LocationData { name: "Sant'Ambrogio", lat: 43.7705, lng: 11.2680, customer_type: Some(CustomerType::Residential) },
|
| 259 |
+
LocationData { name: "Campo di Marte", lat: 43.7820, lng: 11.2820, customer_type: Some(CustomerType::Residential) },
|
| 260 |
+
LocationData { name: "Novoli", lat: 43.7880, lng: 11.2220, customer_type: Some(CustomerType::Residential) },
|
| 261 |
+
LocationData { name: "Rifredi", lat: 43.7950, lng: 11.2410, customer_type: Some(CustomerType::Residential) },
|
| 262 |
+
LocationData { name: "Le Cure", lat: 43.7890, lng: 11.2580, customer_type: Some(CustomerType::Residential) },
|
| 263 |
+
LocationData { name: "Careggi", lat: 43.8020, lng: 11.2530, customer_type: Some(CustomerType::Residential) },
|
| 264 |
+
LocationData { name: "Peretola", lat: 43.7960, lng: 11.2050, customer_type: Some(CustomerType::Residential) },
|
| 265 |
+
LocationData { name: "Isolotto", lat: 43.7620, lng: 11.2200, customer_type: Some(CustomerType::Residential) },
|
| 266 |
+
LocationData { name: "Gavinana", lat: 43.7520, lng: 11.2680, customer_type: Some(CustomerType::Residential) },
|
| 267 |
+
LocationData { name: "Galluzzo", lat: 43.7400, lng: 11.2480, customer_type: Some(CustomerType::Residential) },
|
| 268 |
+
LocationData { name: "Porta Romana", lat: 43.7610, lng: 11.2560, customer_type: Some(CustomerType::Residential) },
|
| 269 |
+
LocationData { name: "Bellosguardo", lat: 43.7650, lng: 11.2350, customer_type: Some(CustomerType::Residential) },
|
| 270 |
+
LocationData { name: "Arcetri", lat: 43.7500, lng: 11.2530, customer_type: Some(CustomerType::Residential) },
|
| 271 |
+
LocationData { name: "Fiesole", lat: 43.8055, lng: 11.2935, customer_type: Some(CustomerType::Residential) },
|
| 272 |
+
LocationData { name: "Settignano", lat: 43.7850, lng: 11.3100, customer_type: Some(CustomerType::Residential) },
|
| 273 |
+
];
|
| 274 |
+
|
| 275 |
+
// ============================================================================
|
| 276 |
+
// Generator Functions
|
| 277 |
+
// ============================================================================
|
| 278 |
+
|
| 279 |
+
fn generate_demo_data(
|
| 280 |
+
name: &str,
|
| 281 |
+
config: &DemoConfig,
|
| 282 |
+
depots: &[LocationData],
|
| 283 |
+
visit_data: &[LocationData],
|
| 284 |
+
) -> VehicleRoutePlan {
|
| 285 |
+
let mut rng = StdRng::seed_from_u64(config.seed);
|
| 286 |
+
|
| 287 |
+
// Build locations: depots first, then visit locations
|
| 288 |
+
let mut locations = Vec::new();
|
| 289 |
+
let mut location_idx = 0;
|
| 290 |
+
|
| 291 |
+
// Add depot locations
|
| 292 |
+
for depot in depots.iter().take(config.vehicle_count) {
|
| 293 |
+
locations.push(Location::new(location_idx, depot.lat, depot.lng));
|
| 294 |
+
location_idx += 1;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
// Shuffle visit data for variety
|
| 298 |
+
let mut shuffled_visits: Vec<_> = visit_data.iter().collect();
|
| 299 |
+
for i in (1..shuffled_visits.len()).rev() {
|
| 300 |
+
let j = rng.gen_range(0..=i);
|
| 301 |
+
shuffled_visits.swap(i, j);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// Add visit locations
|
| 305 |
+
for visit in shuffled_visits.iter().take(config.visit_count) {
|
| 306 |
+
locations.push(Location::new(location_idx, visit.lat, visit.lng));
|
| 307 |
+
location_idx += 1;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Create vehicles - now needs Location object, not index
|
| 311 |
+
let depot_count = config.vehicle_count.min(depots.len());
|
| 312 |
+
let vehicles: Vec<_> = (0..config.vehicle_count)
|
| 313 |
+
.map(|i| {
|
| 314 |
+
let capacity = rng.gen_range(config.min_capacity..=config.max_capacity);
|
| 315 |
+
let home_loc = locations[i].clone(); // Depot locations are first
|
| 316 |
+
Vehicle::new(
|
| 317 |
+
i,
|
| 318 |
+
VEHICLE_NAMES[i % VEHICLE_NAMES.len()],
|
| 319 |
+
capacity,
|
| 320 |
+
home_loc,
|
| 321 |
+
)
|
| 322 |
+
.with_departure_time(config.vehicle_start_time)
|
| 323 |
+
})
|
| 324 |
+
.collect();
|
| 325 |
+
|
| 326 |
+
// Create visits - now needs Location object, not index
|
| 327 |
+
let visits: Vec<_> = shuffled_visits
|
| 328 |
+
.iter()
|
| 329 |
+
.take(config.visit_count)
|
| 330 |
+
.enumerate()
|
| 331 |
+
.map(|(i, loc_data)| {
|
| 332 |
+
let ctype = loc_data.customer_type.unwrap_or_else(|| CustomerType::random(&mut rng));
|
| 333 |
+
let (min_time, max_time) = ctype.time_window();
|
| 334 |
+
let (min_demand, max_demand) = ctype.demand_range();
|
| 335 |
+
let (min_service, max_service) = ctype.service_duration_range();
|
| 336 |
+
|
| 337 |
+
let demand = rng.gen_range(min_demand..=max_demand);
|
| 338 |
+
let service_duration = rng.gen_range(min_service..=max_service);
|
| 339 |
+
|
| 340 |
+
let visit_loc = locations[depot_count + i].clone(); // Visit locations are after depots
|
| 341 |
+
Visit::new(i, loc_data.name, visit_loc)
|
| 342 |
+
.with_demand(demand)
|
| 343 |
+
.with_time_window(min_time, max_time)
|
| 344 |
+
.with_service_duration(service_duration)
|
| 345 |
+
})
|
| 346 |
+
.collect();
|
| 347 |
+
|
| 348 |
+
let mut plan = VehicleRoutePlan::new(name, locations, visits, vehicles);
|
| 349 |
+
plan.finalize();
|
| 350 |
+
plan
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
/// Generates Philadelphia demo data (49 visits, 10 vehicles).
|
| 354 |
+
///
|
| 355 |
+
/// # Examples
|
| 356 |
+
///
|
| 357 |
+
/// ```
|
| 358 |
+
/// use vehicle_routing::demo_data::generate_philadelphia;
|
| 359 |
+
///
|
| 360 |
+
/// let plan = generate_philadelphia();
|
| 361 |
+
/// assert_eq!(plan.name, "Philadelphia");
|
| 362 |
+
/// assert_eq!(plan.visits.len(), 49);
|
| 363 |
+
/// assert_eq!(plan.vehicles.len(), 10);
|
| 364 |
+
/// ```
|
| 365 |
+
pub fn generate_philadelphia() -> VehicleRoutePlan {
|
| 366 |
+
let config = DemoConfig {
|
| 367 |
+
seed: 0,
|
| 368 |
+
visit_count: PHILADELPHIA_VISITS.len(),
|
| 369 |
+
vehicle_count: 10,
|
| 370 |
+
vehicle_start_time: 6 * 3600, // 6am
|
| 371 |
+
min_capacity: 15,
|
| 372 |
+
max_capacity: 30,
|
| 373 |
+
};
|
| 374 |
+
generate_demo_data("Philadelphia", &config, PHILADELPHIA_DEPOTS, PHILADELPHIA_VISITS)
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
/// Generates Hartford demo data (30 visits, 10 vehicles).
|
| 378 |
+
///
|
| 379 |
+
/// # Examples
|
| 380 |
+
///
|
| 381 |
+
/// ```
|
| 382 |
+
/// use vehicle_routing::demo_data::generate_hartford;
|
| 383 |
+
///
|
| 384 |
+
/// let plan = generate_hartford();
|
| 385 |
+
/// assert_eq!(plan.name, "Hartford");
|
| 386 |
+
/// assert_eq!(plan.visits.len(), 30);
|
| 387 |
+
/// assert_eq!(plan.vehicles.len(), 10);
|
| 388 |
+
/// ```
|
| 389 |
+
pub fn generate_hartford() -> VehicleRoutePlan {
|
| 390 |
+
let config = DemoConfig {
|
| 391 |
+
seed: 1,
|
| 392 |
+
visit_count: HARTFORD_VISITS.len(),
|
| 393 |
+
vehicle_count: 10,
|
| 394 |
+
vehicle_start_time: 6 * 3600,
|
| 395 |
+
min_capacity: 20,
|
| 396 |
+
max_capacity: 30,
|
| 397 |
+
};
|
| 398 |
+
generate_demo_data("Hartford", &config, HARTFORD_DEPOTS, HARTFORD_VISITS)
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
/// Generates Firenze demo data (48 visits, 10 vehicles).
|
| 402 |
+
///
|
| 403 |
+
/// # Examples
|
| 404 |
+
///
|
| 405 |
+
/// ```
|
| 406 |
+
/// use vehicle_routing::demo_data::generate_firenze;
|
| 407 |
+
///
|
| 408 |
+
/// let plan = generate_firenze();
|
| 409 |
+
/// assert_eq!(plan.name, "Firenze");
|
| 410 |
+
/// assert_eq!(plan.visits.len(), 48);
|
| 411 |
+
/// assert_eq!(plan.vehicles.len(), 10);
|
| 412 |
+
/// ```
|
| 413 |
+
pub fn generate_firenze() -> VehicleRoutePlan {
|
| 414 |
+
let config = DemoConfig {
|
| 415 |
+
seed: 2,
|
| 416 |
+
visit_count: FIRENZE_VISITS.len(),
|
| 417 |
+
vehicle_count: 10,
|
| 418 |
+
vehicle_start_time: 6 * 3600,
|
| 419 |
+
min_capacity: 20,
|
| 420 |
+
max_capacity: 40,
|
| 421 |
+
};
|
| 422 |
+
generate_demo_data("Firenze", &config, FIRENZE_DEPOTS, FIRENZE_VISITS)
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
/// Returns all available demo dataset names.
|
| 426 |
+
pub fn available_datasets() -> &'static [&'static str] {
|
| 427 |
+
&["PHILADELPHIA", "HARTFORD", "FIRENZE"]
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/// Generates demo data by name.
|
| 431 |
+
///
|
| 432 |
+
/// # Examples
|
| 433 |
+
///
|
| 434 |
+
/// ```
|
| 435 |
+
/// use vehicle_routing::demo_data::generate_by_name;
|
| 436 |
+
///
|
| 437 |
+
/// let plan = generate_by_name("PHILADELPHIA").unwrap();
|
| 438 |
+
/// assert_eq!(plan.name, "Philadelphia");
|
| 439 |
+
///
|
| 440 |
+
/// assert!(generate_by_name("UNKNOWN").is_none());
|
| 441 |
+
/// ```
|
| 442 |
+
pub fn generate_by_name(name: &str) -> Option<VehicleRoutePlan> {
|
| 443 |
+
match name.to_uppercase().as_str() {
|
| 444 |
+
"PHILADELPHIA" => Some(generate_philadelphia()),
|
| 445 |
+
"HARTFORD" => Some(generate_hartford()),
|
| 446 |
+
"FIRENZE" => Some(generate_firenze()),
|
| 447 |
+
_ => None,
|
| 448 |
+
}
|
| 449 |
+
}
|
src/domain.rs
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Domain model for Vehicle Routing Problem.
|
| 2 |
+
//!
|
| 3 |
+
//! # Overview
|
| 4 |
+
//!
|
| 5 |
+
//! Models a vehicle routing problem with:
|
| 6 |
+
//! - Geographic [`Location`]s with haversine distance calculation
|
| 7 |
+
//! - Customer [`Visit`]s with time windows, demand, and service duration
|
| 8 |
+
//! - [`Vehicle`]s with capacity constraints and routes
|
| 9 |
+
//! - [`VehicleRoutePlan`] as the complete planning solution
|
| 10 |
+
//!
|
| 11 |
+
//! # Design
|
| 12 |
+
//!
|
| 13 |
+
//! All scoring uses direct access to the plan's travel time matrix.
|
| 14 |
+
//! No global state or RwLock overhead.
|
| 15 |
+
|
| 16 |
+
use serde::{Deserialize, Serialize};
|
| 17 |
+
use solverforge::prelude::*;
|
| 18 |
+
use std::collections::HashMap;
|
| 19 |
+
|
| 20 |
+
/// Average driving speed in km/h for travel time estimation.
|
| 21 |
+
pub const AVERAGE_SPEED_KMPH: f64 = 50.0;
|
| 22 |
+
|
| 23 |
+
/// Earth radius in meters for haversine calculation.
|
| 24 |
+
const EARTH_RADIUS_M: f64 = 6_371_000.0;
|
| 25 |
+
|
| 26 |
+
/// A geographic location with latitude and longitude.
|
| 27 |
+
///
|
| 28 |
+
/// Supports haversine distance calculation for travel time estimation.
|
| 29 |
+
///
|
| 30 |
+
/// # Examples
|
| 31 |
+
///
|
| 32 |
+
/// ```
|
| 33 |
+
/// use vehicle_routing::domain::Location;
|
| 34 |
+
///
|
| 35 |
+
/// let philadelphia = Location::new(0, 39.9526, -75.1652);
|
| 36 |
+
/// let new_york = Location::new(1, 40.7128, -74.0060);
|
| 37 |
+
///
|
| 38 |
+
/// // Distance is approximately 130 km
|
| 39 |
+
/// let distance = philadelphia.distance_meters(&new_york);
|
| 40 |
+
/// assert!(distance > 120_000.0 && distance < 140_000.0);
|
| 41 |
+
///
|
| 42 |
+
/// // Travel time at 50 km/h is approximately 2.6 hours
|
| 43 |
+
/// let travel_secs = philadelphia.travel_time_seconds(&new_york);
|
| 44 |
+
/// assert!(travel_secs > 8000 && travel_secs < 10000);
|
| 45 |
+
/// ```
|
| 46 |
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
| 47 |
+
pub struct Location {
|
| 48 |
+
/// Index in `VehicleRoutePlan.locations`.
|
| 49 |
+
pub index: usize,
|
| 50 |
+
/// Latitude in degrees (-90 to 90).
|
| 51 |
+
pub latitude: f64,
|
| 52 |
+
/// Longitude in degrees (-180 to 180).
|
| 53 |
+
pub longitude: f64,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
impl PartialEq for Location {
|
| 57 |
+
fn eq(&self, other: &Self) -> bool {
|
| 58 |
+
self.index == other.index
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
impl Eq for Location {}
|
| 63 |
+
|
| 64 |
+
impl std::hash::Hash for Location {
|
| 65 |
+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
| 66 |
+
self.index.hash(state);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
impl Location {
|
| 71 |
+
/// Creates a new location.
|
| 72 |
+
pub fn new(index: usize, latitude: f64, longitude: f64) -> Self {
|
| 73 |
+
Self {
|
| 74 |
+
index,
|
| 75 |
+
latitude,
|
| 76 |
+
longitude,
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/// Calculates the great-circle distance in meters using the haversine formula.
|
| 81 |
+
///
|
| 82 |
+
/// # Examples
|
| 83 |
+
///
|
| 84 |
+
/// ```
|
| 85 |
+
/// use vehicle_routing::domain::Location;
|
| 86 |
+
///
|
| 87 |
+
/// let a = Location::new(0, 0.0, 0.0);
|
| 88 |
+
/// let b = Location::new(1, 0.0, 1.0);
|
| 89 |
+
///
|
| 90 |
+
/// // 1 degree of longitude at equator is about 111 km
|
| 91 |
+
/// let dist = a.distance_meters(&b);
|
| 92 |
+
/// assert!(dist > 110_000.0 && dist < 112_000.0);
|
| 93 |
+
/// ```
|
| 94 |
+
pub fn distance_meters(&self, other: &Location) -> f64 {
|
| 95 |
+
if self.latitude == other.latitude && self.longitude == other.longitude {
|
| 96 |
+
return 0.0;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
let lat1 = self.latitude.to_radians();
|
| 100 |
+
let lat2 = other.latitude.to_radians();
|
| 101 |
+
let lon1 = self.longitude.to_radians();
|
| 102 |
+
let lon2 = other.longitude.to_radians();
|
| 103 |
+
|
| 104 |
+
// Haversine formula
|
| 105 |
+
let dlat = lat2 - lat1;
|
| 106 |
+
let dlon = lon2 - lon1;
|
| 107 |
+
let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
|
| 108 |
+
let c = 2.0 * a.sqrt().asin();
|
| 109 |
+
|
| 110 |
+
EARTH_RADIUS_M * c
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/// Calculates travel time in seconds assuming average driving speed.
|
| 114 |
+
///
|
| 115 |
+
/// Uses [`AVERAGE_SPEED_KMPH`] (50 km/h) for conversion.
|
| 116 |
+
pub fn travel_time_seconds(&self, other: &Location) -> i64 {
|
| 117 |
+
let meters = self.distance_meters(other);
|
| 118 |
+
// seconds = meters / (km/h * 1000 / 3600) = meters * 3.6 / km/h
|
| 119 |
+
(meters * 3.6 / AVERAGE_SPEED_KMPH).round() as i64
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/// A customer visit with time window and demand constraints.
|
| 125 |
+
///
|
| 126 |
+
/// # Time Window
|
| 127 |
+
///
|
| 128 |
+
/// - `min_start_time`: Earliest time service can begin (vehicle may wait)
|
| 129 |
+
/// - `max_end_time`: Latest time service must finish (hard constraint)
|
| 130 |
+
/// - `service_duration`: Time required to complete the visit
|
| 131 |
+
///
|
| 132 |
+
/// All times are in seconds from midnight.
|
| 133 |
+
///
|
| 134 |
+
/// # Examples
|
| 135 |
+
///
|
| 136 |
+
/// ```
|
| 137 |
+
/// use vehicle_routing::domain::{Visit, Location};
|
| 138 |
+
///
|
| 139 |
+
/// let location = Location::new(0, 39.95, -75.17);
|
| 140 |
+
///
|
| 141 |
+
/// // A restaurant delivery: 6am-10am window, 5-minute service
|
| 142 |
+
/// let visit = Visit::new(0, "Restaurant A", location)
|
| 143 |
+
/// .with_demand(8)
|
| 144 |
+
/// .with_time_window(6 * 3600, 10 * 3600)
|
| 145 |
+
/// .with_service_duration(300);
|
| 146 |
+
///
|
| 147 |
+
/// assert_eq!(visit.demand, 8);
|
| 148 |
+
/// assert_eq!(visit.min_start_time, 21600); // 6 * 3600
|
| 149 |
+
/// ```
|
| 150 |
+
#[problem_fact]
|
| 151 |
+
#[derive(Serialize, Deserialize)]
|
| 152 |
+
pub struct Visit {
|
| 153 |
+
/// Index in `VehicleRoutePlan.visits`.
|
| 154 |
+
pub index: usize,
|
| 155 |
+
/// Customer name.
|
| 156 |
+
pub name: String,
|
| 157 |
+
/// The geographic location of this visit.
|
| 158 |
+
pub location: Location,
|
| 159 |
+
/// Quantity demanded (must fit in vehicle capacity).
|
| 160 |
+
pub demand: i32,
|
| 161 |
+
/// Earliest service start time (seconds from midnight).
|
| 162 |
+
#[serde(rename = "minStartTime")]
|
| 163 |
+
pub min_start_time: i64,
|
| 164 |
+
/// Latest service end time (seconds from midnight).
|
| 165 |
+
#[serde(rename = "maxEndTime")]
|
| 166 |
+
pub max_end_time: i64,
|
| 167 |
+
/// Service duration in seconds.
|
| 168 |
+
#[serde(rename = "serviceDuration")]
|
| 169 |
+
pub service_duration: i64,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
impl Visit {
|
| 173 |
+
/// Creates a new visit with default time window (all day).
|
| 174 |
+
pub fn new(index: usize, name: impl Into<String>, location: Location) -> Self {
|
| 175 |
+
Self {
|
| 176 |
+
index,
|
| 177 |
+
name: name.into(),
|
| 178 |
+
location,
|
| 179 |
+
demand: 1,
|
| 180 |
+
min_start_time: 0,
|
| 181 |
+
max_end_time: 24 * 3600,
|
| 182 |
+
service_duration: 0,
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/// Sets the demand.
|
| 187 |
+
pub fn with_demand(mut self, demand: i32) -> Self {
|
| 188 |
+
self.demand = demand;
|
| 189 |
+
self
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/// Sets the time window (min_start_time, max_end_time) in seconds from midnight.
|
| 193 |
+
pub fn with_time_window(mut self, min_start: i64, max_end: i64) -> Self {
|
| 194 |
+
self.min_start_time = min_start;
|
| 195 |
+
self.max_end_time = max_end;
|
| 196 |
+
self
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/// Sets the service duration in seconds.
|
| 200 |
+
pub fn with_service_duration(mut self, duration: i64) -> Self {
|
| 201 |
+
self.service_duration = duration;
|
| 202 |
+
self
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/// A delivery vehicle with capacity and assigned route.
|
| 208 |
+
///
|
| 209 |
+
/// The route is stored as a list of visit indices in order.
|
| 210 |
+
///
|
| 211 |
+
/// # Examples
|
| 212 |
+
///
|
| 213 |
+
/// ```
|
| 214 |
+
/// use vehicle_routing::domain::{Vehicle, Location};
|
| 215 |
+
///
|
| 216 |
+
/// let depot = Location::new(0, 39.95, -75.17);
|
| 217 |
+
/// let vehicle = Vehicle::new(0, "Truck 1", 100, depot)
|
| 218 |
+
/// .with_departure_time(8 * 3600); // Departs at 8am
|
| 219 |
+
///
|
| 220 |
+
/// assert_eq!(vehicle.capacity, 100);
|
| 221 |
+
/// assert!(vehicle.visits.is_empty());
|
| 222 |
+
/// ```
|
| 223 |
+
#[planning_entity]
|
| 224 |
+
#[derive(Serialize, Deserialize)]
|
| 225 |
+
pub struct Vehicle {
|
| 226 |
+
/// Unique vehicle ID.
|
| 227 |
+
#[planning_id]
|
| 228 |
+
pub id: usize,
|
| 229 |
+
/// Vehicle name for display.
|
| 230 |
+
pub name: String,
|
| 231 |
+
/// Maximum capacity (sum of visit demands must not exceed).
|
| 232 |
+
pub capacity: i32,
|
| 233 |
+
/// Home depot location.
|
| 234 |
+
#[serde(rename = "homeLocation")]
|
| 235 |
+
pub home_location: Location,
|
| 236 |
+
/// Departure time from depot (seconds from midnight).
|
| 237 |
+
#[serde(rename = "departureTime")]
|
| 238 |
+
pub departure_time: i64,
|
| 239 |
+
/// Ordered list of visit indices (the route).
|
| 240 |
+
#[serde(default)]
|
| 241 |
+
pub visits: Vec<usize>,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
impl Vehicle {
|
| 245 |
+
/// Creates a new vehicle with empty route.
|
| 246 |
+
pub fn new(id: usize, name: impl Into<String>, capacity: i32, home_location: Location) -> Self {
|
| 247 |
+
Self {
|
| 248 |
+
id,
|
| 249 |
+
name: name.into(),
|
| 250 |
+
capacity,
|
| 251 |
+
home_location,
|
| 252 |
+
departure_time: 8 * 3600, // Default 8am
|
| 253 |
+
visits: Vec::new(),
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
/// Sets the departure time in seconds from midnight.
|
| 258 |
+
pub fn with_departure_time(mut self, time: i64) -> Self {
|
| 259 |
+
self.departure_time = time;
|
| 260 |
+
self
|
| 261 |
+
}
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
/// Arrival and departure times for a visit in a route.
|
| 265 |
+
#[derive(Debug, Clone, Copy)]
|
| 266 |
+
pub struct VisitTiming {
|
| 267 |
+
/// Visit index.
|
| 268 |
+
pub visit_idx: usize,
|
| 269 |
+
/// Arrival time at the visit (seconds from midnight).
|
| 270 |
+
pub arrival: i64,
|
| 271 |
+
/// Departure time from the visit (seconds from midnight).
|
| 272 |
+
pub departure: i64,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/// The complete vehicle routing solution.
|
| 276 |
+
///
|
| 277 |
+
/// Contains all problem facts (locations, visits) and planning entities (vehicles).
|
| 278 |
+
/// Call `finalize()` after construction to populate the travel time matrix.
|
| 279 |
+
///
|
| 280 |
+
/// # Examples
|
| 281 |
+
///
|
| 282 |
+
/// ```
|
| 283 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 284 |
+
///
|
| 285 |
+
/// let depot = Location::new(0, 39.95, -75.17); // Philadelphia
|
| 286 |
+
/// let customer_loc = Location::new(1, 40.00, -75.10);
|
| 287 |
+
///
|
| 288 |
+
/// let locations = vec![depot.clone(), customer_loc.clone()];
|
| 289 |
+
/// let visits = vec![
|
| 290 |
+
/// Visit::new(0, "Customer 1", customer_loc).with_demand(5),
|
| 291 |
+
/// ];
|
| 292 |
+
/// let vehicles = vec![
|
| 293 |
+
/// Vehicle::new(0, "Truck 1", 100, depot),
|
| 294 |
+
/// ];
|
| 295 |
+
///
|
| 296 |
+
/// let mut plan = VehicleRoutePlan::new("Philadelphia", locations, visits, vehicles);
|
| 297 |
+
/// plan.finalize();
|
| 298 |
+
///
|
| 299 |
+
/// // Travel time matrix is now populated
|
| 300 |
+
/// assert!(plan.travel_time(0, 1) > 0);
|
| 301 |
+
/// ```
|
| 302 |
+
#[planning_solution]
|
| 303 |
+
#[derive(Serialize, Deserialize)]
|
| 304 |
+
pub struct VehicleRoutePlan {
|
| 305 |
+
/// Problem name.
|
| 306 |
+
pub name: String,
|
| 307 |
+
/// South-west corner of bounding box (for map display).
|
| 308 |
+
#[serde(rename = "southWestCorner")]
|
| 309 |
+
pub south_west_corner: [f64; 2],
|
| 310 |
+
/// North-east corner of bounding box (for map display).
|
| 311 |
+
#[serde(rename = "northEastCorner")]
|
| 312 |
+
pub north_east_corner: [f64; 2],
|
| 313 |
+
/// All locations (depot and customer locations).
|
| 314 |
+
#[problem_fact_collection]
|
| 315 |
+
pub locations: Vec<Location>,
|
| 316 |
+
/// All customer visits.
|
| 317 |
+
#[problem_fact_collection]
|
| 318 |
+
pub visits: Vec<Visit>,
|
| 319 |
+
/// All vehicles.
|
| 320 |
+
#[planning_entity_collection]
|
| 321 |
+
pub vehicles: Vec<Vehicle>,
|
| 322 |
+
/// Current score.
|
| 323 |
+
#[planning_score]
|
| 324 |
+
pub score: Option<HardSoftScore>,
|
| 325 |
+
/// Solver status for REST API.
|
| 326 |
+
#[serde(rename = "solverStatus", skip_serializing_if = "Option::is_none")]
|
| 327 |
+
pub solver_status: Option<String>,
|
| 328 |
+
/// Precomputed travel times: `travel_time_matrix[from][to]` in seconds.
|
| 329 |
+
#[serde(skip)]
|
| 330 |
+
pub travel_time_matrix: Vec<Vec<i64>>,
|
| 331 |
+
/// Route geometries: `(from_loc, to_loc)` -> list of (lat, lng) waypoints.
|
| 332 |
+
#[serde(skip)]
|
| 333 |
+
pub route_geometries: HashMap<(usize, usize), Vec<(f64, f64)>>,
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
impl VehicleRoutePlan {
|
| 337 |
+
/// Creates a new vehicle route plan.
|
| 338 |
+
pub fn new(
|
| 339 |
+
name: impl Into<String>,
|
| 340 |
+
locations: Vec<Location>,
|
| 341 |
+
visits: Vec<Visit>,
|
| 342 |
+
vehicles: Vec<Vehicle>,
|
| 343 |
+
) -> Self {
|
| 344 |
+
// Compute bounding box from locations
|
| 345 |
+
let (sw, ne) = Self::compute_bounds(&locations);
|
| 346 |
+
|
| 347 |
+
Self {
|
| 348 |
+
name: name.into(),
|
| 349 |
+
south_west_corner: sw,
|
| 350 |
+
north_east_corner: ne,
|
| 351 |
+
locations,
|
| 352 |
+
visits,
|
| 353 |
+
vehicles,
|
| 354 |
+
score: None,
|
| 355 |
+
solver_status: None,
|
| 356 |
+
travel_time_matrix: Vec::new(),
|
| 357 |
+
route_geometries: HashMap::new(),
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/// Computes bounding box from locations.
|
| 362 |
+
fn compute_bounds(locations: &[Location]) -> ([f64; 2], [f64; 2]) {
|
| 363 |
+
if locations.is_empty() {
|
| 364 |
+
return ([0.0, 0.0], [0.0, 0.0]);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
let mut min_lat = f64::MAX;
|
| 368 |
+
let mut max_lat = f64::MIN;
|
| 369 |
+
let mut min_lon = f64::MAX;
|
| 370 |
+
let mut max_lon = f64::MIN;
|
| 371 |
+
|
| 372 |
+
for loc in locations {
|
| 373 |
+
min_lat = min_lat.min(loc.latitude);
|
| 374 |
+
max_lat = max_lat.max(loc.latitude);
|
| 375 |
+
min_lon = min_lon.min(loc.longitude);
|
| 376 |
+
max_lon = max_lon.max(loc.longitude);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
// No padding here - init_routing() adds expansion
|
| 380 |
+
([min_lat, min_lon], [max_lat, max_lon])
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
/// Populates travel time matrix using haversine distances.
|
| 384 |
+
///
|
| 385 |
+
/// Must be called after construction and before solving.
|
| 386 |
+
/// For real road routing, use `init_routing()` instead.
|
| 387 |
+
pub fn finalize(&mut self) {
|
| 388 |
+
let n = self.locations.len();
|
| 389 |
+
self.travel_time_matrix = vec![vec![0; n]; n];
|
| 390 |
+
|
| 391 |
+
for i in 0..n {
|
| 392 |
+
for j in 0..n {
|
| 393 |
+
if i != j {
|
| 394 |
+
self.travel_time_matrix[i][j] =
|
| 395 |
+
self.locations[i].travel_time_seconds(&self.locations[j]);
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
/// Initializes with real road routing from OSM data.
|
| 402 |
+
///
|
| 403 |
+
/// Downloads road network via Overpass API (cached), builds graph,
|
| 404 |
+
/// and computes travel times using Dijkstra shortest paths.
|
| 405 |
+
/// Also stores route geometries for visualization.
|
| 406 |
+
pub async fn init_routing(&mut self) -> Result<(), crate::routing::RoutingError> {
|
| 407 |
+
use crate::routing::{BoundingBox, RoadNetwork};
|
| 408 |
+
|
| 409 |
+
// Build bounding box from plan bounds (with expansion)
|
| 410 |
+
let bbox = BoundingBox::new(
|
| 411 |
+
self.south_west_corner[0],
|
| 412 |
+
self.south_west_corner[1],
|
| 413 |
+
self.north_east_corner[0],
|
| 414 |
+
self.north_east_corner[1],
|
| 415 |
+
)
|
| 416 |
+
.expand(0.05); // 5% expansion to catch nearby roads
|
| 417 |
+
|
| 418 |
+
// Load or fetch road network
|
| 419 |
+
let network = RoadNetwork::load_or_fetch(&bbox).await?;
|
| 420 |
+
|
| 421 |
+
// Extract coordinates
|
| 422 |
+
let coords: Vec<(f64, f64)> = self
|
| 423 |
+
.locations
|
| 424 |
+
.iter()
|
| 425 |
+
.map(|l| (l.latitude, l.longitude))
|
| 426 |
+
.collect();
|
| 427 |
+
|
| 428 |
+
// Compute travel time matrix
|
| 429 |
+
self.travel_time_matrix = network.compute_matrix(&coords);
|
| 430 |
+
|
| 431 |
+
// Compute route geometries for visualization
|
| 432 |
+
self.route_geometries = network.compute_all_geometries(&coords);
|
| 433 |
+
|
| 434 |
+
Ok(())
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
/// Returns the bounding box for this plan.
|
| 438 |
+
pub fn bounding_box(&self) -> crate::routing::BoundingBox {
|
| 439 |
+
crate::routing::BoundingBox::new(
|
| 440 |
+
self.south_west_corner[0],
|
| 441 |
+
self.south_west_corner[1],
|
| 442 |
+
self.north_east_corner[0],
|
| 443 |
+
self.north_east_corner[1],
|
| 444 |
+
)
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
/// Gets travel time between two locations in seconds.
|
| 448 |
+
///
|
| 449 |
+
/// Returns 0 if indices are out of bounds or matrix not initialized.
|
| 450 |
+
#[inline]
|
| 451 |
+
pub fn travel_time(&self, from_idx: usize, to_idx: usize) -> i64 {
|
| 452 |
+
self.travel_time_matrix
|
| 453 |
+
.get(from_idx)
|
| 454 |
+
.and_then(|row| row.get(to_idx))
|
| 455 |
+
.copied()
|
| 456 |
+
.unwrap_or(0)
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/// Gets route geometry between two locations.
|
| 460 |
+
///
|
| 461 |
+
/// Returns the waypoints if real road routing was initialized,
|
| 462 |
+
/// or `None` if using haversine fallback.
|
| 463 |
+
#[inline]
|
| 464 |
+
pub fn route_geometry(&self, from_idx: usize, to_idx: usize) -> Option<&[(f64, f64)]> {
|
| 465 |
+
self.route_geometries.get(&(from_idx, to_idx)).map(|v| v.as_slice())
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
/// Gets a location by index.
|
| 469 |
+
#[inline]
|
| 470 |
+
pub fn get_location(&self, idx: usize) -> Option<&Location> {
|
| 471 |
+
self.locations.get(idx)
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/// Gets a visit by index.
|
| 475 |
+
#[inline]
|
| 476 |
+
pub fn get_visit(&self, idx: usize) -> Option<&Visit> {
|
| 477 |
+
self.visits.get(idx)
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
/// Calculates arrival and departure times for each visit in a vehicle's route.
|
| 481 |
+
///
|
| 482 |
+
/// Returns a vector of [`VisitTiming`] in route order.
|
| 483 |
+
///
|
| 484 |
+
/// # Examples
|
| 485 |
+
///
|
| 486 |
+
/// ```
|
| 487 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 488 |
+
///
|
| 489 |
+
/// let depot = Location::new(0, 0.0, 0.0);
|
| 490 |
+
/// let customer_loc = Location::new(1, 0.0, 0.01); // ~1.1 km away
|
| 491 |
+
///
|
| 492 |
+
/// let locations = vec![depot.clone(), customer_loc.clone()];
|
| 493 |
+
/// let visits = vec![
|
| 494 |
+
/// Visit::new(0, "A", customer_loc)
|
| 495 |
+
/// .with_service_duration(300)
|
| 496 |
+
/// .with_time_window(0, 86400),
|
| 497 |
+
/// ];
|
| 498 |
+
/// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
|
| 499 |
+
/// vehicle.departure_time = 8 * 3600; // 8am
|
| 500 |
+
/// vehicle.visits = vec![0];
|
| 501 |
+
///
|
| 502 |
+
/// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
|
| 503 |
+
/// plan.finalize();
|
| 504 |
+
///
|
| 505 |
+
/// let timings = plan.calculate_route_times(&plan.vehicles[0]);
|
| 506 |
+
/// assert_eq!(timings.len(), 1);
|
| 507 |
+
/// assert!(timings[0].arrival > 8 * 3600); // Arrives after departure
|
| 508 |
+
/// assert_eq!(timings[0].departure, timings[0].arrival + 300); // Service takes 5 min
|
| 509 |
+
/// ```
|
| 510 |
+
pub fn calculate_route_times(&self, vehicle: &Vehicle) -> Vec<VisitTiming> {
|
| 511 |
+
let mut timings = Vec::with_capacity(vehicle.visits.len());
|
| 512 |
+
let mut current_time = vehicle.departure_time;
|
| 513 |
+
let mut current_loc = vehicle.home_location.index;
|
| 514 |
+
|
| 515 |
+
for &visit_idx in &vehicle.visits {
|
| 516 |
+
let Some(visit) = self.visits.get(visit_idx) else {
|
| 517 |
+
continue;
|
| 518 |
+
};
|
| 519 |
+
|
| 520 |
+
// Travel to this visit
|
| 521 |
+
let travel = self.travel_time(current_loc, visit.location.index);
|
| 522 |
+
let arrival = current_time + travel;
|
| 523 |
+
|
| 524 |
+
// Service starts at max(arrival, min_start_time)
|
| 525 |
+
let service_start = arrival.max(visit.min_start_time);
|
| 526 |
+
let departure = service_start + visit.service_duration;
|
| 527 |
+
|
| 528 |
+
timings.push(VisitTiming {
|
| 529 |
+
visit_idx,
|
| 530 |
+
arrival,
|
| 531 |
+
departure,
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
current_time = departure;
|
| 535 |
+
current_loc = visit.location.index;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
timings
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
/// Calculates total driving time for a vehicle's route in seconds.
|
| 542 |
+
///
|
| 543 |
+
/// Includes travel from depot, between visits, and back to depot.
|
| 544 |
+
pub fn total_driving_time(&self, vehicle: &Vehicle) -> i64 {
|
| 545 |
+
if vehicle.visits.is_empty() {
|
| 546 |
+
return 0;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
let mut total = 0i64;
|
| 550 |
+
let mut current_loc = vehicle.home_location.index;
|
| 551 |
+
|
| 552 |
+
for &visit_idx in &vehicle.visits {
|
| 553 |
+
if let Some(visit) = self.visits.get(visit_idx) {
|
| 554 |
+
total += self.travel_time(current_loc, visit.location.index);
|
| 555 |
+
current_loc = visit.location.index;
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
// Return to depot
|
| 560 |
+
total += self.travel_time(current_loc, vehicle.home_location.index);
|
| 561 |
+
total
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
/// Calculates total driving time across all vehicles.
|
| 565 |
+
pub fn total_driving_time_all(&self) -> i64 {
|
| 566 |
+
self.vehicles.iter().map(|v| self.total_driving_time(v)).sum()
|
| 567 |
+
}
|
| 568 |
+
}
|
src/geometry.rs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Geometry utilities for route visualization.
|
| 2 |
+
//!
|
| 3 |
+
//! Implements Google Polyline encoding for efficient route transmission.
|
| 4 |
+
//! See: <https://developers.google.com/maps/documentation/utilities/polylinealgorithm>
|
| 5 |
+
|
| 6 |
+
use crate::domain::{Vehicle, VehicleRoutePlan};
|
| 7 |
+
use utoipa::ToSchema;
|
| 8 |
+
|
| 9 |
+
/// Encodes a sequence of coordinates using Google Polyline Algorithm.
|
| 10 |
+
///
|
| 11 |
+
/// The algorithm encodes latitude/longitude pairs as an ASCII string for
|
| 12 |
+
/// efficient transmission. Each coordinate is encoded as the difference
|
| 13 |
+
/// from the previous point, with 5 decimal places of precision.
|
| 14 |
+
///
|
| 15 |
+
/// # Examples
|
| 16 |
+
///
|
| 17 |
+
/// ```
|
| 18 |
+
/// use vehicle_routing::geometry::encode_polyline;
|
| 19 |
+
///
|
| 20 |
+
/// // Single point encodes to non-empty string
|
| 21 |
+
/// let encoded = encode_polyline(&[(38.5, -120.2)]);
|
| 22 |
+
/// assert!(!encoded.is_empty());
|
| 23 |
+
///
|
| 24 |
+
/// // Empty input gives empty output
|
| 25 |
+
/// let empty = encode_polyline(&[]);
|
| 26 |
+
/// assert!(empty.is_empty());
|
| 27 |
+
///
|
| 28 |
+
/// // Two points create a line
|
| 29 |
+
/// let line = encode_polyline(&[(38.5, -120.2), (40.7, -120.95)]);
|
| 30 |
+
/// assert!(!line.is_empty());
|
| 31 |
+
/// ```
|
| 32 |
+
pub fn encode_polyline(coords: &[(f64, f64)]) -> String {
|
| 33 |
+
if coords.is_empty() {
|
| 34 |
+
return String::new();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
let mut result = String::new();
|
| 38 |
+
let mut prev_lat = 0i64;
|
| 39 |
+
let mut prev_lng = 0i64;
|
| 40 |
+
|
| 41 |
+
for &(lat, lng) in coords {
|
| 42 |
+
// Convert to fixed-point with 5 decimal places
|
| 43 |
+
let lat_e5 = (lat * 1e5).round() as i64;
|
| 44 |
+
let lng_e5 = (lng * 1e5).round() as i64;
|
| 45 |
+
|
| 46 |
+
// Encode deltas
|
| 47 |
+
encode_value(lat_e5 - prev_lat, &mut result);
|
| 48 |
+
encode_value(lng_e5 - prev_lng, &mut result);
|
| 49 |
+
|
| 50 |
+
prev_lat = lat_e5;
|
| 51 |
+
prev_lng = lng_e5;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
result
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/// Encodes a single signed value using the polyline algorithm.
|
| 58 |
+
fn encode_value(value: i64, output: &mut String) {
|
| 59 |
+
// Left-shift and invert if negative
|
| 60 |
+
let mut encoded = if value < 0 {
|
| 61 |
+
!((value) << 1)
|
| 62 |
+
} else {
|
| 63 |
+
(value) << 1
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
// Break into 5-bit chunks, OR with 0x20 if more chunks follow
|
| 67 |
+
while encoded >= 0x20 {
|
| 68 |
+
output.push(char::from_u32(((encoded & 0x1f) | 0x20) as u32 + 63).unwrap());
|
| 69 |
+
encoded >>= 5;
|
| 70 |
+
}
|
| 71 |
+
output.push(char::from_u32(encoded as u32 + 63).unwrap());
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/// Decodes a Google Polyline string back to coordinates.
|
| 75 |
+
///
|
| 76 |
+
/// # Examples
|
| 77 |
+
///
|
| 78 |
+
/// ```
|
| 79 |
+
/// use vehicle_routing::geometry::{encode_polyline, decode_polyline};
|
| 80 |
+
///
|
| 81 |
+
/// let original = vec![(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)];
|
| 82 |
+
/// let encoded = encode_polyline(&original);
|
| 83 |
+
/// let decoded = decode_polyline(&encoded);
|
| 84 |
+
///
|
| 85 |
+
/// // Check round-trip (within 5 decimal places precision)
|
| 86 |
+
/// assert_eq!(decoded.len(), original.len());
|
| 87 |
+
/// for (orig, dec) in original.iter().zip(decoded.iter()) {
|
| 88 |
+
/// assert!((orig.0 - dec.0).abs() < 0.00001);
|
| 89 |
+
/// assert!((orig.1 - dec.1).abs() < 0.00001);
|
| 90 |
+
/// }
|
| 91 |
+
/// ```
|
| 92 |
+
pub fn decode_polyline(encoded: &str) -> Vec<(f64, f64)> {
|
| 93 |
+
let mut coords = Vec::new();
|
| 94 |
+
let mut lat = 0i64;
|
| 95 |
+
let mut lng = 0i64;
|
| 96 |
+
let bytes = encoded.as_bytes();
|
| 97 |
+
let mut i = 0;
|
| 98 |
+
|
| 99 |
+
while i < bytes.len() {
|
| 100 |
+
// Decode latitude delta
|
| 101 |
+
let (lat_delta, consumed) = decode_value(&bytes[i..]);
|
| 102 |
+
i += consumed;
|
| 103 |
+
lat += lat_delta;
|
| 104 |
+
|
| 105 |
+
if i >= bytes.len() {
|
| 106 |
+
break;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Decode longitude delta
|
| 110 |
+
let (lng_delta, consumed) = decode_value(&bytes[i..]);
|
| 111 |
+
i += consumed;
|
| 112 |
+
lng += lng_delta;
|
| 113 |
+
|
| 114 |
+
coords.push((lat as f64 / 1e5, lng as f64 / 1e5));
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
coords
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/// Decodes a single value, returning (value, bytes_consumed).
|
| 121 |
+
fn decode_value(bytes: &[u8]) -> (i64, usize) {
|
| 122 |
+
let mut result = 0i64;
|
| 123 |
+
let mut shift = 0;
|
| 124 |
+
let mut consumed = 0;
|
| 125 |
+
|
| 126 |
+
for &b in bytes {
|
| 127 |
+
consumed += 1;
|
| 128 |
+
let chunk = (b as i64) - 63;
|
| 129 |
+
result |= (chunk & 0x1f) << shift;
|
| 130 |
+
shift += 5;
|
| 131 |
+
|
| 132 |
+
if chunk < 0x20 {
|
| 133 |
+
break;
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Invert if negative (check LSB)
|
| 138 |
+
if result & 1 != 0 {
|
| 139 |
+
result = !(result >> 1);
|
| 140 |
+
} else {
|
| 141 |
+
result >>= 1;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
(result, consumed)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/// Encoded route segment for a vehicle's route.
|
| 148 |
+
#[derive(Debug, Clone, serde::Serialize, ToSchema)]
|
| 149 |
+
pub struct EncodedSegment {
|
| 150 |
+
/// Vehicle index.
|
| 151 |
+
pub vehicle_idx: usize,
|
| 152 |
+
/// Vehicle name.
|
| 153 |
+
pub vehicle_name: String,
|
| 154 |
+
/// Encoded polyline string (Google format).
|
| 155 |
+
pub polyline: String,
|
| 156 |
+
/// Number of points in the route.
|
| 157 |
+
pub point_count: usize,
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/// Generates encoded polylines for all vehicle routes.
|
| 161 |
+
///
|
| 162 |
+
/// Returns segments for each vehicle with non-empty routes.
|
| 163 |
+
///
|
| 164 |
+
/// # Examples
|
| 165 |
+
///
|
| 166 |
+
/// ```
|
| 167 |
+
/// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
|
| 168 |
+
/// use vehicle_routing::geometry::encode_routes;
|
| 169 |
+
///
|
| 170 |
+
/// let depot = Location::new(0, 39.95, -75.16);
|
| 171 |
+
/// let loc_a = Location::new(1, 39.96, -75.17);
|
| 172 |
+
/// let loc_b = Location::new(2, 39.94, -75.15);
|
| 173 |
+
///
|
| 174 |
+
/// let locations = vec![depot.clone(), loc_a.clone(), loc_b.clone()];
|
| 175 |
+
/// let visits = vec![
|
| 176 |
+
/// Visit::new(0, "A", loc_a),
|
| 177 |
+
/// Visit::new(1, "B", loc_b),
|
| 178 |
+
/// ];
|
| 179 |
+
/// let mut vehicle = Vehicle::new(0, "Alpha", 100, depot);
|
| 180 |
+
/// vehicle.visits = vec![0, 1]; // A -> B
|
| 181 |
+
///
|
| 182 |
+
/// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
|
| 183 |
+
///
|
| 184 |
+
/// // Set up route geometries (normally done by init_routing)
|
| 185 |
+
/// // Route: depot(0) -> A(1) -> B(2) -> depot(0)
|
| 186 |
+
/// plan.route_geometries.insert((0, 1), vec![(39.95, -75.16), (39.96, -75.17)]);
|
| 187 |
+
/// plan.route_geometries.insert((1, 2), vec![(39.96, -75.17), (39.94, -75.15)]);
|
| 188 |
+
/// plan.route_geometries.insert((2, 0), vec![(39.94, -75.15), (39.95, -75.16)]);
|
| 189 |
+
///
|
| 190 |
+
/// let segments = encode_routes(&plan);
|
| 191 |
+
/// assert_eq!(segments.len(), 1); // One vehicle with visits
|
| 192 |
+
/// assert_eq!(segments[0].vehicle_name, "Alpha");
|
| 193 |
+
/// assert_eq!(segments[0].point_count, 4); // depot -> A -> B -> depot
|
| 194 |
+
/// ```
|
| 195 |
+
pub fn encode_routes(plan: &VehicleRoutePlan) -> Vec<EncodedSegment> {
|
| 196 |
+
plan.vehicles
|
| 197 |
+
.iter()
|
| 198 |
+
.filter(|v| !v.visits.is_empty())
|
| 199 |
+
.map(|vehicle| {
|
| 200 |
+
let coords = get_route_coords(plan, vehicle);
|
| 201 |
+
let polyline = encode_polyline(&coords);
|
| 202 |
+
EncodedSegment {
|
| 203 |
+
vehicle_idx: vehicle.id,
|
| 204 |
+
vehicle_name: vehicle.name.clone(),
|
| 205 |
+
polyline,
|
| 206 |
+
point_count: coords.len(),
|
| 207 |
+
}
|
| 208 |
+
})
|
| 209 |
+
.collect()
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
/// Gets coordinates for a vehicle's complete route (depot -> visits -> depot).
|
| 213 |
+
///
|
| 214 |
+
/// Uses stored route geometries from road network routing.
|
| 215 |
+
/// Returns empty if route geometries are not initialized.
|
| 216 |
+
fn get_route_coords(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> Vec<(f64, f64)> {
|
| 217 |
+
let mut coords = Vec::new();
|
| 218 |
+
let depot_idx = vehicle.home_location.index;
|
| 219 |
+
|
| 220 |
+
// Build the sequence of location indices: depot -> visits -> depot
|
| 221 |
+
let visit_loc_indices: Vec<usize> = vehicle
|
| 222 |
+
.visits
|
| 223 |
+
.iter()
|
| 224 |
+
.filter_map(|&v| plan.get_visit(v).map(|visit| visit.location.index))
|
| 225 |
+
.collect();
|
| 226 |
+
|
| 227 |
+
let route: Vec<usize> = std::iter::once(depot_idx)
|
| 228 |
+
.chain(visit_loc_indices)
|
| 229 |
+
.chain(std::iter::once(depot_idx))
|
| 230 |
+
.collect();
|
| 231 |
+
|
| 232 |
+
// Process each leg
|
| 233 |
+
for i in 0..route.len().saturating_sub(1) {
|
| 234 |
+
let from_idx = route[i];
|
| 235 |
+
let to_idx = route[i + 1];
|
| 236 |
+
|
| 237 |
+
if let Some(geometry) = plan.route_geometry(from_idx, to_idx) {
|
| 238 |
+
// Use stored road geometry
|
| 239 |
+
// Skip first point of subsequent segments to avoid duplicates
|
| 240 |
+
let skip = if coords.is_empty() { 0 } else { 1 };
|
| 241 |
+
coords.extend(geometry.iter().skip(skip).copied());
|
| 242 |
+
} else {
|
| 243 |
+
// Fallback: use direct lat/lng when road geometry unavailable
|
| 244 |
+
if coords.is_empty() {
|
| 245 |
+
if let Some(from_loc) = plan.get_location(from_idx) {
|
| 246 |
+
coords.push((from_loc.latitude, from_loc.longitude));
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
if let Some(to_loc) = plan.get_location(to_idx) {
|
| 250 |
+
coords.push((to_loc.latitude, to_loc.longitude));
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
coords
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
#[cfg(test)]
|
| 259 |
+
mod tests {
|
| 260 |
+
use super::*;
|
| 261 |
+
|
| 262 |
+
#[test]
|
| 263 |
+
fn test_encode_decode_roundtrip() {
|
| 264 |
+
let coords = vec![
|
| 265 |
+
(38.5, -120.2),
|
| 266 |
+
(40.7, -120.95),
|
| 267 |
+
(43.252, -126.453),
|
| 268 |
+
];
|
| 269 |
+
let encoded = encode_polyline(&coords);
|
| 270 |
+
let decoded = decode_polyline(&encoded);
|
| 271 |
+
|
| 272 |
+
assert_eq!(decoded.len(), coords.len());
|
| 273 |
+
for (orig, dec) in coords.iter().zip(decoded.iter()) {
|
| 274 |
+
assert!((orig.0 - dec.0).abs() < 0.00001);
|
| 275 |
+
assert!((orig.1 - dec.1).abs() < 0.00001);
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
#[test]
|
| 280 |
+
fn test_known_encoding() {
|
| 281 |
+
// Known encoding from Google's examples
|
| 282 |
+
let coords = vec![(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)];
|
| 283 |
+
let encoded = encode_polyline(&coords);
|
| 284 |
+
// The encoding should be deterministic
|
| 285 |
+
assert!(!encoded.is_empty());
|
| 286 |
+
// Verify we can decode it back
|
| 287 |
+
let decoded = decode_polyline(&encoded);
|
| 288 |
+
assert_eq!(decoded.len(), 3);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
#[test]
|
| 292 |
+
fn test_empty_coords() {
|
| 293 |
+
let encoded = encode_polyline(&[]);
|
| 294 |
+
assert!(encoded.is_empty());
|
| 295 |
+
let decoded = decode_polyline("");
|
| 296 |
+
assert!(decoded.is_empty());
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
#[test]
|
| 300 |
+
fn test_single_point() {
|
| 301 |
+
let coords = vec![(0.0, 0.0)];
|
| 302 |
+
let encoded = encode_polyline(&coords);
|
| 303 |
+
let decoded = decode_polyline(&encoded);
|
| 304 |
+
assert_eq!(decoded.len(), 1);
|
| 305 |
+
assert!((decoded[0].0).abs() < 0.00001);
|
| 306 |
+
assert!((decoded[0].1).abs() < 0.00001);
|
| 307 |
+
}
|
| 308 |
+
}
|
src/lib.rs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Vehicle Routing Quickstart for SolverForge
|
| 2 |
+
//!
|
| 3 |
+
//! Solves vehicle routing problems with time windows, capacity constraints,
|
| 4 |
+
//! and travel time minimization using Late Acceptance local search.
|
| 5 |
+
//!
|
| 6 |
+
//! # Domain Model
|
| 7 |
+
//!
|
| 8 |
+
//! - [`Location`](domain::Location): Geographic point with haversine distance
|
| 9 |
+
//! - [`Visit`](domain::Visit): Customer to visit with time window and demand
|
| 10 |
+
//! - [`Vehicle`](domain::Vehicle): Delivery vehicle with capacity and route
|
| 11 |
+
//! - [`VehicleRoutePlan`](domain::VehicleRoutePlan): Complete planning solution
|
| 12 |
+
//!
|
| 13 |
+
//! # Constraints
|
| 14 |
+
//!
|
| 15 |
+
//! - **Vehicle capacity** (hard): Total demand must not exceed vehicle capacity
|
| 16 |
+
//! - **Time windows** (hard): Service must finish before max end time
|
| 17 |
+
//! - **Travel time** (soft): Minimize total driving time
|
| 18 |
+
|
| 19 |
+
pub mod api;
|
| 20 |
+
pub mod console;
|
| 21 |
+
pub mod constraints;
|
| 22 |
+
pub mod demo_data;
|
| 23 |
+
pub mod domain;
|
| 24 |
+
pub mod geometry;
|
| 25 |
+
pub mod routing;
|
| 26 |
+
pub mod solver;
|
src/main.rs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Vehicle Routing Quickstart - Axum Server
|
| 2 |
+
//!
|
| 3 |
+
//! Run with: cargo run -p vehicle-routing
|
| 4 |
+
//! Then open: http://localhost:8082
|
| 5 |
+
|
| 6 |
+
use owo_colors::OwoColorize;
|
| 7 |
+
use std::net::SocketAddr;
|
| 8 |
+
use std::path::PathBuf;
|
| 9 |
+
use tower_http::cors::{Any, CorsLayer};
|
| 10 |
+
use tower_http::services::ServeDir;
|
| 11 |
+
use tracing_subscriber::EnvFilter;
|
| 12 |
+
use vehicle_routing::console;
|
| 13 |
+
|
| 14 |
+
#[tokio::main]
|
| 15 |
+
async fn main() {
|
| 16 |
+
// Initialize tracing (logs from vehicle_routing at INFO level)
|
| 17 |
+
tracing_subscriber::fmt()
|
| 18 |
+
.with_env_filter(
|
| 19 |
+
EnvFilter::from_default_env()
|
| 20 |
+
.add_directive("vehicle_routing=info".parse().unwrap()),
|
| 21 |
+
)
|
| 22 |
+
.init();
|
| 23 |
+
|
| 24 |
+
// Print colorful banner
|
| 25 |
+
console::print_banner();
|
| 26 |
+
|
| 27 |
+
// CORS for development
|
| 28 |
+
let cors = CorsLayer::new()
|
| 29 |
+
.allow_origin(Any)
|
| 30 |
+
.allow_methods(Any)
|
| 31 |
+
.allow_headers(Any);
|
| 32 |
+
|
| 33 |
+
// Determine static files path (works from workspace root or example dir)
|
| 34 |
+
let static_path = if PathBuf::from("examples/vehicle-routing/static").exists() {
|
| 35 |
+
"examples/vehicle-routing/static"
|
| 36 |
+
} else {
|
| 37 |
+
"static"
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
// Build router with static file fallback
|
| 41 |
+
let app = vehicle_routing::api::create_router()
|
| 42 |
+
.fallback_service(ServeDir::new(static_path))
|
| 43 |
+
.layer(cors);
|
| 44 |
+
|
| 45 |
+
// Bind and serve
|
| 46 |
+
let addr = SocketAddr::from(([0, 0, 0, 0], 7860));
|
| 47 |
+
println!(
|
| 48 |
+
"{} Server listening on {}",
|
| 49 |
+
"▸".bright_green(),
|
| 50 |
+
format!("http://{}", addr).bright_cyan().underline()
|
| 51 |
+
);
|
| 52 |
+
println!(
|
| 53 |
+
"{} Open {} in your browser\n",
|
| 54 |
+
"▸".bright_green(),
|
| 55 |
+
"http://localhost:7860".bright_cyan().underline()
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
| 59 |
+
axum::serve(listener, app).await.unwrap();
|
| 60 |
+
}
|
src/routing.rs
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Local OSM road routing using Overpass API and petgraph.
|
| 2 |
+
//!
|
| 3 |
+
//! Downloads OpenStreetMap road network data via Overpass API,
|
| 4 |
+
//! builds a graph locally, and computes shortest paths with Dijkstra.
|
| 5 |
+
//! Results are cached in memory (per-process) and `.osm_cache/` (persistent).
|
| 6 |
+
|
| 7 |
+
use ordered_float::OrderedFloat;
|
| 8 |
+
use petgraph::algo::{astar, dijkstra};
|
| 9 |
+
use petgraph::graph::{DiGraph, NodeIndex};
|
| 10 |
+
use serde::{Deserialize, Serialize};
|
| 11 |
+
use std::collections::HashMap;
|
| 12 |
+
use std::path::Path;
|
| 13 |
+
use std::sync::{Arc, OnceLock};
|
| 14 |
+
use tokio::sync::RwLock;
|
| 15 |
+
use tracing::{debug, error, info};
|
| 16 |
+
|
| 17 |
+
/// In-memory cache of road networks, keyed by bbox cache key.
|
| 18 |
+
/// First request downloads, subsequent requests reuse the cached network.
|
| 19 |
+
static NETWORK_CACHE: OnceLock<RwLock<HashMap<String, Arc<RoadNetwork>>>> = OnceLock::new();
|
| 20 |
+
|
| 21 |
+
fn network_cache() -> &'static RwLock<HashMap<String, Arc<RoadNetwork>>> {
|
| 22 |
+
NETWORK_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/// Overpass API URL.
|
| 26 |
+
const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
|
| 27 |
+
|
| 28 |
+
/// Cache directory for downloaded OSM data.
|
| 29 |
+
const CACHE_DIR: &str = ".osm_cache";
|
| 30 |
+
|
| 31 |
+
/// Default driving speed in m/s (50 km/h = 13.89 m/s).
|
| 32 |
+
const DEFAULT_SPEED_MPS: f64 = 50.0 * 1000.0 / 3600.0;
|
| 33 |
+
|
| 34 |
+
/// Error type for routing operations.
|
| 35 |
+
#[derive(Debug)]
|
| 36 |
+
pub enum RoutingError {
|
| 37 |
+
/// Network request failed.
|
| 38 |
+
Network(String),
|
| 39 |
+
/// Failed to parse OSM data.
|
| 40 |
+
Parse(String),
|
| 41 |
+
/// I/O error.
|
| 42 |
+
Io(std::io::Error),
|
| 43 |
+
/// No route found.
|
| 44 |
+
NoRoute,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
impl std::fmt::Display for RoutingError {
|
| 48 |
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
| 49 |
+
match self {
|
| 50 |
+
RoutingError::Network(msg) => write!(f, "Network error: {}", msg),
|
| 51 |
+
RoutingError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
| 52 |
+
RoutingError::Io(e) => write!(f, "I/O error: {}", e),
|
| 53 |
+
RoutingError::NoRoute => write!(f, "No route found"),
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
impl std::error::Error for RoutingError {}
|
| 59 |
+
|
| 60 |
+
impl From<std::io::Error> for RoutingError {
|
| 61 |
+
fn from(e: std::io::Error) -> Self {
|
| 62 |
+
RoutingError::Io(e)
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/// Bounding box for OSM queries.
|
| 67 |
+
#[derive(Debug, Clone, Copy)]
|
| 68 |
+
pub struct BoundingBox {
|
| 69 |
+
pub min_lat: f64,
|
| 70 |
+
pub min_lng: f64,
|
| 71 |
+
pub max_lat: f64,
|
| 72 |
+
pub max_lng: f64,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
impl BoundingBox {
|
| 76 |
+
/// Creates a new bounding box.
|
| 77 |
+
pub fn new(min_lat: f64, min_lng: f64, max_lat: f64, max_lng: f64) -> Self {
|
| 78 |
+
Self {
|
| 79 |
+
min_lat,
|
| 80 |
+
min_lng,
|
| 81 |
+
max_lat,
|
| 82 |
+
max_lng,
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/// Expands the bounding box by a factor (e.g., 0.1 = 10% on each side).
|
| 87 |
+
pub fn expand(&self, factor: f64) -> Self {
|
| 88 |
+
let lat_range = self.max_lat - self.min_lat;
|
| 89 |
+
let lng_range = self.max_lng - self.min_lng;
|
| 90 |
+
let lat_pad = lat_range * factor;
|
| 91 |
+
let lng_pad = lng_range * factor;
|
| 92 |
+
|
| 93 |
+
Self {
|
| 94 |
+
min_lat: self.min_lat - lat_pad,
|
| 95 |
+
min_lng: self.min_lng - lng_pad,
|
| 96 |
+
max_lat: self.max_lat + lat_pad,
|
| 97 |
+
max_lng: self.max_lng + lng_pad,
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/// Returns a cache key for this bounding box.
|
| 102 |
+
fn cache_key(&self) -> String {
|
| 103 |
+
format!(
|
| 104 |
+
"{:.4}_{:.4}_{:.4}_{:.4}",
|
| 105 |
+
self.min_lat, self.min_lng, self.max_lat, self.max_lng
|
| 106 |
+
)
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
/// Node data in the road graph.
|
| 111 |
+
#[derive(Debug, Clone)]
|
| 112 |
+
struct NodeData {
|
| 113 |
+
lat: f64,
|
| 114 |
+
lng: f64,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/// Edge data in the road graph.
|
| 118 |
+
#[derive(Debug, Clone)]
|
| 119 |
+
struct EdgeData {
|
| 120 |
+
/// Travel time in seconds.
|
| 121 |
+
travel_time_s: f64,
|
| 122 |
+
/// Distance in meters.
|
| 123 |
+
distance_m: f64,
|
| 124 |
+
/// Intermediate geometry points (for future full path reconstruction).
|
| 125 |
+
#[allow(dead_code)]
|
| 126 |
+
geometry: Vec<(f64, f64)>,
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/// Result of a route computation.
|
| 130 |
+
#[derive(Debug, Clone)]
|
| 131 |
+
pub struct RouteResult {
|
| 132 |
+
/// Travel time in seconds.
|
| 133 |
+
pub duration_seconds: i64,
|
| 134 |
+
/// Distance in meters.
|
| 135 |
+
pub distance_meters: f64,
|
| 136 |
+
/// Full route geometry (lat, lng pairs).
|
| 137 |
+
pub geometry: Vec<(f64, f64)>,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/// Road network graph built from OSM data.
|
| 141 |
+
pub struct RoadNetwork {
|
| 142 |
+
/// Directed graph with travel times as edge weights.
|
| 143 |
+
graph: DiGraph<NodeData, EdgeData>,
|
| 144 |
+
/// Map from (lat_e7, lng_e7) to node index.
|
| 145 |
+
coord_to_node: HashMap<(i64, i64), NodeIndex>,
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
impl RoadNetwork {
|
| 149 |
+
/// Creates an empty road network.
|
| 150 |
+
pub fn new() -> Self {
|
| 151 |
+
Self {
|
| 152 |
+
graph: DiGraph::new(),
|
| 153 |
+
coord_to_node: HashMap::new(),
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/// Loads or fetches road network for a bounding box.
|
| 158 |
+
///
|
| 159 |
+
/// Uses three-tier caching:
|
| 160 |
+
/// 1. In-memory cache (instant, per-process)
|
| 161 |
+
/// 2. File cache (fast, persists across restarts)
|
| 162 |
+
/// 3. Overpass API download (slow, ~5-30s)
|
| 163 |
+
///
|
| 164 |
+
/// Thread-safe: concurrent requests for the same bbox will wait for
|
| 165 |
+
/// the first download to complete rather than downloading multiple times.
|
| 166 |
+
pub async fn load_or_fetch(bbox: &BoundingBox) -> Result<Arc<Self>, RoutingError> {
|
| 167 |
+
let cache_key = bbox.cache_key();
|
| 168 |
+
|
| 169 |
+
// 1. Check in-memory cache (fast path, read lock)
|
| 170 |
+
{
|
| 171 |
+
let cache = network_cache().read().await;
|
| 172 |
+
if let Some(network) = cache.get(&cache_key) {
|
| 173 |
+
info!("Using in-memory cached road network for {}", cache_key);
|
| 174 |
+
return Ok(Arc::clone(network));
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// 2. Acquire write lock and double-check (another request may have loaded it)
|
| 179 |
+
let mut cache = network_cache().write().await;
|
| 180 |
+
if let Some(network) = cache.get(&cache_key) {
|
| 181 |
+
info!("Using in-memory cached road network for {}", cache_key);
|
| 182 |
+
return Ok(Arc::clone(network));
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
// 3. Try loading from file cache
|
| 186 |
+
tokio::fs::create_dir_all(CACHE_DIR).await?;
|
| 187 |
+
let cache_path = Path::new(CACHE_DIR).join(format!("{}.json", cache_key));
|
| 188 |
+
|
| 189 |
+
let network = if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
|
| 190 |
+
info!("Loading road network from file cache: {:?}", cache_path);
|
| 191 |
+
match Self::load_from_cache(&cache_path).await {
|
| 192 |
+
Ok(n) => n,
|
| 193 |
+
Err(e) => {
|
| 194 |
+
// File cache failed (corrupted/old version), download fresh
|
| 195 |
+
info!("File cache invalid ({}), downloading fresh", e);
|
| 196 |
+
let n = Self::from_bbox(bbox).await?;
|
| 197 |
+
n.save_to_cache(&cache_path).await?;
|
| 198 |
+
info!("Saved road network to file cache: {:?}", cache_path);
|
| 199 |
+
n
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
} else {
|
| 203 |
+
// 4. Download from Overpass API
|
| 204 |
+
info!("Downloading road network from Overpass API");
|
| 205 |
+
let n = Self::from_bbox(bbox).await?;
|
| 206 |
+
n.save_to_cache(&cache_path).await?;
|
| 207 |
+
info!("Saved road network to file cache: {:?}", cache_path);
|
| 208 |
+
n
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
// Store in memory cache
|
| 212 |
+
let network = Arc::new(network);
|
| 213 |
+
cache.insert(cache_key, Arc::clone(&network));
|
| 214 |
+
|
| 215 |
+
Ok(network)
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/// Downloads and builds road network from Overpass API.
|
| 219 |
+
pub async fn from_bbox(bbox: &BoundingBox) -> Result<Self, RoutingError> {
|
| 220 |
+
let query = format!(
|
| 221 |
+
r#"[out:json][timeout:120];
|
| 222 |
+
(
|
| 223 |
+
way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|service|living_street)$"]
|
| 224 |
+
({},{},{},{});
|
| 225 |
+
);
|
| 226 |
+
(._;>;);
|
| 227 |
+
out body;"#,
|
| 228 |
+
bbox.min_lat, bbox.min_lng, bbox.max_lat, bbox.max_lng
|
| 229 |
+
);
|
| 230 |
+
|
| 231 |
+
debug!("Overpass query:\n{}", query);
|
| 232 |
+
|
| 233 |
+
info!("Preparing Overpass query for bbox: {:.4},{:.4} to {:.4},{:.4}",
|
| 234 |
+
bbox.min_lat, bbox.min_lng, bbox.max_lat, bbox.max_lng);
|
| 235 |
+
|
| 236 |
+
let client = reqwest::Client::builder()
|
| 237 |
+
.connect_timeout(std::time::Duration::from_secs(30))
|
| 238 |
+
.read_timeout(std::time::Duration::from_secs(180))
|
| 239 |
+
.timeout(std::time::Duration::from_secs(180))
|
| 240 |
+
.user_agent("SolverForge/0.4.0")
|
| 241 |
+
.build()
|
| 242 |
+
.map_err(|e| RoutingError::Network(e.to_string()))?;
|
| 243 |
+
|
| 244 |
+
info!("Sending request to Overpass API...");
|
| 245 |
+
|
| 246 |
+
let response = client
|
| 247 |
+
.post(OVERPASS_URL)
|
| 248 |
+
.body(query)
|
| 249 |
+
.header("Content-Type", "text/plain")
|
| 250 |
+
.send()
|
| 251 |
+
.await
|
| 252 |
+
.map_err(|e| {
|
| 253 |
+
error!("Overpass request failed: {}", e);
|
| 254 |
+
RoutingError::Network(e.to_string())
|
| 255 |
+
})?;
|
| 256 |
+
|
| 257 |
+
info!("Received response: status={}", response.status());
|
| 258 |
+
|
| 259 |
+
if !response.status().is_success() {
|
| 260 |
+
return Err(RoutingError::Network(format!(
|
| 261 |
+
"Overpass API returned status {}",
|
| 262 |
+
response.status()
|
| 263 |
+
)));
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
let osm_data: OverpassResponse = response
|
| 267 |
+
.json()
|
| 268 |
+
.await
|
| 269 |
+
.map_err(|e| RoutingError::Parse(e.to_string()))?;
|
| 270 |
+
|
| 271 |
+
info!(
|
| 272 |
+
"Downloaded {} OSM elements",
|
| 273 |
+
osm_data.elements.len()
|
| 274 |
+
);
|
| 275 |
+
|
| 276 |
+
Self::build_from_osm(&osm_data)
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/// Builds the road network from parsed OSM data.
|
| 280 |
+
fn build_from_osm(osm: &OverpassResponse) -> Result<Self, RoutingError> {
|
| 281 |
+
let mut network = Self::new();
|
| 282 |
+
|
| 283 |
+
// First pass: collect all nodes
|
| 284 |
+
let mut nodes: HashMap<i64, (f64, f64)> = HashMap::new();
|
| 285 |
+
for elem in &osm.elements {
|
| 286 |
+
if elem.elem_type == "node" {
|
| 287 |
+
if let (Some(lat), Some(lon)) = (elem.lat, elem.lon) {
|
| 288 |
+
nodes.insert(elem.id, (lat, lon));
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
info!("Parsed {} nodes", nodes.len());
|
| 294 |
+
|
| 295 |
+
// Second pass: process ways and build graph
|
| 296 |
+
let mut way_count = 0;
|
| 297 |
+
for elem in &osm.elements {
|
| 298 |
+
if elem.elem_type == "way" {
|
| 299 |
+
if let Some(ref node_ids) = elem.nodes {
|
| 300 |
+
let highway = elem.tags.as_ref().and_then(|t| t.highway.as_deref());
|
| 301 |
+
let oneway = elem.tags.as_ref().and_then(|t| t.oneway.as_deref());
|
| 302 |
+
let speed = get_speed_for_highway(highway.unwrap_or("residential"));
|
| 303 |
+
let is_oneway = matches!(oneway, Some("yes") | Some("1"));
|
| 304 |
+
|
| 305 |
+
// Process consecutive node pairs
|
| 306 |
+
for window in node_ids.windows(2) {
|
| 307 |
+
let n1_id = window[0];
|
| 308 |
+
let n2_id = window[1];
|
| 309 |
+
|
| 310 |
+
let Some(&(lat1, lng1)) = nodes.get(&n1_id) else {
|
| 311 |
+
continue;
|
| 312 |
+
};
|
| 313 |
+
let Some(&(lat2, lng2)) = nodes.get(&n2_id) else {
|
| 314 |
+
continue;
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
// Get or create node indices
|
| 318 |
+
let idx1 = network.get_or_create_node(lat1, lng1);
|
| 319 |
+
let idx2 = network.get_or_create_node(lat2, lng2);
|
| 320 |
+
|
| 321 |
+
// Calculate edge properties
|
| 322 |
+
let distance = haversine_distance(lat1, lng1, lat2, lng2);
|
| 323 |
+
let travel_time = distance / speed;
|
| 324 |
+
|
| 325 |
+
let edge_data = EdgeData {
|
| 326 |
+
travel_time_s: travel_time,
|
| 327 |
+
distance_m: distance,
|
| 328 |
+
geometry: vec![(lat1, lng1), (lat2, lng2)],
|
| 329 |
+
};
|
| 330 |
+
|
| 331 |
+
// Add forward edge
|
| 332 |
+
network.graph.add_edge(idx1, idx2, edge_data.clone());
|
| 333 |
+
|
| 334 |
+
// Add reverse edge if not oneway
|
| 335 |
+
if !is_oneway {
|
| 336 |
+
network.graph.add_edge(idx2, idx1, edge_data);
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
way_count += 1;
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
info!(
|
| 346 |
+
"Built graph with {} nodes and {} edges from {} ways",
|
| 347 |
+
network.graph.node_count(),
|
| 348 |
+
network.graph.edge_count(),
|
| 349 |
+
way_count
|
| 350 |
+
);
|
| 351 |
+
|
| 352 |
+
Ok(network)
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/// Gets or creates a node for the given coordinates.
|
| 356 |
+
fn get_or_create_node(&mut self, lat: f64, lng: f64) -> NodeIndex {
|
| 357 |
+
let key = coord_key(lat, lng);
|
| 358 |
+
if let Some(&idx) = self.coord_to_node.get(&key) {
|
| 359 |
+
idx
|
| 360 |
+
} else {
|
| 361 |
+
let idx = self.graph.add_node(NodeData { lat, lng });
|
| 362 |
+
self.coord_to_node.insert(key, idx);
|
| 363 |
+
idx
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
/// Finds the nearest road node to the given coordinates.
|
| 368 |
+
pub fn snap_to_road(&self, lat: f64, lng: f64) -> Option<NodeIndex> {
|
| 369 |
+
self.coord_to_node
|
| 370 |
+
.iter()
|
| 371 |
+
.min_by_key(|((lat_e7, lng_e7), _)| {
|
| 372 |
+
let node_lat = *lat_e7 as f64 / 1e7;
|
| 373 |
+
let node_lng = *lng_e7 as f64 / 1e7;
|
| 374 |
+
OrderedFloat(haversine_distance(lat, lng, node_lat, node_lng))
|
| 375 |
+
})
|
| 376 |
+
.map(|(_, &idx)| idx)
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
/// Computes shortest path between two coordinates.
|
| 380 |
+
///
|
| 381 |
+
/// Returns the route with full geometry following roads.
|
| 382 |
+
pub fn route(&self, from: (f64, f64), to: (f64, f64)) -> Option<RouteResult> {
|
| 383 |
+
let start = self.snap_to_road(from.0, from.1)?;
|
| 384 |
+
let end = self.snap_to_road(to.0, to.1)?;
|
| 385 |
+
|
| 386 |
+
if start == end {
|
| 387 |
+
return Some(RouteResult {
|
| 388 |
+
duration_seconds: 0,
|
| 389 |
+
distance_meters: 0.0,
|
| 390 |
+
geometry: vec![from, to],
|
| 391 |
+
});
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
// Use A* with zero heuristic (equivalent to Dijkstra, but returns full path)
|
| 395 |
+
let (cost, path) = astar(
|
| 396 |
+
&self.graph,
|
| 397 |
+
start,
|
| 398 |
+
|n| n == end,
|
| 399 |
+
|e| OrderedFloat(e.weight().travel_time_s),
|
| 400 |
+
|_| OrderedFloat(0.0),
|
| 401 |
+
)?;
|
| 402 |
+
|
| 403 |
+
let total_time = cost.0;
|
| 404 |
+
|
| 405 |
+
// Build geometry from path nodes
|
| 406 |
+
let geometry: Vec<(f64, f64)> = path
|
| 407 |
+
.iter()
|
| 408 |
+
.filter_map(|&idx| self.graph.node_weight(idx).map(|n| (n.lat, n.lng)))
|
| 409 |
+
.collect();
|
| 410 |
+
|
| 411 |
+
// Sum actual edge distances along the path
|
| 412 |
+
let mut distance = 0.0;
|
| 413 |
+
for window in path.windows(2) {
|
| 414 |
+
if let Some(edge) = self.graph.find_edge(window[0], window[1]) {
|
| 415 |
+
if let Some(weight) = self.graph.edge_weight(edge) {
|
| 416 |
+
distance += weight.distance_m;
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
Some(RouteResult {
|
| 422 |
+
duration_seconds: total_time.round() as i64,
|
| 423 |
+
distance_meters: distance,
|
| 424 |
+
geometry,
|
| 425 |
+
})
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
/// Computes route geometries for all location pairs.
|
| 429 |
+
///
|
| 430 |
+
/// Returns a map from `(from_idx, to_idx)` to the route geometry.
|
| 431 |
+
pub fn compute_all_geometries(
|
| 432 |
+
&self,
|
| 433 |
+
locations: &[(f64, f64)],
|
| 434 |
+
) -> HashMap<(usize, usize), Vec<(f64, f64)>> {
|
| 435 |
+
self.compute_all_geometries_with_progress(locations, |_, _| {})
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
/// Computes route geometries with row-level progress callback.
|
| 439 |
+
///
|
| 440 |
+
/// The callback receives `(completed_row, total_rows)` after each source row is computed.
|
| 441 |
+
/// For n locations, this computes n*(n-1) routes, calling the callback n times.
|
| 442 |
+
///
|
| 443 |
+
/// # Example
|
| 444 |
+
///
|
| 445 |
+
/// ```
|
| 446 |
+
/// # use vehicle_routing::routing::RoadNetwork;
|
| 447 |
+
/// let network = RoadNetwork::new();
|
| 448 |
+
/// let locations = vec![(39.95, -75.16), (39.96, -75.17)];
|
| 449 |
+
/// let mut progress_calls = 0;
|
| 450 |
+
/// let geometries = network.compute_all_geometries_with_progress(&locations, |row, total| {
|
| 451 |
+
/// progress_calls += 1;
|
| 452 |
+
/// assert!(row < total);
|
| 453 |
+
/// });
|
| 454 |
+
/// assert_eq!(progress_calls, 2); // One call per source location
|
| 455 |
+
/// ```
|
| 456 |
+
pub fn compute_all_geometries_with_progress<F>(
|
| 457 |
+
&self,
|
| 458 |
+
locations: &[(f64, f64)],
|
| 459 |
+
mut on_row_complete: F,
|
| 460 |
+
) -> HashMap<(usize, usize), Vec<(f64, f64)>>
|
| 461 |
+
where
|
| 462 |
+
F: FnMut(usize, usize),
|
| 463 |
+
{
|
| 464 |
+
let n = locations.len();
|
| 465 |
+
let mut geometries = HashMap::new();
|
| 466 |
+
|
| 467 |
+
for i in 0..n {
|
| 468 |
+
for j in 0..n {
|
| 469 |
+
if i == j {
|
| 470 |
+
continue;
|
| 471 |
+
}
|
| 472 |
+
if let Some(result) = self.route(locations[i], locations[j]) {
|
| 473 |
+
geometries.insert((i, j), result.geometry);
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
// Report progress after each source row
|
| 477 |
+
on_row_complete(i, n);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
geometries
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/// Computes all-pairs travel time matrix for given locations.
|
| 484 |
+
///
|
| 485 |
+
/// Returns a matrix where `result[i][j]` is the travel time from location i to j.
|
| 486 |
+
pub fn compute_matrix(&self, locations: &[(f64, f64)]) -> Vec<Vec<i64>> {
|
| 487 |
+
self.compute_matrix_with_progress(locations, |_, _| {})
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
/// Computes all-pairs travel time matrix with row-level progress callback.
|
| 491 |
+
///
|
| 492 |
+
/// The callback receives `(completed_row, total_rows)` after each row is computed.
|
| 493 |
+
/// This enables progress reporting during the O(n) Dijkstra runs.
|
| 494 |
+
///
|
| 495 |
+
/// # Example
|
| 496 |
+
///
|
| 497 |
+
/// ```
|
| 498 |
+
/// # use vehicle_routing::routing::RoadNetwork;
|
| 499 |
+
/// let network = RoadNetwork::new();
|
| 500 |
+
/// let locations = vec![(39.95, -75.16), (39.96, -75.17)];
|
| 501 |
+
/// let mut progress_calls = 0;
|
| 502 |
+
/// let matrix = network.compute_matrix_with_progress(&locations, |row, total| {
|
| 503 |
+
/// progress_calls += 1;
|
| 504 |
+
/// assert!(row < total);
|
| 505 |
+
/// });
|
| 506 |
+
/// assert_eq!(progress_calls, 2); // One call per row
|
| 507 |
+
/// assert_eq!(matrix.len(), 2);
|
| 508 |
+
/// ```
|
| 509 |
+
pub fn compute_matrix_with_progress<F>(
|
| 510 |
+
&self,
|
| 511 |
+
locations: &[(f64, f64)],
|
| 512 |
+
mut on_row_complete: F,
|
| 513 |
+
) -> Vec<Vec<i64>>
|
| 514 |
+
where
|
| 515 |
+
F: FnMut(usize, usize),
|
| 516 |
+
{
|
| 517 |
+
let n = locations.len();
|
| 518 |
+
let mut matrix = vec![vec![0i64; n]; n];
|
| 519 |
+
|
| 520 |
+
// Snap all locations to nodes
|
| 521 |
+
let nodes: Vec<Option<NodeIndex>> = locations
|
| 522 |
+
.iter()
|
| 523 |
+
.map(|&(lat, lng)| self.snap_to_road(lat, lng))
|
| 524 |
+
.collect();
|
| 525 |
+
|
| 526 |
+
// Compute travel times row by row
|
| 527 |
+
for i in 0..n {
|
| 528 |
+
if let Some(from_node) = nodes[i] {
|
| 529 |
+
// Run Dijkstra from this node
|
| 530 |
+
let costs = dijkstra(&self.graph, from_node, None, |e| {
|
| 531 |
+
OrderedFloat(e.weight().travel_time_s)
|
| 532 |
+
});
|
| 533 |
+
|
| 534 |
+
for j in 0..n {
|
| 535 |
+
if i == j {
|
| 536 |
+
continue;
|
| 537 |
+
}
|
| 538 |
+
if let Some(to_node) = nodes[j] {
|
| 539 |
+
if let Some(cost) = costs.get(&to_node) {
|
| 540 |
+
matrix[i][j] = cost.0.round() as i64;
|
| 541 |
+
} else {
|
| 542 |
+
// No route found, use haversine estimate
|
| 543 |
+
let dist = haversine_distance(
|
| 544 |
+
locations[i].0,
|
| 545 |
+
locations[i].1,
|
| 546 |
+
locations[j].0,
|
| 547 |
+
locations[j].1,
|
| 548 |
+
);
|
| 549 |
+
matrix[i][j] = (dist / DEFAULT_SPEED_MPS).round() as i64;
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
} else {
|
| 554 |
+
// Location couldn't be snapped, use haversine for all
|
| 555 |
+
for j in 0..n {
|
| 556 |
+
if i == j {
|
| 557 |
+
continue;
|
| 558 |
+
}
|
| 559 |
+
let dist = haversine_distance(
|
| 560 |
+
locations[i].0,
|
| 561 |
+
locations[i].1,
|
| 562 |
+
locations[j].0,
|
| 563 |
+
locations[j].1,
|
| 564 |
+
);
|
| 565 |
+
matrix[i][j] = (dist / DEFAULT_SPEED_MPS).round() as i64;
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// Report progress after each row
|
| 570 |
+
on_row_complete(i, n);
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
matrix
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
/// Returns the number of nodes in the graph.
|
| 577 |
+
pub fn node_count(&self) -> usize {
|
| 578 |
+
self.graph.node_count()
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
/// Returns the number of edges in the graph.
|
| 582 |
+
pub fn edge_count(&self) -> usize {
|
| 583 |
+
self.graph.edge_count()
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
/// Loads road network from cache file.
|
| 587 |
+
async fn load_from_cache(path: &Path) -> Result<Self, RoutingError> {
|
| 588 |
+
let data = tokio::fs::read_to_string(path).await?;
|
| 589 |
+
|
| 590 |
+
// Parse cached data, handling corrupted files
|
| 591 |
+
let cached: CachedNetwork = match serde_json::from_str(&data) {
|
| 592 |
+
Ok(c) => c,
|
| 593 |
+
Err(e) => {
|
| 594 |
+
info!("Cache file corrupted, will re-download: {}", e);
|
| 595 |
+
let _ = tokio::fs::remove_file(path).await;
|
| 596 |
+
return Err(RoutingError::Parse(e.to_string()));
|
| 597 |
+
}
|
| 598 |
+
};
|
| 599 |
+
|
| 600 |
+
// Check version - delete old format and re-download
|
| 601 |
+
if cached.version != CACHE_VERSION {
|
| 602 |
+
info!(
|
| 603 |
+
"Cache version mismatch (got {}, need {}), will re-download",
|
| 604 |
+
cached.version, CACHE_VERSION
|
| 605 |
+
);
|
| 606 |
+
let _ = tokio::fs::remove_file(path).await;
|
| 607 |
+
return Err(RoutingError::Parse("cache version mismatch".into()));
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
let mut network = Self::new();
|
| 611 |
+
|
| 612 |
+
// Rebuild graph from cached data
|
| 613 |
+
for node in &cached.nodes {
|
| 614 |
+
let idx = network.graph.add_node(NodeData {
|
| 615 |
+
lat: node.lat,
|
| 616 |
+
lng: node.lng,
|
| 617 |
+
});
|
| 618 |
+
let key = coord_key(node.lat, node.lng);
|
| 619 |
+
network.coord_to_node.insert(key, idx);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
for edge in &cached.edges {
|
| 623 |
+
let from = NodeIndex::new(edge.from);
|
| 624 |
+
let to = NodeIndex::new(edge.to);
|
| 625 |
+
network.graph.add_edge(
|
| 626 |
+
from,
|
| 627 |
+
to,
|
| 628 |
+
EdgeData {
|
| 629 |
+
travel_time_s: edge.travel_time_s,
|
| 630 |
+
distance_m: edge.distance_m,
|
| 631 |
+
geometry: vec![],
|
| 632 |
+
},
|
| 633 |
+
);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
Ok(network)
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/// Saves road network to cache file.
|
| 640 |
+
async fn save_to_cache(&self, path: &Path) -> Result<(), RoutingError> {
|
| 641 |
+
let nodes: Vec<CachedNode> = self
|
| 642 |
+
.graph
|
| 643 |
+
.node_indices()
|
| 644 |
+
.filter_map(|idx| {
|
| 645 |
+
self.graph.node_weight(idx).map(|n| CachedNode {
|
| 646 |
+
lat: n.lat,
|
| 647 |
+
lng: n.lng,
|
| 648 |
+
})
|
| 649 |
+
})
|
| 650 |
+
.collect();
|
| 651 |
+
|
| 652 |
+
let edges: Vec<CachedEdge> = self
|
| 653 |
+
.graph
|
| 654 |
+
.edge_indices()
|
| 655 |
+
.filter_map(|idx| {
|
| 656 |
+
let (from, to) = self.graph.edge_endpoints(idx)?;
|
| 657 |
+
let weight = self.graph.edge_weight(idx)?;
|
| 658 |
+
Some(CachedEdge {
|
| 659 |
+
from: from.index(),
|
| 660 |
+
to: to.index(),
|
| 661 |
+
travel_time_s: weight.travel_time_s,
|
| 662 |
+
distance_m: weight.distance_m,
|
| 663 |
+
})
|
| 664 |
+
})
|
| 665 |
+
.collect();
|
| 666 |
+
|
| 667 |
+
let cached = CachedNetwork {
|
| 668 |
+
version: CACHE_VERSION,
|
| 669 |
+
nodes,
|
| 670 |
+
edges,
|
| 671 |
+
};
|
| 672 |
+
let data = serde_json::to_string(&cached).map_err(|e| RoutingError::Parse(e.to_string()))?;
|
| 673 |
+
tokio::fs::write(path, data).await?;
|
| 674 |
+
|
| 675 |
+
Ok(())
|
| 676 |
+
}
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
impl Default for RoadNetwork {
|
| 680 |
+
fn default() -> Self {
|
| 681 |
+
Self::new()
|
| 682 |
+
}
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
// ============================================================================
|
| 686 |
+
// OSM Data Structures (Overpass API)
|
| 687 |
+
// ============================================================================
|
| 688 |
+
|
| 689 |
+
#[derive(Debug, Deserialize)]
|
| 690 |
+
struct OverpassResponse {
|
| 691 |
+
elements: Vec<OsmElement>,
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
#[derive(Debug, Deserialize)]
|
| 695 |
+
struct OsmElement {
|
| 696 |
+
#[serde(rename = "type")]
|
| 697 |
+
elem_type: String,
|
| 698 |
+
id: i64,
|
| 699 |
+
lat: Option<f64>,
|
| 700 |
+
lon: Option<f64>,
|
| 701 |
+
nodes: Option<Vec<i64>>,
|
| 702 |
+
tags: Option<OsmTags>,
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
#[derive(Debug, Deserialize)]
|
| 706 |
+
struct OsmTags {
|
| 707 |
+
highway: Option<String>,
|
| 708 |
+
oneway: Option<String>,
|
| 709 |
+
/// Maxspeed tag (for future use with dynamic speed calculation).
|
| 710 |
+
#[allow(dead_code)]
|
| 711 |
+
maxspeed: Option<String>,
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
// ============================================================================
|
| 715 |
+
// Cache Data Structures
|
| 716 |
+
// ============================================================================
|
| 717 |
+
|
| 718 |
+
/// Cache format version. Bump this when changing the cache structure.
|
| 719 |
+
const CACHE_VERSION: u32 = 1;
|
| 720 |
+
|
| 721 |
+
#[derive(Debug, Serialize, Deserialize)]
|
| 722 |
+
struct CachedNetwork {
|
| 723 |
+
/// Cache format version for automatic invalidation.
|
| 724 |
+
version: u32,
|
| 725 |
+
nodes: Vec<CachedNode>,
|
| 726 |
+
edges: Vec<CachedEdge>,
|
| 727 |
+
}
|
| 728 |
+
|
| 729 |
+
#[derive(Debug, Serialize, Deserialize)]
|
| 730 |
+
struct CachedNode {
|
| 731 |
+
lat: f64,
|
| 732 |
+
lng: f64,
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
#[derive(Debug, Serialize, Deserialize)]
|
| 736 |
+
struct CachedEdge {
|
| 737 |
+
from: usize,
|
| 738 |
+
to: usize,
|
| 739 |
+
travel_time_s: f64,
|
| 740 |
+
distance_m: f64,
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
// ============================================================================
|
| 744 |
+
// Helper Functions
|
| 745 |
+
// ============================================================================
|
| 746 |
+
|
| 747 |
+
/// Converts coordinates to a hash key (7 decimal places precision).
|
| 748 |
+
fn coord_key(lat: f64, lng: f64) -> (i64, i64) {
|
| 749 |
+
((lat * 1e7).round() as i64, (lng * 1e7).round() as i64)
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
/// Returns speed in m/s for a highway type.
|
| 753 |
+
fn get_speed_for_highway(highway: &str) -> f64 {
|
| 754 |
+
let kmh = match highway {
|
| 755 |
+
"motorway" | "motorway_link" => 100.0,
|
| 756 |
+
"trunk" | "trunk_link" => 80.0,
|
| 757 |
+
"primary" | "primary_link" => 60.0,
|
| 758 |
+
"secondary" | "secondary_link" => 50.0,
|
| 759 |
+
"tertiary" | "tertiary_link" => 40.0,
|
| 760 |
+
"residential" => 30.0,
|
| 761 |
+
"unclassified" => 30.0,
|
| 762 |
+
"service" => 20.0,
|
| 763 |
+
"living_street" => 10.0,
|
| 764 |
+
_ => 30.0,
|
| 765 |
+
};
|
| 766 |
+
kmh * 1000.0 / 3600.0
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
/// Haversine distance between two points in meters.
|
| 770 |
+
fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
|
| 771 |
+
const R: f64 = 6_371_000.0; // Earth radius in meters
|
| 772 |
+
|
| 773 |
+
let lat1_rad = lat1.to_radians();
|
| 774 |
+
let lat2_rad = lat2.to_radians();
|
| 775 |
+
let dlat = (lat2 - lat1).to_radians();
|
| 776 |
+
let dlng = (lng2 - lng1).to_radians();
|
| 777 |
+
|
| 778 |
+
let a = (dlat / 2.0).sin().powi(2)
|
| 779 |
+
+ lat1_rad.cos() * lat2_rad.cos() * (dlng / 2.0).sin().powi(2);
|
| 780 |
+
let c = 2.0 * a.sqrt().asin();
|
| 781 |
+
|
| 782 |
+
R * c
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
#[cfg(test)]
|
| 786 |
+
mod tests {
|
| 787 |
+
use super::*;
|
| 788 |
+
|
| 789 |
+
#[test]
|
| 790 |
+
fn test_haversine_distance() {
|
| 791 |
+
// Philadelphia City Hall to Liberty Bell (~500m)
|
| 792 |
+
let dist = haversine_distance(39.9526, -75.1635, 39.9496, -75.1503);
|
| 793 |
+
assert!((dist - 1200.0).abs() < 100.0); // Approximately 1.2 km
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
#[test]
|
| 797 |
+
fn test_coord_key() {
|
| 798 |
+
let key = coord_key(39.9526, -75.1635);
|
| 799 |
+
assert_eq!(key, (399526000, -751635000));
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
#[test]
|
| 803 |
+
fn test_bbox_expand() {
|
| 804 |
+
let bbox = BoundingBox::new(39.9, -75.2, 40.0, -75.1);
|
| 805 |
+
let expanded = bbox.expand(0.1);
|
| 806 |
+
assert!(expanded.min_lat < bbox.min_lat);
|
| 807 |
+
assert!(expanded.max_lat > bbox.max_lat);
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
#[test]
|
| 811 |
+
fn test_empty_network() {
|
| 812 |
+
let network = RoadNetwork::new();
|
| 813 |
+
assert_eq!(network.node_count(), 0);
|
| 814 |
+
assert_eq!(network.edge_count(), 0);
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
#[test]
|
| 818 |
+
fn test_snap_to_road_empty() {
|
| 819 |
+
let network = RoadNetwork::new();
|
| 820 |
+
assert!(network.snap_to_road(39.95, -75.16).is_none());
|
| 821 |
+
}
|
| 822 |
+
}
|
src/solver.rs
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Solver service for Vehicle Routing Problem.
|
| 2 |
+
//!
|
| 3 |
+
//! Uses Late Acceptance local search with 3-opt moves for efficient route optimization.
|
| 4 |
+
//! Direct score calculation with full solution access (no global state).
|
| 5 |
+
|
| 6 |
+
use parking_lot::RwLock;
|
| 7 |
+
use rand::Rng;
|
| 8 |
+
use solverforge::prelude::*;
|
| 9 |
+
use std::collections::HashMap;
|
| 10 |
+
use std::sync::Arc;
|
| 11 |
+
use std::time::{Duration, Instant};
|
| 12 |
+
use tokio::sync::oneshot;
|
| 13 |
+
use tracing::{debug, info};
|
| 14 |
+
|
| 15 |
+
use crate::console::{self, PhaseTimer};
|
| 16 |
+
use crate::constraints::calculate_score;
|
| 17 |
+
use crate::domain::VehicleRoutePlan;
|
| 18 |
+
|
| 19 |
+
/// Default solving time: 30 seconds.
|
| 20 |
+
const DEFAULT_TIME_LIMIT_SECS: u64 = 30;
|
| 21 |
+
|
| 22 |
+
/// Late acceptance history size.
|
| 23 |
+
const LATE_ACCEPTANCE_SIZE: usize = 400;
|
| 24 |
+
|
| 25 |
+
/// Solver configuration with termination criteria.
|
| 26 |
+
///
|
| 27 |
+
/// Multiple termination conditions combine with OR logic (any triggers termination).
|
| 28 |
+
#[derive(Debug, Clone, Default)]
|
| 29 |
+
pub struct SolverConfig {
|
| 30 |
+
/// Stop after this duration.
|
| 31 |
+
pub time_limit: Option<Duration>,
|
| 32 |
+
/// Stop after this duration without improvement.
|
| 33 |
+
pub unimproved_time_limit: Option<Duration>,
|
| 34 |
+
/// Stop after this many steps.
|
| 35 |
+
pub step_limit: Option<u64>,
|
| 36 |
+
/// Stop after this many steps without improvement.
|
| 37 |
+
pub unimproved_step_limit: Option<u64>,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
impl SolverConfig {
|
| 41 |
+
/// Creates a config with default 30-second time limit.
|
| 42 |
+
pub fn default_config() -> Self {
|
| 43 |
+
Self {
|
| 44 |
+
time_limit: Some(Duration::from_secs(DEFAULT_TIME_LIMIT_SECS)),
|
| 45 |
+
..Default::default()
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/// Checks if any termination condition is met.
|
| 50 |
+
fn should_terminate(
|
| 51 |
+
&self,
|
| 52 |
+
elapsed: Duration,
|
| 53 |
+
steps: u64,
|
| 54 |
+
time_since_improvement: Duration,
|
| 55 |
+
steps_since_improvement: u64,
|
| 56 |
+
) -> bool {
|
| 57 |
+
if let Some(limit) = self.time_limit {
|
| 58 |
+
if elapsed >= limit {
|
| 59 |
+
return true;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
if let Some(limit) = self.unimproved_time_limit {
|
| 63 |
+
if time_since_improvement >= limit {
|
| 64 |
+
return true;
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
if let Some(limit) = self.step_limit {
|
| 68 |
+
if steps >= limit {
|
| 69 |
+
return true;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
if let Some(limit) = self.unimproved_step_limit {
|
| 73 |
+
if steps_since_improvement >= limit {
|
| 74 |
+
return true;
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
false
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/// Status of a solving job.
|
| 82 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
| 83 |
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
| 84 |
+
pub enum SolverStatus {
|
| 85 |
+
/// Not currently solving.
|
| 86 |
+
NotSolving,
|
| 87 |
+
/// Actively solving.
|
| 88 |
+
Solving,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
impl SolverStatus {
|
| 92 |
+
/// Returns the status as a SCREAMING_SNAKE_CASE string for API responses.
|
| 93 |
+
///
|
| 94 |
+
/// ```
|
| 95 |
+
/// use vehicle_routing::solver::SolverStatus;
|
| 96 |
+
///
|
| 97 |
+
/// assert_eq!(SolverStatus::NotSolving.as_str(), "NOT_SOLVING");
|
| 98 |
+
/// assert_eq!(SolverStatus::Solving.as_str(), "SOLVING");
|
| 99 |
+
/// ```
|
| 100 |
+
pub fn as_str(self) -> &'static str {
|
| 101 |
+
match self {
|
| 102 |
+
SolverStatus::NotSolving => "NOT_SOLVING",
|
| 103 |
+
SolverStatus::Solving => "SOLVING",
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/// A solving job with current state.
|
| 109 |
+
pub struct SolveJob {
|
| 110 |
+
/// Unique job identifier.
|
| 111 |
+
pub id: String,
|
| 112 |
+
/// Current status.
|
| 113 |
+
pub status: SolverStatus,
|
| 114 |
+
/// Current best solution.
|
| 115 |
+
pub plan: VehicleRoutePlan,
|
| 116 |
+
/// Solver configuration.
|
| 117 |
+
pub config: SolverConfig,
|
| 118 |
+
/// Stop signal sender.
|
| 119 |
+
stop_signal: Option<oneshot::Sender<()>>,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
impl SolveJob {
|
| 123 |
+
/// Creates a new solve job with default config.
|
| 124 |
+
pub fn new(id: String, plan: VehicleRoutePlan) -> Self {
|
| 125 |
+
Self {
|
| 126 |
+
id,
|
| 127 |
+
status: SolverStatus::NotSolving,
|
| 128 |
+
plan,
|
| 129 |
+
config: SolverConfig::default_config(),
|
| 130 |
+
stop_signal: None,
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/// Creates a new solve job with custom config.
|
| 135 |
+
pub fn with_config(id: String, plan: VehicleRoutePlan, config: SolverConfig) -> Self {
|
| 136 |
+
Self {
|
| 137 |
+
id,
|
| 138 |
+
status: SolverStatus::NotSolving,
|
| 139 |
+
plan,
|
| 140 |
+
config,
|
| 141 |
+
stop_signal: None,
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/// Manages VRP solving jobs.
|
| 147 |
+
///
|
| 148 |
+
/// # Examples
|
| 149 |
+
///
|
| 150 |
+
/// ```
|
| 151 |
+
/// use vehicle_routing::solver::SolverService;
|
| 152 |
+
/// use vehicle_routing::demo_data::generate_philadelphia;
|
| 153 |
+
///
|
| 154 |
+
/// let service = SolverService::new();
|
| 155 |
+
/// let plan = generate_philadelphia();
|
| 156 |
+
///
|
| 157 |
+
/// // Create a job (doesn't start solving yet)
|
| 158 |
+
/// let job = service.create_job("test-1".to_string(), plan);
|
| 159 |
+
/// assert_eq!(job.read().status, vehicle_routing::solver::SolverStatus::NotSolving);
|
| 160 |
+
/// ```
|
| 161 |
+
pub struct SolverService {
|
| 162 |
+
jobs: RwLock<HashMap<String, Arc<RwLock<SolveJob>>>>,
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
impl SolverService {
|
| 166 |
+
/// Creates a new solver service.
|
| 167 |
+
pub fn new() -> Self {
|
| 168 |
+
Self {
|
| 169 |
+
jobs: RwLock::new(HashMap::new()),
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/// Creates a new job for the given plan with default config.
|
| 174 |
+
pub fn create_job(&self, id: String, plan: VehicleRoutePlan) -> Arc<RwLock<SolveJob>> {
|
| 175 |
+
let job = Arc::new(RwLock::new(SolveJob::new(id.clone(), plan)));
|
| 176 |
+
self.jobs.write().insert(id, job.clone());
|
| 177 |
+
job
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/// Creates a new job with custom config.
|
| 181 |
+
pub fn create_job_with_config(
|
| 182 |
+
&self,
|
| 183 |
+
id: String,
|
| 184 |
+
plan: VehicleRoutePlan,
|
| 185 |
+
config: SolverConfig,
|
| 186 |
+
) -> Arc<RwLock<SolveJob>> {
|
| 187 |
+
let job = Arc::new(RwLock::new(SolveJob::with_config(id.clone(), plan, config)));
|
| 188 |
+
self.jobs.write().insert(id, job.clone());
|
| 189 |
+
job
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/// Gets a job by ID.
|
| 193 |
+
pub fn get_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> {
|
| 194 |
+
self.jobs.read().get(id).cloned()
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/// Lists all job IDs.
|
| 198 |
+
pub fn list_jobs(&self) -> Vec<String> {
|
| 199 |
+
self.jobs.read().keys().cloned().collect()
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/// Removes a job by ID.
|
| 203 |
+
pub fn remove_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> {
|
| 204 |
+
self.jobs.write().remove(id)
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/// Starts solving a job in the background.
|
| 208 |
+
pub fn start_solving(&self, job: Arc<RwLock<SolveJob>>) {
|
| 209 |
+
let (tx, rx) = oneshot::channel();
|
| 210 |
+
let config = job.read().config.clone();
|
| 211 |
+
|
| 212 |
+
{
|
| 213 |
+
let mut job_guard = job.write();
|
| 214 |
+
job_guard.status = SolverStatus::Solving;
|
| 215 |
+
job_guard.stop_signal = Some(tx);
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
let job_clone = job.clone();
|
| 219 |
+
|
| 220 |
+
tokio::task::spawn_blocking(move || {
|
| 221 |
+
solve_blocking(job_clone, rx, config);
|
| 222 |
+
});
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/// Stops a solving job.
|
| 226 |
+
pub fn stop_solving(&self, id: &str) -> bool {
|
| 227 |
+
if let Some(job) = self.get_job(id) {
|
| 228 |
+
let mut job_guard = job.write();
|
| 229 |
+
if let Some(stop_signal) = job_guard.stop_signal.take() {
|
| 230 |
+
let _ = stop_signal.send(());
|
| 231 |
+
job_guard.status = SolverStatus::NotSolving;
|
| 232 |
+
return true;
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
false
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
impl Default for SolverService {
|
| 240 |
+
fn default() -> Self {
|
| 241 |
+
Self::new()
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
/// Runs the solver in a blocking context.
|
| 246 |
+
fn solve_blocking(
|
| 247 |
+
job: Arc<RwLock<SolveJob>>,
|
| 248 |
+
mut stop_rx: oneshot::Receiver<()>,
|
| 249 |
+
config: SolverConfig,
|
| 250 |
+
) {
|
| 251 |
+
let mut solution = job.read().plan.clone();
|
| 252 |
+
let job_id = job.read().id.clone();
|
| 253 |
+
let solve_start = Instant::now();
|
| 254 |
+
|
| 255 |
+
// Print problem configuration
|
| 256 |
+
console::print_config(
|
| 257 |
+
solution.vehicles.len(),
|
| 258 |
+
solution.visits.len(),
|
| 259 |
+
solution.locations.len(),
|
| 260 |
+
);
|
| 261 |
+
|
| 262 |
+
info!(
|
| 263 |
+
job_id = %job_id,
|
| 264 |
+
visits = solution.visits.len(),
|
| 265 |
+
vehicles = solution.vehicles.len(),
|
| 266 |
+
"Starting VRP solver"
|
| 267 |
+
);
|
| 268 |
+
|
| 269 |
+
// Phase 1: Construction heuristic (round-robin)
|
| 270 |
+
let mut ch_timer = PhaseTimer::start("ConstructionHeuristic", 0);
|
| 271 |
+
let mut current_score = construction_heuristic(&mut solution, &mut ch_timer);
|
| 272 |
+
ch_timer.finish();
|
| 273 |
+
|
| 274 |
+
// Print solving started after construction
|
| 275 |
+
console::print_solving_started(
|
| 276 |
+
solve_start.elapsed().as_millis() as u64,
|
| 277 |
+
¤t_score.to_string(),
|
| 278 |
+
solution.visits.len(),
|
| 279 |
+
solution.visits.len(),
|
| 280 |
+
solution.vehicles.len(),
|
| 281 |
+
);
|
| 282 |
+
|
| 283 |
+
// Update job with constructed solution
|
| 284 |
+
update_job(&job, &solution, current_score);
|
| 285 |
+
|
| 286 |
+
// Phase 2: Late Acceptance local search with 3-opt
|
| 287 |
+
let n_vehicles = solution.vehicles.len();
|
| 288 |
+
if n_vehicles == 0 {
|
| 289 |
+
info!("No vehicles to optimize");
|
| 290 |
+
console::print_solving_ended(
|
| 291 |
+
solve_start.elapsed(),
|
| 292 |
+
0,
|
| 293 |
+
1,
|
| 294 |
+
¤t_score.to_string(),
|
| 295 |
+
current_score.is_feasible(),
|
| 296 |
+
);
|
| 297 |
+
finish_job(&job, &solution, current_score);
|
| 298 |
+
return;
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
let mut ls_timer = PhaseTimer::start("LateAcceptance", 1);
|
| 302 |
+
let mut late_scores = vec![current_score; LATE_ACCEPTANCE_SIZE];
|
| 303 |
+
let mut step: u64 = 0;
|
| 304 |
+
let mut rng = rand::thread_rng();
|
| 305 |
+
|
| 306 |
+
// Track best score and improvement times
|
| 307 |
+
let mut best_score = current_score;
|
| 308 |
+
let mut last_improvement_time = solve_start;
|
| 309 |
+
let mut last_improvement_step: u64 = 0;
|
| 310 |
+
|
| 311 |
+
loop {
|
| 312 |
+
// Check termination conditions
|
| 313 |
+
let elapsed = solve_start.elapsed();
|
| 314 |
+
let time_since_improvement = last_improvement_time.elapsed();
|
| 315 |
+
let steps_since_improvement = step - last_improvement_step;
|
| 316 |
+
|
| 317 |
+
if config.should_terminate(elapsed, step, time_since_improvement, steps_since_improvement) {
|
| 318 |
+
debug!("Termination condition met");
|
| 319 |
+
break;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// Check for stop signal
|
| 323 |
+
if stop_rx.try_recv().is_ok() {
|
| 324 |
+
info!("Solving terminated early by user");
|
| 325 |
+
break;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Alternate between list-change and 2-opt moves
|
| 329 |
+
let accepted = if step % 3 == 0 {
|
| 330 |
+
// 2-opt move (intra-route segment reversal)
|
| 331 |
+
try_two_opt_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer)
|
| 332 |
+
} else {
|
| 333 |
+
// List-change move (visit relocation)
|
| 334 |
+
try_list_change_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer)
|
| 335 |
+
};
|
| 336 |
+
|
| 337 |
+
if accepted {
|
| 338 |
+
// Update late acceptance history
|
| 339 |
+
let late_idx = (step as usize) % LATE_ACCEPTANCE_SIZE;
|
| 340 |
+
late_scores[late_idx] = current_score;
|
| 341 |
+
|
| 342 |
+
// Track improvements
|
| 343 |
+
if current_score > best_score {
|
| 344 |
+
best_score = current_score;
|
| 345 |
+
last_improvement_time = Instant::now();
|
| 346 |
+
last_improvement_step = step;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Periodic update
|
| 350 |
+
if ls_timer.steps_accepted().is_multiple_of(1000) {
|
| 351 |
+
update_job(&job, &solution, current_score);
|
| 352 |
+
debug!(
|
| 353 |
+
step,
|
| 354 |
+
moves_accepted = ls_timer.steps_accepted(),
|
| 355 |
+
score = %current_score,
|
| 356 |
+
elapsed_secs = solve_start.elapsed().as_secs(),
|
| 357 |
+
"Progress update"
|
| 358 |
+
);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
// Periodic console progress (every 10000 moves)
|
| 362 |
+
if ls_timer.moves_evaluated().is_multiple_of(10000) {
|
| 363 |
+
console::print_step_progress(
|
| 364 |
+
ls_timer.steps_accepted(),
|
| 365 |
+
ls_timer.elapsed(),
|
| 366 |
+
ls_timer.moves_evaluated(),
|
| 367 |
+
¤t_score.to_string(),
|
| 368 |
+
);
|
| 369 |
+
}
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
step += 1;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
ls_timer.finish();
|
| 376 |
+
|
| 377 |
+
let total_duration = solve_start.elapsed();
|
| 378 |
+
let total_moves = step;
|
| 379 |
+
|
| 380 |
+
info!(
|
| 381 |
+
job_id = %job_id,
|
| 382 |
+
duration_secs = total_duration.as_secs_f64(),
|
| 383 |
+
steps = step,
|
| 384 |
+
score = %current_score,
|
| 385 |
+
feasible = current_score.is_feasible(),
|
| 386 |
+
"Solving complete"
|
| 387 |
+
);
|
| 388 |
+
|
| 389 |
+
console::print_solving_ended(
|
| 390 |
+
total_duration,
|
| 391 |
+
total_moves,
|
| 392 |
+
2,
|
| 393 |
+
¤t_score.to_string(),
|
| 394 |
+
current_score.is_feasible(),
|
| 395 |
+
);
|
| 396 |
+
|
| 397 |
+
finish_job(&job, &solution, current_score);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/// Construction heuristic: round-robin visit assignment.
|
| 401 |
+
///
|
| 402 |
+
/// Skips construction if all visits are already assigned (continue mode).
|
| 403 |
+
fn construction_heuristic(solution: &mut VehicleRoutePlan, timer: &mut PhaseTimer) -> HardSoftScore {
|
| 404 |
+
let n_visits = solution.visits.len();
|
| 405 |
+
let n_vehicles = solution.vehicles.len();
|
| 406 |
+
|
| 407 |
+
if n_vehicles == 0 || n_visits == 0 {
|
| 408 |
+
return calculate_score(solution);
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
// Count already-assigned visits
|
| 412 |
+
let assigned_count: usize = solution.vehicles.iter().map(|v| v.visits.len()).sum();
|
| 413 |
+
|
| 414 |
+
// If all visits already assigned, skip construction (continue mode)
|
| 415 |
+
if assigned_count == n_visits {
|
| 416 |
+
info!("All visits already assigned, skipping construction heuristic");
|
| 417 |
+
return calculate_score(solution);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// Build set of already-assigned visits
|
| 421 |
+
let assigned: std::collections::HashSet<usize> = solution
|
| 422 |
+
.vehicles
|
| 423 |
+
.iter()
|
| 424 |
+
.flat_map(|v| v.visits.iter().copied())
|
| 425 |
+
.collect();
|
| 426 |
+
|
| 427 |
+
// Round-robin assignment for unassigned visits only
|
| 428 |
+
let mut vehicle_idx = 0;
|
| 429 |
+
for visit_idx in 0..n_visits {
|
| 430 |
+
if assigned.contains(&visit_idx) {
|
| 431 |
+
continue;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
timer.record_move();
|
| 435 |
+
solution.vehicles[vehicle_idx].visits.push(visit_idx);
|
| 436 |
+
|
| 437 |
+
let score = calculate_score(solution);
|
| 438 |
+
timer.record_accepted(&score.to_string());
|
| 439 |
+
|
| 440 |
+
vehicle_idx = (vehicle_idx + 1) % n_vehicles;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
calculate_score(solution)
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
/// Tries a list-change (visit relocation) move.
|
| 447 |
+
/// Returns true if the move was accepted.
|
| 448 |
+
fn try_list_change_move<R: Rng>(
|
| 449 |
+
solution: &mut VehicleRoutePlan,
|
| 450 |
+
current_score: &mut HardSoftScore,
|
| 451 |
+
late_scores: &[HardSoftScore],
|
| 452 |
+
step: u64,
|
| 453 |
+
rng: &mut R,
|
| 454 |
+
timer: &mut PhaseTimer,
|
| 455 |
+
) -> bool {
|
| 456 |
+
let n_vehicles = solution.vehicles.len();
|
| 457 |
+
|
| 458 |
+
// Find a non-empty source vehicle
|
| 459 |
+
let non_empty: Vec<usize> = solution
|
| 460 |
+
.vehicles
|
| 461 |
+
.iter()
|
| 462 |
+
.enumerate()
|
| 463 |
+
.filter(|(_, v)| !v.visits.is_empty())
|
| 464 |
+
.map(|(i, _)| i)
|
| 465 |
+
.collect();
|
| 466 |
+
|
| 467 |
+
if non_empty.is_empty() {
|
| 468 |
+
return false;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
let src_vehicle = non_empty[rng.gen_range(0..non_empty.len())];
|
| 472 |
+
let src_len = solution.vehicles[src_vehicle].visits.len();
|
| 473 |
+
let src_pos = rng.gen_range(0..src_len);
|
| 474 |
+
|
| 475 |
+
// Pick destination vehicle and position
|
| 476 |
+
let dst_vehicle = rng.gen_range(0..n_vehicles);
|
| 477 |
+
let dst_len = solution.vehicles[dst_vehicle].visits.len();
|
| 478 |
+
|
| 479 |
+
// Valid insertion position
|
| 480 |
+
let max_pos = if src_vehicle == dst_vehicle {
|
| 481 |
+
src_len
|
| 482 |
+
} else {
|
| 483 |
+
dst_len + 1
|
| 484 |
+
};
|
| 485 |
+
|
| 486 |
+
if max_pos == 0 {
|
| 487 |
+
return false;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
let dst_pos = rng.gen_range(0..max_pos);
|
| 491 |
+
|
| 492 |
+
// Skip no-op moves
|
| 493 |
+
if src_vehicle == dst_vehicle {
|
| 494 |
+
let effective_dst = if dst_pos > src_pos { dst_pos - 1 } else { dst_pos };
|
| 495 |
+
if src_pos == effective_dst {
|
| 496 |
+
return false;
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
timer.record_move();
|
| 501 |
+
|
| 502 |
+
// Apply move
|
| 503 |
+
let visit_idx = solution.vehicles[src_vehicle].visits.remove(src_pos);
|
| 504 |
+
let adjusted_dst = if src_vehicle == dst_vehicle && dst_pos > src_pos {
|
| 505 |
+
dst_pos - 1
|
| 506 |
+
} else {
|
| 507 |
+
dst_pos
|
| 508 |
+
};
|
| 509 |
+
solution.vehicles[dst_vehicle].visits.insert(adjusted_dst, visit_idx);
|
| 510 |
+
|
| 511 |
+
// Evaluate
|
| 512 |
+
let new_score = calculate_score(solution);
|
| 513 |
+
let late_idx = (step as usize) % late_scores.len();
|
| 514 |
+
let late_score = late_scores[late_idx];
|
| 515 |
+
|
| 516 |
+
if new_score >= *current_score || new_score >= late_score {
|
| 517 |
+
// Accept
|
| 518 |
+
timer.record_accepted(&new_score.to_string());
|
| 519 |
+
*current_score = new_score;
|
| 520 |
+
true
|
| 521 |
+
} else {
|
| 522 |
+
// Reject - undo
|
| 523 |
+
solution.vehicles[dst_vehicle].visits.remove(adjusted_dst);
|
| 524 |
+
solution.vehicles[src_vehicle].visits.insert(src_pos, visit_idx);
|
| 525 |
+
false
|
| 526 |
+
}
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
/// Tries a 2-opt move (reverse a segment within a route).
|
| 530 |
+
/// Returns true if the move was accepted.
|
| 531 |
+
fn try_two_opt_move<R: Rng>(
|
| 532 |
+
solution: &mut VehicleRoutePlan,
|
| 533 |
+
current_score: &mut HardSoftScore,
|
| 534 |
+
late_scores: &[HardSoftScore],
|
| 535 |
+
step: u64,
|
| 536 |
+
rng: &mut R,
|
| 537 |
+
timer: &mut PhaseTimer,
|
| 538 |
+
) -> bool {
|
| 539 |
+
// Find a vehicle with at least 2 visits
|
| 540 |
+
let eligible: Vec<usize> = solution
|
| 541 |
+
.vehicles
|
| 542 |
+
.iter()
|
| 543 |
+
.enumerate()
|
| 544 |
+
.filter(|(_, v)| v.visits.len() >= 2)
|
| 545 |
+
.map(|(i, _)| i)
|
| 546 |
+
.collect();
|
| 547 |
+
|
| 548 |
+
if eligible.is_empty() {
|
| 549 |
+
return false;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
let vehicle_idx = eligible[rng.gen_range(0..eligible.len())];
|
| 553 |
+
let route_len = solution.vehicles[vehicle_idx].visits.len();
|
| 554 |
+
|
| 555 |
+
// Pick two cut points for 2-opt
|
| 556 |
+
let i = rng.gen_range(0..route_len);
|
| 557 |
+
let j = rng.gen_range(0..route_len);
|
| 558 |
+
|
| 559 |
+
if i == j {
|
| 560 |
+
return false;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
let (start, end) = if i < j { (i, j) } else { (j, i) };
|
| 564 |
+
|
| 565 |
+
// Need at least 2 elements to reverse
|
| 566 |
+
if end - start < 1 {
|
| 567 |
+
return false;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
timer.record_move();
|
| 571 |
+
|
| 572 |
+
// Apply 2-opt: reverse segment [start, end]
|
| 573 |
+
solution.vehicles[vehicle_idx].visits[start..=end].reverse();
|
| 574 |
+
|
| 575 |
+
// Evaluate
|
| 576 |
+
let new_score = calculate_score(solution);
|
| 577 |
+
let late_idx = (step as usize) % late_scores.len();
|
| 578 |
+
let late_score = late_scores[late_idx];
|
| 579 |
+
|
| 580 |
+
if new_score >= *current_score || new_score >= late_score {
|
| 581 |
+
// Accept
|
| 582 |
+
timer.record_accepted(&new_score.to_string());
|
| 583 |
+
*current_score = new_score;
|
| 584 |
+
true
|
| 585 |
+
} else {
|
| 586 |
+
// Reject - undo (reverse again)
|
| 587 |
+
solution.vehicles[vehicle_idx].visits[start..=end].reverse();
|
| 588 |
+
false
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
/// Updates job with current solution.
|
| 593 |
+
fn update_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) {
|
| 594 |
+
let mut job_guard = job.write();
|
| 595 |
+
job_guard.plan = solution.clone();
|
| 596 |
+
job_guard.plan.score = Some(score);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
/// Finishes job and sets status.
|
| 600 |
+
fn finish_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) {
|
| 601 |
+
let mut job_guard = job.write();
|
| 602 |
+
job_guard.plan = solution.clone();
|
| 603 |
+
job_guard.plan.score = Some(score);
|
| 604 |
+
job_guard.status = SolverStatus::NotSolving;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
#[cfg(test)]
|
| 608 |
+
mod tests {
|
| 609 |
+
use super::*;
|
| 610 |
+
use crate::demo_data::generate_philadelphia;
|
| 611 |
+
|
| 612 |
+
#[test]
|
| 613 |
+
fn test_construction_heuristic() {
|
| 614 |
+
let mut plan = generate_philadelphia();
|
| 615 |
+
|
| 616 |
+
// Create a timer but don't print (we're in a test)
|
| 617 |
+
let mut timer = PhaseTimer::start("ConstructionHeuristic", 0);
|
| 618 |
+
let score = construction_heuristic(&mut plan, &mut timer);
|
| 619 |
+
|
| 620 |
+
// All visits should be assigned
|
| 621 |
+
let total_visits: usize = plan.vehicles.iter().map(|v| v.visits.len()).sum();
|
| 622 |
+
assert_eq!(total_visits, 49); // Philadelphia has 49 visits
|
| 623 |
+
assert!(score.hard() <= 0); // May have some violations
|
| 624 |
+
}
|
| 625 |
+
}
|
static/app.js
ADDED
|
@@ -0,0 +1,1627 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let autoRefreshIntervalId = null;
|
| 2 |
+
let initialized = false;
|
| 3 |
+
let optimizing = false;
|
| 4 |
+
let demoDataId = null;
|
| 5 |
+
let scheduleId = null;
|
| 6 |
+
let loadedRoutePlan = null;
|
| 7 |
+
let newVisit = null;
|
| 8 |
+
let visitMarker = null;
|
| 9 |
+
let routeGeometries = null; // Cache for encoded polyline geometries
|
| 10 |
+
let useRealRoads = true; // Routing mode toggle state (default: real roads)
|
| 11 |
+
const solveButton = $("#solveButton");
|
| 12 |
+
const stopSolvingButton = $("#stopSolvingButton");
|
| 13 |
+
const vehiclesTable = $("#vehicles");
|
| 14 |
+
const analyzeButton = $("#analyzeButton");
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Decode an encoded polyline string into an array of [lat, lng] coordinates.
|
| 18 |
+
* This is the Google polyline encoding algorithm.
|
| 19 |
+
* @param {string} encoded - The encoded polyline string
|
| 20 |
+
* @returns {Array<Array<number>>} Array of [lat, lng] coordinate pairs
|
| 21 |
+
*/
|
| 22 |
+
function decodePolyline(encoded) {
|
| 23 |
+
if (!encoded) return [];
|
| 24 |
+
|
| 25 |
+
const points = [];
|
| 26 |
+
let index = 0;
|
| 27 |
+
let lat = 0;
|
| 28 |
+
let lng = 0;
|
| 29 |
+
|
| 30 |
+
while (index < encoded.length) {
|
| 31 |
+
// Decode latitude
|
| 32 |
+
let shift = 0;
|
| 33 |
+
let result = 0;
|
| 34 |
+
let byte;
|
| 35 |
+
do {
|
| 36 |
+
byte = encoded.charCodeAt(index++) - 63;
|
| 37 |
+
result |= (byte & 0x1f) << shift;
|
| 38 |
+
shift += 5;
|
| 39 |
+
} while (byte >= 0x20);
|
| 40 |
+
const dlat = (result & 1) ? ~(result >> 1) : (result >> 1);
|
| 41 |
+
lat += dlat;
|
| 42 |
+
|
| 43 |
+
// Decode longitude
|
| 44 |
+
shift = 0;
|
| 45 |
+
result = 0;
|
| 46 |
+
do {
|
| 47 |
+
byte = encoded.charCodeAt(index++) - 63;
|
| 48 |
+
result |= (byte & 0x1f) << shift;
|
| 49 |
+
shift += 5;
|
| 50 |
+
} while (byte >= 0x20);
|
| 51 |
+
const dlng = (result & 1) ? ~(result >> 1) : (result >> 1);
|
| 52 |
+
lng += dlng;
|
| 53 |
+
|
| 54 |
+
// Polyline encoding uses precision of 5 decimal places
|
| 55 |
+
points.push([lat / 1e5, lng / 1e5]);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
return points;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/**
|
| 62 |
+
* Fetch route geometries for the current schedule from the backend.
|
| 63 |
+
* @returns {Promise<Object|null>} The geometries object or null if unavailable
|
| 64 |
+
*/
|
| 65 |
+
async function fetchRouteGeometries() {
|
| 66 |
+
if (!scheduleId) return null;
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const response = await fetch(`/route-plans/${scheduleId}/geometry`);
|
| 70 |
+
if (response.ok) {
|
| 71 |
+
const data = await response.json();
|
| 72 |
+
// Transform segments array into map: { vehicleId: [polyline] }
|
| 73 |
+
const geometries = {};
|
| 74 |
+
for (const segment of data.segments || []) {
|
| 75 |
+
const vehicleId = String(segment.vehicle_idx);
|
| 76 |
+
if (!geometries[vehicleId]) {
|
| 77 |
+
geometries[vehicleId] = [];
|
| 78 |
+
}
|
| 79 |
+
geometries[vehicleId].push(segment.polyline);
|
| 80 |
+
}
|
| 81 |
+
return Object.keys(geometries).length > 0 ? geometries : null;
|
| 82 |
+
}
|
| 83 |
+
} catch (e) {
|
| 84 |
+
console.warn('Could not fetch route geometries:', e);
|
| 85 |
+
}
|
| 86 |
+
return null;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/*************************************** Loading Overlay Functions **************************************/
|
| 90 |
+
|
| 91 |
+
function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") {
|
| 92 |
+
$("#loadingTitle").text(title);
|
| 93 |
+
$("#loadingMessage").text(message);
|
| 94 |
+
$("#loadingProgress").css("width", "0%");
|
| 95 |
+
$("#loadingDetail").text("");
|
| 96 |
+
$("#loadingOverlay").removeClass("hidden");
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function hideLoadingOverlay() {
|
| 100 |
+
$("#loadingOverlay").addClass("hidden");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function updateLoadingProgress(message, percent, detail = "") {
|
| 104 |
+
$("#loadingMessage").text(message);
|
| 105 |
+
$("#loadingProgress").css("width", `${percent}%`);
|
| 106 |
+
$("#loadingDetail").text(detail);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Load demo data with progress updates via Server-Sent Events.
|
| 111 |
+
* Used when Real Roads mode is enabled.
|
| 112 |
+
*/
|
| 113 |
+
function loadDemoDataWithProgress(demoId) {
|
| 114 |
+
return new Promise((resolve, reject) => {
|
| 115 |
+
const routingMode = useRealRoads ? "real_roads" : "haversine";
|
| 116 |
+
const url = `/demo-data/${demoId}/stream?routing=${routingMode}`;
|
| 117 |
+
|
| 118 |
+
showLoadingOverlay(
|
| 119 |
+
useRealRoads ? "Loading Real Road Data" : "Loading Demo Data",
|
| 120 |
+
"Connecting..."
|
| 121 |
+
);
|
| 122 |
+
|
| 123 |
+
const eventSource = new EventSource(url);
|
| 124 |
+
let solution = null;
|
| 125 |
+
|
| 126 |
+
eventSource.onmessage = function(event) {
|
| 127 |
+
try {
|
| 128 |
+
const data = JSON.parse(event.data);
|
| 129 |
+
|
| 130 |
+
if (data.event === "progress") {
|
| 131 |
+
let statusIcon = "";
|
| 132 |
+
if (data.phase === "network") {
|
| 133 |
+
statusIcon = '<i class="fas fa-download me-2"></i>';
|
| 134 |
+
} else if (data.phase === "routes") {
|
| 135 |
+
statusIcon = '<i class="fas fa-route me-2"></i>';
|
| 136 |
+
} else if (data.phase === "complete") {
|
| 137 |
+
statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>';
|
| 138 |
+
}
|
| 139 |
+
updateLoadingProgress(data.message, data.percent, data.detail || "");
|
| 140 |
+
} else if (data.event === "complete") {
|
| 141 |
+
solution = data.solution;
|
| 142 |
+
// Store geometries from the response if available
|
| 143 |
+
if (data.geometries) {
|
| 144 |
+
routeGeometries = data.geometries;
|
| 145 |
+
}
|
| 146 |
+
eventSource.close();
|
| 147 |
+
hideLoadingOverlay();
|
| 148 |
+
resolve(solution);
|
| 149 |
+
} else if (data.event === "error") {
|
| 150 |
+
eventSource.close();
|
| 151 |
+
hideLoadingOverlay();
|
| 152 |
+
reject(new Error(data.message));
|
| 153 |
+
}
|
| 154 |
+
} catch (e) {
|
| 155 |
+
console.error("Error parsing SSE event:", e);
|
| 156 |
+
}
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
eventSource.onerror = function(error) {
|
| 160 |
+
eventSource.close();
|
| 161 |
+
hideLoadingOverlay();
|
| 162 |
+
reject(new Error("Connection lost while loading data"));
|
| 163 |
+
};
|
| 164 |
+
});
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/*************************************** Map constants and variable definitions **************************************/
|
| 168 |
+
|
| 169 |
+
const homeLocationMarkerByIdMap = new Map();
|
| 170 |
+
const visitMarkerByIdMap = new Map();
|
| 171 |
+
|
| 172 |
+
const map = L.map("map", { doubleClickZoom: false }).setView(
|
| 173 |
+
[51.505, -0.09],
|
| 174 |
+
13,
|
| 175 |
+
);
|
| 176 |
+
const visitGroup = L.layerGroup().addTo(map);
|
| 177 |
+
const homeLocationGroup = L.layerGroup().addTo(map);
|
| 178 |
+
const routeGroup = L.layerGroup().addTo(map);
|
| 179 |
+
|
| 180 |
+
/************************************ Time line constants and variable definitions ************************************/
|
| 181 |
+
|
| 182 |
+
let byVehicleTimeline;
|
| 183 |
+
let byVisitTimeline;
|
| 184 |
+
const byVehicleGroupData = new vis.DataSet();
|
| 185 |
+
const byVehicleItemData = new vis.DataSet();
|
| 186 |
+
const byVisitGroupData = new vis.DataSet();
|
| 187 |
+
const byVisitItemData = new vis.DataSet();
|
| 188 |
+
|
| 189 |
+
const byVehicleTimelineOptions = {
|
| 190 |
+
timeAxis: { scale: "hour" },
|
| 191 |
+
orientation: { axis: "top" },
|
| 192 |
+
xss: { disabled: true }, // Items are XSS safe through JQuery
|
| 193 |
+
stack: false,
|
| 194 |
+
stackSubgroups: false,
|
| 195 |
+
zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
|
| 196 |
+
zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
const byVisitTimelineOptions = {
|
| 200 |
+
timeAxis: { scale: "hour" },
|
| 201 |
+
orientation: { axis: "top" },
|
| 202 |
+
verticalScroll: true,
|
| 203 |
+
xss: { disabled: true }, // Items are XSS safe through JQuery
|
| 204 |
+
stack: false,
|
| 205 |
+
stackSubgroups: false,
|
| 206 |
+
zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
|
| 207 |
+
zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
/************************************ Initialize ************************************/
|
| 211 |
+
|
| 212 |
+
// Vehicle management state
|
| 213 |
+
let addingVehicleMode = false;
|
| 214 |
+
let pickingVehicleLocation = false;
|
| 215 |
+
let tempVehicleMarker = null;
|
| 216 |
+
let vehicleDeparturePicker = null;
|
| 217 |
+
|
| 218 |
+
// Route highlighting state
|
| 219 |
+
let highlightedVehicleId = null;
|
| 220 |
+
let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
$(document).ready(function () {
|
| 224 |
+
replaceQuickstartSolverForgeAutoHeaderFooter();
|
| 225 |
+
|
| 226 |
+
// Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered
|
| 227 |
+
setTimeout(function () {
|
| 228 |
+
const byVehiclePanel = document.getElementById("byVehiclePanel");
|
| 229 |
+
const byVisitPanel = document.getElementById("byVisitPanel");
|
| 230 |
+
|
| 231 |
+
if (byVehiclePanel) {
|
| 232 |
+
byVehicleTimeline = new vis.Timeline(
|
| 233 |
+
byVehiclePanel,
|
| 234 |
+
byVehicleItemData,
|
| 235 |
+
byVehicleGroupData,
|
| 236 |
+
byVehicleTimelineOptions,
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
if (byVisitPanel) {
|
| 241 |
+
byVisitTimeline = new vis.Timeline(
|
| 242 |
+
byVisitPanel,
|
| 243 |
+
byVisitItemData,
|
| 244 |
+
byVisitGroupData,
|
| 245 |
+
byVisitTimelineOptions,
|
| 246 |
+
);
|
| 247 |
+
}
|
| 248 |
+
}, 100);
|
| 249 |
+
|
| 250 |
+
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
| 251 |
+
maxZoom: 19,
|
| 252 |
+
attribution:
|
| 253 |
+
'© <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
|
| 254 |
+
}).addTo(map);
|
| 255 |
+
|
| 256 |
+
solveButton.click(solve);
|
| 257 |
+
stopSolvingButton.click(stopSolving);
|
| 258 |
+
analyzeButton.click(analyze);
|
| 259 |
+
refreshSolvingButtons(false);
|
| 260 |
+
|
| 261 |
+
// HACK to allow vis-timeline to work within Bootstrap tabs
|
| 262 |
+
$("#byVehicleTab").on("shown.bs.tab", function (event) {
|
| 263 |
+
if (byVehicleTimeline) {
|
| 264 |
+
byVehicleTimeline.redraw();
|
| 265 |
+
}
|
| 266 |
+
});
|
| 267 |
+
$("#byVisitTab").on("shown.bs.tab", function (event) {
|
| 268 |
+
if (byVisitTimeline) {
|
| 269 |
+
byVisitTimeline.redraw();
|
| 270 |
+
}
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
// Map click handler - context aware
|
| 274 |
+
map.on("click", function (e) {
|
| 275 |
+
if (addingVehicleMode) {
|
| 276 |
+
// Set vehicle home location
|
| 277 |
+
setVehicleHomeLocation(e.latlng.lat, e.latlng.lng);
|
| 278 |
+
} else if (!optimizing) {
|
| 279 |
+
// Add new visit
|
| 280 |
+
visitMarker = L.circleMarker(e.latlng);
|
| 281 |
+
visitMarker.setStyle({ color: "green" });
|
| 282 |
+
visitMarker.addTo(map);
|
| 283 |
+
openRecommendationModal(e.latlng.lat, e.latlng.lng);
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
// Remove visit marker when modal closes
|
| 288 |
+
$("#newVisitModal").on("hidden.bs.modal", function () {
|
| 289 |
+
if (visitMarker) {
|
| 290 |
+
map.removeLayer(visitMarker);
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
+
// Vehicle management
|
| 295 |
+
$("#addVehicleBtn").click(openAddVehicleModal);
|
| 296 |
+
$("#removeVehicleBtn").click(removeLastVehicle);
|
| 297 |
+
$("#confirmAddVehicle").click(confirmAddVehicle);
|
| 298 |
+
$("#pickLocationBtn").click(pickVehicleLocationOnMap);
|
| 299 |
+
|
| 300 |
+
// Clean up when add vehicle modal closes (only if not picking location)
|
| 301 |
+
$("#addVehicleModal").on("hidden.bs.modal", function () {
|
| 302 |
+
if (!pickingVehicleLocation) {
|
| 303 |
+
addingVehicleMode = false;
|
| 304 |
+
if (tempVehicleMarker) {
|
| 305 |
+
map.removeLayer(tempVehicleMarker);
|
| 306 |
+
tempVehicleMarker = null;
|
| 307 |
+
}
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// Real Roads toggle handler
|
| 312 |
+
$(document).on('change', '#realRoadRouting', function() {
|
| 313 |
+
useRealRoads = $(this).is(':checked');
|
| 314 |
+
|
| 315 |
+
// If we have a demo dataset loaded, reload it with the new routing mode
|
| 316 |
+
if (demoDataId && !optimizing) {
|
| 317 |
+
scheduleId = null;
|
| 318 |
+
initialized = false;
|
| 319 |
+
homeLocationGroup.clearLayers();
|
| 320 |
+
homeLocationMarkerByIdMap.clear();
|
| 321 |
+
visitGroup.clearLayers();
|
| 322 |
+
visitMarkerByIdMap.clear();
|
| 323 |
+
routeGeometries = null;
|
| 324 |
+
refreshRoutePlan();
|
| 325 |
+
}
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
setupAjax();
|
| 329 |
+
fetchDemoData();
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
/*************************************** Vehicle Management **************************************/
|
| 333 |
+
|
| 334 |
+
function openAddVehicleModal() {
|
| 335 |
+
if (optimizing) {
|
| 336 |
+
alert("Cannot add vehicles while solving. Please stop solving first.");
|
| 337 |
+
return;
|
| 338 |
+
}
|
| 339 |
+
if (!loadedRoutePlan) {
|
| 340 |
+
alert("Please load a dataset first.");
|
| 341 |
+
return;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
addingVehicleMode = true;
|
| 345 |
+
|
| 346 |
+
// Suggest next vehicle name
|
| 347 |
+
$("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`);
|
| 348 |
+
|
| 349 |
+
// Set default values based on existing vehicles
|
| 350 |
+
const existingVehicle = loadedRoutePlan.vehicles[0];
|
| 351 |
+
if (existingVehicle) {
|
| 352 |
+
$("#vehicleCapacity").val(existingVehicle.capacity || 25);
|
| 353 |
+
const defaultLat = existingVehicle.homeLocation[0];
|
| 354 |
+
const defaultLng = existingVehicle.homeLocation[1];
|
| 355 |
+
$("#vehicleHomeLat").val(defaultLat.toFixed(6));
|
| 356 |
+
$("#vehicleHomeLng").val(defaultLng.toFixed(6));
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// Initialize departure time picker
|
| 360 |
+
const tomorrow = JSJoda.LocalDate.now().plusDays(1);
|
| 361 |
+
const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0));
|
| 362 |
+
|
| 363 |
+
if (vehicleDeparturePicker) {
|
| 364 |
+
vehicleDeparturePicker.destroy();
|
| 365 |
+
}
|
| 366 |
+
vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", {
|
| 367 |
+
enableTime: true,
|
| 368 |
+
dateFormat: "Y-m-d H:i",
|
| 369 |
+
defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
|
| 370 |
+
});
|
| 371 |
+
|
| 372 |
+
$("#addVehicleModal").modal("show");
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
function pickVehicleLocationOnMap() {
|
| 376 |
+
// Hide modal temporarily while user picks location
|
| 377 |
+
pickingVehicleLocation = true;
|
| 378 |
+
addingVehicleMode = true;
|
| 379 |
+
$("#addVehicleModal").modal("hide");
|
| 380 |
+
|
| 381 |
+
// Show hint on map
|
| 382 |
+
$("#mapHint").html('<i class="fas fa-crosshairs"></i> Click on the map to set vehicle depot location').removeClass("hidden");
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
function setVehicleHomeLocation(lat, lng) {
|
| 386 |
+
$("#vehicleHomeLat").val(lat.toFixed(6));
|
| 387 |
+
$("#vehicleHomeLng").val(lng.toFixed(6));
|
| 388 |
+
$("#vehicleLocationPreview").html(`<i class="fas fa-check text-success"></i> Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
|
| 389 |
+
|
| 390 |
+
// Show temporary marker
|
| 391 |
+
if (tempVehicleMarker) {
|
| 392 |
+
map.removeLayer(tempVehicleMarker);
|
| 393 |
+
}
|
| 394 |
+
tempVehicleMarker = L.marker([lat, lng], {
|
| 395 |
+
icon: L.divIcon({
|
| 396 |
+
className: 'temp-vehicle-marker',
|
| 397 |
+
html: `<div style="
|
| 398 |
+
background-color: #6366f1;
|
| 399 |
+
border: 3px solid white;
|
| 400 |
+
border-radius: 4px;
|
| 401 |
+
width: 28px;
|
| 402 |
+
height: 28px;
|
| 403 |
+
display: flex;
|
| 404 |
+
align-items: center;
|
| 405 |
+
justify-content: center;
|
| 406 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
| 407 |
+
animation: pulse 1s infinite;
|
| 408 |
+
"><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`,
|
| 409 |
+
iconSize: [28, 28],
|
| 410 |
+
iconAnchor: [14, 14]
|
| 411 |
+
})
|
| 412 |
+
});
|
| 413 |
+
tempVehicleMarker.addTo(map);
|
| 414 |
+
|
| 415 |
+
// If we were picking location, re-open the modal
|
| 416 |
+
if (pickingVehicleLocation) {
|
| 417 |
+
pickingVehicleLocation = false;
|
| 418 |
+
addingVehicleMode = false;
|
| 419 |
+
$("#addVehicleModal").modal("show");
|
| 420 |
+
// Restore normal map hint
|
| 421 |
+
$("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit');
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// Extended phonetic alphabet for generating vehicle names
|
| 426 |
+
const PHONETIC_NAMES = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
|
| 427 |
+
|
| 428 |
+
function getNextVehicleName() {
|
| 429 |
+
if (!loadedRoutePlan) return "Alpha";
|
| 430 |
+
const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name));
|
| 431 |
+
for (const name of PHONETIC_NAMES) {
|
| 432 |
+
if (!usedNames.has(name)) return name;
|
| 433 |
+
}
|
| 434 |
+
// Fallback if all names used
|
| 435 |
+
return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
async function confirmAddVehicle() {
|
| 439 |
+
const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
|
| 440 |
+
const capacity = parseInt($("#vehicleCapacity").val());
|
| 441 |
+
const lat = parseFloat($("#vehicleHomeLat").val());
|
| 442 |
+
const lng = parseFloat($("#vehicleHomeLng").val());
|
| 443 |
+
const departureTime = $("#vehicleDepartureTime").val();
|
| 444 |
+
|
| 445 |
+
if (!capacity || capacity < 1) {
|
| 446 |
+
alert("Please enter a valid capacity (minimum 1).");
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
if (isNaN(lat) || isNaN(lng)) {
|
| 450 |
+
alert("Please set a valid home location by clicking on the map or entering coordinates.");
|
| 451 |
+
return;
|
| 452 |
+
}
|
| 453 |
+
if (!departureTime) {
|
| 454 |
+
alert("Please set a departure time.");
|
| 455 |
+
return;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
// Generate new vehicle ID
|
| 459 |
+
const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0);
|
| 460 |
+
const newId = String(maxId + 1);
|
| 461 |
+
|
| 462 |
+
// Format departure time
|
| 463 |
+
const formattedDeparture = JSJoda.LocalDateTime.parse(
|
| 464 |
+
departureTime,
|
| 465 |
+
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
|
| 466 |
+
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
| 467 |
+
|
| 468 |
+
// Create new vehicle
|
| 469 |
+
const newVehicle = {
|
| 470 |
+
id: newId,
|
| 471 |
+
name: vehicleName,
|
| 472 |
+
capacity: capacity,
|
| 473 |
+
homeLocation: [lat, lng],
|
| 474 |
+
departureTime: formattedDeparture,
|
| 475 |
+
visits: [],
|
| 476 |
+
totalDemand: 0,
|
| 477 |
+
totalDrivingTimeSeconds: 0,
|
| 478 |
+
arrivalTime: formattedDeparture
|
| 479 |
+
};
|
| 480 |
+
|
| 481 |
+
// Add to solution
|
| 482 |
+
loadedRoutePlan.vehicles.push(newVehicle);
|
| 483 |
+
|
| 484 |
+
// Close modal and refresh
|
| 485 |
+
$("#addVehicleModal").modal("hide");
|
| 486 |
+
addingVehicleMode = false;
|
| 487 |
+
|
| 488 |
+
if (tempVehicleMarker) {
|
| 489 |
+
map.removeLayer(tempVehicleMarker);
|
| 490 |
+
tempVehicleMarker = null;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// Refresh display
|
| 494 |
+
await renderRoutes(loadedRoutePlan);
|
| 495 |
+
renderTimelines(loadedRoutePlan);
|
| 496 |
+
|
| 497 |
+
showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
async function removeLastVehicle() {
|
| 501 |
+
if (optimizing) {
|
| 502 |
+
alert("Cannot remove vehicles while solving. Please stop solving first.");
|
| 503 |
+
return;
|
| 504 |
+
}
|
| 505 |
+
if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) {
|
| 506 |
+
alert("Cannot remove the last vehicle. At least one vehicle is required.");
|
| 507 |
+
return;
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1];
|
| 511 |
+
|
| 512 |
+
if (lastVehicle.visits && lastVehicle.visits.length > 0) {
|
| 513 |
+
if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
|
| 514 |
+
return;
|
| 515 |
+
}
|
| 516 |
+
// Unassign visits from the vehicle
|
| 517 |
+
lastVehicle.visits.forEach(visitId => {
|
| 518 |
+
const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
|
| 519 |
+
if (visit) {
|
| 520 |
+
visit.vehicle = null;
|
| 521 |
+
visit.previousVisit = null;
|
| 522 |
+
visit.nextVisit = null;
|
| 523 |
+
visit.arrivalTime = null;
|
| 524 |
+
visit.departureTime = null;
|
| 525 |
+
}
|
| 526 |
+
});
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// Remove vehicle
|
| 530 |
+
loadedRoutePlan.vehicles.pop();
|
| 531 |
+
|
| 532 |
+
// Remove marker
|
| 533 |
+
const marker = homeLocationMarkerByIdMap.get(lastVehicle.id);
|
| 534 |
+
if (marker) {
|
| 535 |
+
homeLocationGroup.removeLayer(marker);
|
| 536 |
+
homeLocationMarkerByIdMap.delete(lastVehicle.id);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
// Refresh display
|
| 540 |
+
await renderRoutes(loadedRoutePlan);
|
| 541 |
+
renderTimelines(loadedRoutePlan);
|
| 542 |
+
|
| 543 |
+
showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
async function removeVehicle(vehicleId) {
|
| 547 |
+
if (optimizing) {
|
| 548 |
+
alert("Cannot remove vehicles while solving. Please stop solving first.");
|
| 549 |
+
return;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId);
|
| 553 |
+
if (vehicleIndex === -1) return;
|
| 554 |
+
|
| 555 |
+
if (loadedRoutePlan.vehicles.length <= 1) {
|
| 556 |
+
alert("Cannot remove the last vehicle. At least one vehicle is required.");
|
| 557 |
+
return;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const vehicle = loadedRoutePlan.vehicles[vehicleIndex];
|
| 561 |
+
|
| 562 |
+
if (vehicle.visits && vehicle.visits.length > 0) {
|
| 563 |
+
if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
|
| 564 |
+
return;
|
| 565 |
+
}
|
| 566 |
+
// Unassign visits
|
| 567 |
+
vehicle.visits.forEach(visitId => {
|
| 568 |
+
const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
|
| 569 |
+
if (visit) {
|
| 570 |
+
visit.vehicle = null;
|
| 571 |
+
visit.previousVisit = null;
|
| 572 |
+
visit.nextVisit = null;
|
| 573 |
+
visit.arrivalTime = null;
|
| 574 |
+
visit.departureTime = null;
|
| 575 |
+
}
|
| 576 |
+
});
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// Remove vehicle
|
| 580 |
+
loadedRoutePlan.vehicles.splice(vehicleIndex, 1);
|
| 581 |
+
|
| 582 |
+
// Remove marker
|
| 583 |
+
const marker = homeLocationMarkerByIdMap.get(vehicleId);
|
| 584 |
+
if (marker) {
|
| 585 |
+
homeLocationGroup.removeLayer(marker);
|
| 586 |
+
homeLocationMarkerByIdMap.delete(vehicleId);
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
// Refresh display
|
| 590 |
+
await renderRoutes(loadedRoutePlan);
|
| 591 |
+
renderTimelines(loadedRoutePlan);
|
| 592 |
+
|
| 593 |
+
showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
function showNotification(message, type = "info") {
|
| 597 |
+
const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info";
|
| 598 |
+
const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle";
|
| 599 |
+
|
| 600 |
+
const notification = $(`
|
| 601 |
+
<div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;">
|
| 602 |
+
<i class="fas ${icon} me-2"></i>${message}
|
| 603 |
+
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
| 604 |
+
</div>
|
| 605 |
+
`);
|
| 606 |
+
|
| 607 |
+
$("#notificationPanel").append(notification);
|
| 608 |
+
|
| 609 |
+
// Auto-dismiss after 3 seconds
|
| 610 |
+
setTimeout(() => {
|
| 611 |
+
notification.alert('close');
|
| 612 |
+
}, 3000);
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
/*************************************** Route Highlighting **************************************/
|
| 616 |
+
|
| 617 |
+
function toggleVehicleHighlight(vehicleId) {
|
| 618 |
+
if (highlightedVehicleId === vehicleId) {
|
| 619 |
+
// Already highlighted - clear it
|
| 620 |
+
clearRouteHighlight();
|
| 621 |
+
} else {
|
| 622 |
+
// Highlight this vehicle's route
|
| 623 |
+
highlightVehicleRoute(vehicleId);
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function clearRouteHighlight() {
|
| 628 |
+
// Remove number markers
|
| 629 |
+
routeNumberMarkers.forEach(marker => map.removeLayer(marker));
|
| 630 |
+
routeNumberMarkers = [];
|
| 631 |
+
|
| 632 |
+
// Reset all vehicle icons to normal and restore opacity
|
| 633 |
+
if (loadedRoutePlan) {
|
| 634 |
+
loadedRoutePlan.vehicles.forEach(vehicle => {
|
| 635 |
+
const marker = homeLocationMarkerByIdMap.get(vehicle.id);
|
| 636 |
+
if (marker) {
|
| 637 |
+
marker.setIcon(createVehicleHomeIcon(vehicle, false));
|
| 638 |
+
marker.setOpacity(1);
|
| 639 |
+
}
|
| 640 |
+
});
|
| 641 |
+
|
| 642 |
+
// Reset all visit markers to normal and restore opacity
|
| 643 |
+
loadedRoutePlan.visits.forEach(visit => {
|
| 644 |
+
const marker = visitMarkerByIdMap.get(visit.id);
|
| 645 |
+
if (marker) {
|
| 646 |
+
const customerType = getCustomerType(visit);
|
| 647 |
+
const isAssigned = visit.vehicle != null;
|
| 648 |
+
marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false));
|
| 649 |
+
marker.setOpacity(1);
|
| 650 |
+
}
|
| 651 |
+
});
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
// Reset route lines
|
| 655 |
+
renderRouteLines();
|
| 656 |
+
|
| 657 |
+
// Update vehicle table highlighting
|
| 658 |
+
$("#vehicles tr").removeClass("table-active");
|
| 659 |
+
|
| 660 |
+
highlightedVehicleId = null;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
function highlightVehicleRoute(vehicleId) {
|
| 664 |
+
// Clear any existing highlight first
|
| 665 |
+
clearRouteHighlight();
|
| 666 |
+
|
| 667 |
+
highlightedVehicleId = vehicleId;
|
| 668 |
+
|
| 669 |
+
if (!loadedRoutePlan) return;
|
| 670 |
+
|
| 671 |
+
const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId);
|
| 672 |
+
if (!vehicle) return;
|
| 673 |
+
|
| 674 |
+
const vehicleColor = colorByVehicle(vehicle);
|
| 675 |
+
|
| 676 |
+
// Highlight the vehicle's home marker
|
| 677 |
+
const homeMarker = homeLocationMarkerByIdMap.get(vehicleId);
|
| 678 |
+
if (homeMarker) {
|
| 679 |
+
homeMarker.setIcon(createVehicleHomeIcon(vehicle, true));
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
// Dim other vehicles
|
| 683 |
+
loadedRoutePlan.vehicles.forEach(v => {
|
| 684 |
+
if (v.id !== vehicleId) {
|
| 685 |
+
const marker = homeLocationMarkerByIdMap.get(v.id);
|
| 686 |
+
if (marker) {
|
| 687 |
+
marker.setIcon(createVehicleHomeIcon(v, false));
|
| 688 |
+
marker.setOpacity(0.3);
|
| 689 |
+
}
|
| 690 |
+
}
|
| 691 |
+
});
|
| 692 |
+
|
| 693 |
+
// Get visit order for this vehicle
|
| 694 |
+
const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v]));
|
| 695 |
+
const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v);
|
| 696 |
+
|
| 697 |
+
// Highlight and number the visits on this route
|
| 698 |
+
let stopNumber = 1;
|
| 699 |
+
vehicleVisits.forEach(visit => {
|
| 700 |
+
const marker = visitMarkerByIdMap.get(visit.id);
|
| 701 |
+
if (marker) {
|
| 702 |
+
const customerType = getCustomerType(visit);
|
| 703 |
+
marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor));
|
| 704 |
+
marker.setOpacity(1);
|
| 705 |
+
|
| 706 |
+
// Add number marker
|
| 707 |
+
const numberMarker = L.marker(visit.location, {
|
| 708 |
+
icon: createRouteNumberIcon(stopNumber, vehicleColor),
|
| 709 |
+
interactive: false,
|
| 710 |
+
zIndexOffset: 1000
|
| 711 |
+
});
|
| 712 |
+
numberMarker.addTo(map);
|
| 713 |
+
routeNumberMarkers.push(numberMarker);
|
| 714 |
+
stopNumber++;
|
| 715 |
+
}
|
| 716 |
+
});
|
| 717 |
+
|
| 718 |
+
// Dim visits not on this route
|
| 719 |
+
loadedRoutePlan.visits.forEach(visit => {
|
| 720 |
+
if (!vehicle.visits.includes(visit.id)) {
|
| 721 |
+
const marker = visitMarkerByIdMap.get(visit.id);
|
| 722 |
+
if (marker) {
|
| 723 |
+
marker.setOpacity(0.25);
|
| 724 |
+
}
|
| 725 |
+
}
|
| 726 |
+
});
|
| 727 |
+
|
| 728 |
+
// Highlight just this route, dim others
|
| 729 |
+
renderRouteLines(vehicleId);
|
| 730 |
+
|
| 731 |
+
// Highlight the row in the vehicle table
|
| 732 |
+
$("#vehicles tr").removeClass("table-active");
|
| 733 |
+
$(`#vehicle-row-${vehicleId}`).addClass("table-active");
|
| 734 |
+
|
| 735 |
+
// Add start marker (S) at depot
|
| 736 |
+
const startMarker = L.marker(vehicle.homeLocation, {
|
| 737 |
+
icon: createRouteNumberIcon("S", vehicleColor),
|
| 738 |
+
interactive: false,
|
| 739 |
+
zIndexOffset: 1000
|
| 740 |
+
});
|
| 741 |
+
startMarker.addTo(map);
|
| 742 |
+
routeNumberMarkers.push(startMarker);
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
function createRouteNumberIcon(number, color) {
|
| 746 |
+
return L.divIcon({
|
| 747 |
+
className: 'route-number-marker',
|
| 748 |
+
html: `<div style="
|
| 749 |
+
background-color: ${color};
|
| 750 |
+
color: white;
|
| 751 |
+
font-weight: bold;
|
| 752 |
+
font-size: 12px;
|
| 753 |
+
width: 22px;
|
| 754 |
+
height: 22px;
|
| 755 |
+
border-radius: 50%;
|
| 756 |
+
border: 2px solid white;
|
| 757 |
+
display: flex;
|
| 758 |
+
align-items: center;
|
| 759 |
+
justify-content: center;
|
| 760 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.4);
|
| 761 |
+
margin-left: 16px;
|
| 762 |
+
margin-top: -28px;
|
| 763 |
+
">${number}</div>`,
|
| 764 |
+
iconSize: [22, 22],
|
| 765 |
+
iconAnchor: [0, 0]
|
| 766 |
+
});
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
async function renderRouteLines(highlightedId = null) {
|
| 770 |
+
routeGroup.clearLayers();
|
| 771 |
+
|
| 772 |
+
if (!loadedRoutePlan) return;
|
| 773 |
+
|
| 774 |
+
// Fetch geometries during solving (routes change)
|
| 775 |
+
if (scheduleId) {
|
| 776 |
+
routeGeometries = await fetchRouteGeometries();
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
|
| 780 |
+
|
| 781 |
+
for (let vehicle of loadedRoutePlan.vehicles) {
|
| 782 |
+
const homeLocation = vehicle.homeLocation;
|
| 783 |
+
const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l);
|
| 784 |
+
|
| 785 |
+
const isHighlighted = highlightedId === null || vehicle.id === highlightedId;
|
| 786 |
+
const color = colorByVehicle(vehicle);
|
| 787 |
+
const weight = isHighlighted && highlightedId !== null ? 5 : 3;
|
| 788 |
+
const opacity = isHighlighted ? 1 : 0.2;
|
| 789 |
+
|
| 790 |
+
const vehicleGeometry = routeGeometries?.[vehicle.id];
|
| 791 |
+
|
| 792 |
+
if (vehicleGeometry && vehicleGeometry.length > 0) {
|
| 793 |
+
// Draw real road routes using decoded polylines
|
| 794 |
+
for (const encodedSegment of vehicleGeometry) {
|
| 795 |
+
if (encodedSegment) {
|
| 796 |
+
const points = decodePolyline(encodedSegment);
|
| 797 |
+
if (points.length > 0) {
|
| 798 |
+
L.polyline(points, {
|
| 799 |
+
color: color,
|
| 800 |
+
weight: weight,
|
| 801 |
+
opacity: opacity
|
| 802 |
+
}).addTo(routeGroup);
|
| 803 |
+
}
|
| 804 |
+
}
|
| 805 |
+
}
|
| 806 |
+
} else if (locations.length > 0) {
|
| 807 |
+
// Fallback to straight lines if no geometry available
|
| 808 |
+
L.polyline([homeLocation, ...locations, homeLocation], {
|
| 809 |
+
color: color,
|
| 810 |
+
weight: weight,
|
| 811 |
+
opacity: opacity
|
| 812 |
+
}).addTo(routeGroup);
|
| 813 |
+
}
|
| 814 |
+
}
|
| 815 |
+
}
|
| 816 |
+
|
| 817 |
+
function colorByVehicle(vehicle) {
|
| 818 |
+
return vehicle === null ? null : pickColor("vehicle" + vehicle.id);
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
// Customer type definitions matching demo_data.py
|
| 822 |
+
const CUSTOMER_TYPES = {
|
| 823 |
+
RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 },
|
| 824 |
+
BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 },
|
| 825 |
+
RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 },
|
| 826 |
+
};
|
| 827 |
+
|
| 828 |
+
function getCustomerType(visit) {
|
| 829 |
+
const startTime = showTimeOnly(visit.minStartTime).toString();
|
| 830 |
+
const endTime = showTimeOnly(visit.maxEndTime).toString();
|
| 831 |
+
|
| 832 |
+
for (const [type, config] of Object.entries(CUSTOMER_TYPES)) {
|
| 833 |
+
if (startTime === config.windowStart && endTime === config.windowEnd) {
|
| 834 |
+
return { type, ...config };
|
| 835 |
+
}
|
| 836 |
+
}
|
| 837 |
+
return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime };
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
function formatDrivingTime(drivingTimeInSeconds) {
|
| 841 |
+
return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;
|
| 842 |
+
}
|
| 843 |
+
|
| 844 |
+
function homeLocationPopupContent(vehicle) {
|
| 845 |
+
const color = colorByVehicle(vehicle);
|
| 846 |
+
const visitCount = vehicle.visits ? vehicle.visits.length : 0;
|
| 847 |
+
const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
|
| 848 |
+
return `<div style="min-width: 150px;">
|
| 849 |
+
<h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5>
|
| 850 |
+
<p class="mb-1"><strong>Depot Location</strong></p>
|
| 851 |
+
<p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p>
|
| 852 |
+
<p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p>
|
| 853 |
+
<p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p>
|
| 854 |
+
</div>`;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
function visitPopupContent(visit) {
|
| 858 |
+
const customerType = getCustomerType(visit);
|
| 859 |
+
const serviceDurationMinutes = Math.round(visit.serviceDuration / 60);
|
| 860 |
+
const arrival = visit.arrivalTime
|
| 861 |
+
? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>`
|
| 862 |
+
: "";
|
| 863 |
+
return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5>
|
| 864 |
+
<h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6>
|
| 865 |
+
<h6>Cargo: ${visit.demand} units</h6>
|
| 866 |
+
<h6>Service time: ${serviceDurationMinutes} min</h6>
|
| 867 |
+
<h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6>
|
| 868 |
+
${arrival}`;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
function showTimeOnly(localDateTimeString) {
|
| 872 |
+
return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
function createVehicleHomeIcon(vehicle, isHighlighted = false) {
|
| 876 |
+
const color = colorByVehicle(vehicle);
|
| 877 |
+
const size = isHighlighted ? 36 : 28;
|
| 878 |
+
const fontSize = isHighlighted ? 14 : 11;
|
| 879 |
+
const borderWidth = isHighlighted ? 4 : 3;
|
| 880 |
+
const shadow = isHighlighted
|
| 881 |
+
? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)`
|
| 882 |
+
: '0 2px 4px rgba(0,0,0,0.4)';
|
| 883 |
+
|
| 884 |
+
return L.divIcon({
|
| 885 |
+
className: 'vehicle-home-marker',
|
| 886 |
+
html: `<div style="
|
| 887 |
+
background-color: ${color};
|
| 888 |
+
border: ${borderWidth}px solid white;
|
| 889 |
+
border-radius: 50%;
|
| 890 |
+
width: ${size}px;
|
| 891 |
+
height: ${size}px;
|
| 892 |
+
display: flex;
|
| 893 |
+
align-items: center;
|
| 894 |
+
justify-content: center;
|
| 895 |
+
box-shadow: ${shadow};
|
| 896 |
+
transition: all 0.2s ease;
|
| 897 |
+
"><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`,
|
| 898 |
+
iconSize: [size, size],
|
| 899 |
+
iconAnchor: [size/2, size/2],
|
| 900 |
+
popupAnchor: [0, -size/2]
|
| 901 |
+
});
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
function getHomeLocationMarker(vehicle) {
|
| 905 |
+
let marker = homeLocationMarkerByIdMap.get(vehicle.id);
|
| 906 |
+
if (marker) {
|
| 907 |
+
marker.setIcon(createVehicleHomeIcon(vehicle));
|
| 908 |
+
return marker;
|
| 909 |
+
}
|
| 910 |
+
marker = L.marker(vehicle.homeLocation, {
|
| 911 |
+
icon: createVehicleHomeIcon(vehicle)
|
| 912 |
+
});
|
| 913 |
+
marker.addTo(homeLocationGroup).bindPopup();
|
| 914 |
+
homeLocationMarkerByIdMap.set(vehicle.id, marker);
|
| 915 |
+
return marker;
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) {
|
| 919 |
+
const borderColor = isHighlighted && highlightColor
|
| 920 |
+
? highlightColor
|
| 921 |
+
: (isAssigned ? customerType.color : '#6b7280');
|
| 922 |
+
const size = isHighlighted ? 38 : 32;
|
| 923 |
+
const fontSize = isHighlighted ? 16 : 14;
|
| 924 |
+
const borderWidth = isHighlighted ? 4 : 3;
|
| 925 |
+
const shadow = isHighlighted
|
| 926 |
+
? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)`
|
| 927 |
+
: '0 2px 4px rgba(0,0,0,0.3)';
|
| 928 |
+
|
| 929 |
+
return L.divIcon({
|
| 930 |
+
className: 'customer-marker',
|
| 931 |
+
html: `<div style="
|
| 932 |
+
background-color: white;
|
| 933 |
+
border: ${borderWidth}px solid ${borderColor};
|
| 934 |
+
border-radius: 50%;
|
| 935 |
+
width: ${size}px;
|
| 936 |
+
height: ${size}px;
|
| 937 |
+
display: flex;
|
| 938 |
+
align-items: center;
|
| 939 |
+
justify-content: center;
|
| 940 |
+
box-shadow: ${shadow};
|
| 941 |
+
transition: all 0.2s ease;
|
| 942 |
+
"><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`,
|
| 943 |
+
iconSize: [size, size],
|
| 944 |
+
iconAnchor: [size/2, size/2],
|
| 945 |
+
popupAnchor: [0, -size/2]
|
| 946 |
+
});
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
function getVisitMarker(visit) {
|
| 950 |
+
let marker = visitMarkerByIdMap.get(visit.id);
|
| 951 |
+
const customerType = getCustomerType(visit);
|
| 952 |
+
const isAssigned = visit.vehicle != null;
|
| 953 |
+
|
| 954 |
+
if (marker) {
|
| 955 |
+
// Update icon if assignment status changed
|
| 956 |
+
marker.setIcon(createCustomerTypeIcon(customerType, isAssigned));
|
| 957 |
+
return marker;
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
marker = L.marker(visit.location, {
|
| 961 |
+
icon: createCustomerTypeIcon(customerType, isAssigned)
|
| 962 |
+
});
|
| 963 |
+
marker.addTo(visitGroup).bindPopup();
|
| 964 |
+
visitMarkerByIdMap.set(visit.id, marker);
|
| 965 |
+
return marker;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
async function renderRoutes(solution) {
|
| 969 |
+
if (!initialized) {
|
| 970 |
+
const bounds = [solution.southWestCorner, solution.northEastCorner];
|
| 971 |
+
map.fitBounds(bounds);
|
| 972 |
+
}
|
| 973 |
+
// Vehicles
|
| 974 |
+
vehiclesTable.children().remove();
|
| 975 |
+
const canRemove = solution.vehicles.length > 1;
|
| 976 |
+
solution.vehicles.forEach(function (vehicle) {
|
| 977 |
+
getHomeLocationMarker(vehicle).setPopupContent(
|
| 978 |
+
homeLocationPopupContent(vehicle),
|
| 979 |
+
);
|
| 980 |
+
const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle;
|
| 981 |
+
const percentage = Math.min((totalDemand / capacity) * 100, 100);
|
| 982 |
+
const overCapacity = totalDemand > capacity;
|
| 983 |
+
const color = colorByVehicle(vehicle);
|
| 984 |
+
const progressBarColor = overCapacity ? 'bg-danger' : '';
|
| 985 |
+
const isHighlighted = highlightedVehicleId === id;
|
| 986 |
+
const visitCount = vehicle.visits ? vehicle.visits.length : 0;
|
| 987 |
+
const vehicleName = vehicle.name || `Vehicle ${id}`;
|
| 988 |
+
|
| 989 |
+
vehiclesTable.append(`
|
| 990 |
+
<tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;">
|
| 991 |
+
<td onclick="toggleVehicleHighlight('${id}')">
|
| 992 |
+
<div style="background-color: ${color}; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; ${isHighlighted ? 'box-shadow: 0 0 0 3px ' + color + '40;' : ''}">
|
| 993 |
+
<i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i>
|
| 994 |
+
</div>
|
| 995 |
+
</td>
|
| 996 |
+
<td onclick="toggleVehicleHighlight('${id}')">
|
| 997 |
+
<strong>${vehicleName}</strong>
|
| 998 |
+
<br><small class="text-muted">${visitCount} stops</small>
|
| 999 |
+
</td>
|
| 1000 |
+
<td onclick="toggleVehicleHighlight('${id}')">
|
| 1001 |
+
<div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left"
|
| 1002 |
+
title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}">
|
| 1003 |
+
<div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;">
|
| 1004 |
+
${totalDemand}/${capacity}
|
| 1005 |
+
</div>
|
| 1006 |
+
</div>
|
| 1007 |
+
</td>
|
| 1008 |
+
<td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;">
|
| 1009 |
+
${formatDrivingTime(totalDrivingTimeSeconds)}
|
| 1010 |
+
</td>
|
| 1011 |
+
<td>
|
| 1012 |
+
${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}">
|
| 1013 |
+
<i class="fas fa-times" style="font-size: 0.7rem;"></i>
|
| 1014 |
+
</button>` : ''}
|
| 1015 |
+
</td>
|
| 1016 |
+
</tr>`);
|
| 1017 |
+
});
|
| 1018 |
+
// Visits
|
| 1019 |
+
solution.visits.forEach(function (visit) {
|
| 1020 |
+
getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
|
| 1021 |
+
});
|
| 1022 |
+
// Route - use the dedicated function which handles highlighting (await to ensure geometries load)
|
| 1023 |
+
await renderRouteLines(highlightedVehicleId);
|
| 1024 |
+
|
| 1025 |
+
// Summary
|
| 1026 |
+
$("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
|
| 1027 |
+
$("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds));
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
function renderTimelines(routePlan) {
|
| 1031 |
+
byVehicleGroupData.clear();
|
| 1032 |
+
byVisitGroupData.clear();
|
| 1033 |
+
byVehicleItemData.clear();
|
| 1034 |
+
byVisitItemData.clear();
|
| 1035 |
+
|
| 1036 |
+
// Build lookup maps for O(1) access
|
| 1037 |
+
const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));
|
| 1038 |
+
const visitById = new Map(routePlan.visits.map(v => [v.id, v]));
|
| 1039 |
+
const visitOrderMap = new Map();
|
| 1040 |
+
|
| 1041 |
+
// Build stop order for each visit
|
| 1042 |
+
routePlan.vehicles.forEach(vehicle => {
|
| 1043 |
+
vehicle.visits.forEach((visitId, index) => {
|
| 1044 |
+
visitOrderMap.set(visitId, index + 1);
|
| 1045 |
+
});
|
| 1046 |
+
});
|
| 1047 |
+
|
| 1048 |
+
// Vehicle groups with names and status summary
|
| 1049 |
+
$.each(routePlan.vehicles, function (index, vehicle) {
|
| 1050 |
+
const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
|
| 1051 |
+
const { totalDemand, capacity } = vehicle;
|
| 1052 |
+
const percentage = Math.min((totalDemand / capacity) * 100, 100);
|
| 1053 |
+
const overCapacity = totalDemand > capacity;
|
| 1054 |
+
|
| 1055 |
+
// Count late visits for this vehicle
|
| 1056 |
+
const vehicleVisits = vehicle.visits.map(id => visitById.get(id)).filter(v => v);
|
| 1057 |
+
const lateCount = vehicleVisits.filter(v => {
|
| 1058 |
+
if (!v.departureTime) return false;
|
| 1059 |
+
const departure = JSJoda.LocalDateTime.parse(v.departureTime);
|
| 1060 |
+
const maxEnd = JSJoda.LocalDateTime.parse(v.maxEndTime);
|
| 1061 |
+
return departure.isAfter(maxEnd);
|
| 1062 |
+
}).length;
|
| 1063 |
+
|
| 1064 |
+
const statusIcon = lateCount > 0
|
| 1065 |
+
? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="${lateCount} late"></i>`
|
| 1066 |
+
: vehicle.visits.length > 0
|
| 1067 |
+
? `<i class="fas fa-check-circle timeline-status-ontime timeline-status-icon" title="All on-time"></i>`
|
| 1068 |
+
: '';
|
| 1069 |
+
|
| 1070 |
+
const progressBarClass = overCapacity ? 'bg-danger' : '';
|
| 1071 |
+
|
| 1072 |
+
const vehicleWithLoad = `
|
| 1073 |
+
<h5 class="card-title mb-1">${vehicleName}${statusIcon}</h5>
|
| 1074 |
+
<div class="progress" style="height: 16px;" title="Cargo: ${totalDemand} / ${capacity}">
|
| 1075 |
+
<div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${percentage}%">
|
| 1076 |
+
${totalDemand}/${capacity}
|
| 1077 |
+
</div>
|
| 1078 |
+
</div>`;
|
| 1079 |
+
byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad });
|
| 1080 |
+
});
|
| 1081 |
+
|
| 1082 |
+
$.each(routePlan.visits, function (index, visit) {
|
| 1083 |
+
const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);
|
| 1084 |
+
const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);
|
| 1085 |
+
const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);
|
| 1086 |
+
const customerType = getCustomerType(visit);
|
| 1087 |
+
const stopNumber = visitOrderMap.get(visit.id);
|
| 1088 |
+
|
| 1089 |
+
const visitGroupElement = $(`<div/>`).append(
|
| 1090 |
+
$(`<h5 class="card-title mb-1"/>`).html(
|
| 1091 |
+
`<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`
|
| 1092 |
+
),
|
| 1093 |
+
).append(
|
| 1094 |
+
$(`<small class="text-muted"/>`).text(customerType.label)
|
| 1095 |
+
);
|
| 1096 |
+
byVisitGroupData.add({
|
| 1097 |
+
id: visit.id,
|
| 1098 |
+
content: visitGroupElement.html(),
|
| 1099 |
+
});
|
| 1100 |
+
|
| 1101 |
+
// Time window per visit.
|
| 1102 |
+
byVisitItemData.add({
|
| 1103 |
+
id: visit.id + "_readyToDue",
|
| 1104 |
+
group: visit.id,
|
| 1105 |
+
start: visit.minStartTime,
|
| 1106 |
+
end: visit.maxEndTime,
|
| 1107 |
+
type: "background",
|
| 1108 |
+
style: "background-color: #8AE23433",
|
| 1109 |
+
});
|
| 1110 |
+
|
| 1111 |
+
if (visit.vehicle == null) {
|
| 1112 |
+
const byJobJobElement = $(`<div/>`).append(
|
| 1113 |
+
$(`<span/>`).html(`<i class="fas fa-exclamation-circle text-danger me-1"></i>Unassigned`),
|
| 1114 |
+
);
|
| 1115 |
+
|
| 1116 |
+
// Unassigned are shown at the beginning of the visit's time window; the length is the service duration.
|
| 1117 |
+
byVisitItemData.add({
|
| 1118 |
+
id: visit.id + "_unassigned",
|
| 1119 |
+
group: visit.id,
|
| 1120 |
+
content: byJobJobElement.html(),
|
| 1121 |
+
start: minStartTime.toString(),
|
| 1122 |
+
end: minStartTime.plus(serviceDuration).toString(),
|
| 1123 |
+
style: "background-color: #EF292999",
|
| 1124 |
+
});
|
| 1125 |
+
} else {
|
| 1126 |
+
const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);
|
| 1127 |
+
const beforeReady = arrivalTime.isBefore(minStartTime);
|
| 1128 |
+
const departureTime = JSJoda.LocalDateTime.parse(visit.departureTime);
|
| 1129 |
+
const afterDue = departureTime.isAfter(maxEndTime);
|
| 1130 |
+
|
| 1131 |
+
// Get vehicle info for display
|
| 1132 |
+
const vehicleInfo = vehicleById.get(visit.vehicle);
|
| 1133 |
+
const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`;
|
| 1134 |
+
|
| 1135 |
+
// Stop badge for service segment
|
| 1136 |
+
const stopBadge = stopNumber ? `<span class="timeline-stop-badge">${stopNumber}</span>` : '';
|
| 1137 |
+
|
| 1138 |
+
// Status icon based on timing
|
| 1139 |
+
const statusIcon = afterDue
|
| 1140 |
+
? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="Late"></i>`
|
| 1141 |
+
: `<i class="fas fa-check timeline-status-ontime timeline-status-icon" title="On-time"></i>`;
|
| 1142 |
+
|
| 1143 |
+
const byVehicleElement = $(`<div/>`)
|
| 1144 |
+
.append($(`<span/>`).html(
|
| 1145 |
+
`${stopBadge}<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}${statusIcon}`
|
| 1146 |
+
));
|
| 1147 |
+
|
| 1148 |
+
const byVisitElement = $(`<div/>`)
|
| 1149 |
+
.append(
|
| 1150 |
+
$(`<span/>`).html(
|
| 1151 |
+
`${stopBadge}${vehicleName}${statusIcon}`
|
| 1152 |
+
),
|
| 1153 |
+
);
|
| 1154 |
+
|
| 1155 |
+
const byVehicleTravelElement = $(`<div/>`).append(
|
| 1156 |
+
$(`<span/>`).html(`<i class="fas fa-route text-warning me-1"></i>Travel`),
|
| 1157 |
+
);
|
| 1158 |
+
|
| 1159 |
+
const previousDeparture = arrivalTime.minusSeconds(
|
| 1160 |
+
visit.drivingTimeSecondsFromPreviousStandstill,
|
| 1161 |
+
);
|
| 1162 |
+
byVehicleItemData.add({
|
| 1163 |
+
id: visit.id + "_travel",
|
| 1164 |
+
group: visit.vehicle,
|
| 1165 |
+
subgroup: visit.vehicle,
|
| 1166 |
+
content: byVehicleTravelElement.html(),
|
| 1167 |
+
start: previousDeparture.toString(),
|
| 1168 |
+
end: visit.arrivalTime,
|
| 1169 |
+
style: "background-color: #f7dd8f90",
|
| 1170 |
+
});
|
| 1171 |
+
|
| 1172 |
+
if (beforeReady) {
|
| 1173 |
+
const byVehicleWaitElement = $(`<div/>`).append(
|
| 1174 |
+
$(`<span/>`).html(`<i class="fas fa-clock timeline-status-early me-1"></i>Wait`),
|
| 1175 |
+
);
|
| 1176 |
+
|
| 1177 |
+
byVehicleItemData.add({
|
| 1178 |
+
id: visit.id + "_wait",
|
| 1179 |
+
group: visit.vehicle,
|
| 1180 |
+
subgroup: visit.vehicle,
|
| 1181 |
+
content: byVehicleWaitElement.html(),
|
| 1182 |
+
start: visit.arrivalTime,
|
| 1183 |
+
end: visit.minStartTime,
|
| 1184 |
+
style: "background-color: #93c5fd80",
|
| 1185 |
+
});
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955";
|
| 1189 |
+
|
| 1190 |
+
byVehicleItemData.add({
|
| 1191 |
+
id: visit.id + "_service",
|
| 1192 |
+
group: visit.vehicle,
|
| 1193 |
+
subgroup: visit.vehicle,
|
| 1194 |
+
content: byVehicleElement.html(),
|
| 1195 |
+
start: visit.startServiceTime,
|
| 1196 |
+
end: visit.departureTime,
|
| 1197 |
+
style: "background-color: " + serviceElementBackground,
|
| 1198 |
+
});
|
| 1199 |
+
byVisitItemData.add({
|
| 1200 |
+
id: visit.id,
|
| 1201 |
+
group: visit.id,
|
| 1202 |
+
content: byVisitElement.html(),
|
| 1203 |
+
start: visit.startServiceTime,
|
| 1204 |
+
end: visit.departureTime,
|
| 1205 |
+
style: "background-color: " + serviceElementBackground,
|
| 1206 |
+
});
|
| 1207 |
+
}
|
| 1208 |
+
});
|
| 1209 |
+
|
| 1210 |
+
$.each(routePlan.vehicles, function (index, vehicle) {
|
| 1211 |
+
if (vehicle.visits.length > 0) {
|
| 1212 |
+
let lastVisit = routePlan.visits
|
| 1213 |
+
.filter(
|
| 1214 |
+
(visit) => visit.id == vehicle.visits[vehicle.visits.length - 1],
|
| 1215 |
+
)
|
| 1216 |
+
.pop();
|
| 1217 |
+
if (lastVisit) {
|
| 1218 |
+
byVehicleItemData.add({
|
| 1219 |
+
id: vehicle.id + "_travelBackToHomeLocation",
|
| 1220 |
+
group: vehicle.id,
|
| 1221 |
+
subgroup: vehicle.id,
|
| 1222 |
+
content: $(`<div/>`)
|
| 1223 |
+
.append($(`<span/>`).html(`<i class="fas fa-home text-secondary me-1"></i>Return`))
|
| 1224 |
+
.html(),
|
| 1225 |
+
start: lastVisit.departureTime,
|
| 1226 |
+
end: vehicle.arrivalTime,
|
| 1227 |
+
style: "background-color: #f7dd8f90",
|
| 1228 |
+
});
|
| 1229 |
+
}
|
| 1230 |
+
}
|
| 1231 |
+
});
|
| 1232 |
+
|
| 1233 |
+
if (!initialized) {
|
| 1234 |
+
if (byVehicleTimeline) {
|
| 1235 |
+
byVehicleTimeline.setWindow(
|
| 1236 |
+
routePlan.startDateTime,
|
| 1237 |
+
routePlan.endDateTime,
|
| 1238 |
+
);
|
| 1239 |
+
}
|
| 1240 |
+
if (byVisitTimeline) {
|
| 1241 |
+
byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
|
| 1242 |
+
}
|
| 1243 |
+
}
|
| 1244 |
+
}
|
| 1245 |
+
|
| 1246 |
+
function analyze() {
|
| 1247 |
+
// see score-analysis.js
|
| 1248 |
+
analyzeScore(loadedRoutePlan, "/route-plans/analyze");
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
function openRecommendationModal(lat, lng) {
|
| 1252 |
+
if (!('score' in loadedRoutePlan) || optimizing) {
|
| 1253 |
+
map.removeLayer(visitMarker);
|
| 1254 |
+
visitMarker = null;
|
| 1255 |
+
let message = "Please click the Solve button before adding new visits.";
|
| 1256 |
+
if (optimizing) {
|
| 1257 |
+
message = "Please wait for the solving process to finish.";
|
| 1258 |
+
}
|
| 1259 |
+
alert(message);
|
| 1260 |
+
return;
|
| 1261 |
+
}
|
| 1262 |
+
// see recommended-fit.js
|
| 1263 |
+
const visitId = Math.max(...loadedRoutePlan.visits.map(c => parseInt(c.id))) + 1;
|
| 1264 |
+
newVisit = {id: visitId, location: [lat, lng]};
|
| 1265 |
+
addNewVisit(visitId, lat, lng, map, visitMarker);
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
function getRecommendationsModal() {
|
| 1269 |
+
let formValid = true;
|
| 1270 |
+
formValid = validateFormField(newVisit, 'name', '#inputName') && formValid;
|
| 1271 |
+
formValid = validateFormField(newVisit, 'demand', '#inputDemand') && formValid;
|
| 1272 |
+
formValid = validateFormField(newVisit, 'minStartTime', '#inputMinStartTime') && formValid;
|
| 1273 |
+
formValid = validateFormField(newVisit, 'maxEndTime', '#inputMaxStartTime') && formValid;
|
| 1274 |
+
formValid = validateFormField(newVisit, 'serviceDuration', '#inputDuration') && formValid;
|
| 1275 |
+
|
| 1276 |
+
if (formValid) {
|
| 1277 |
+
const updatedMinStartTime = JSJoda.LocalDateTime.parse(
|
| 1278 |
+
newVisit['minStartTime'],
|
| 1279 |
+
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
|
| 1280 |
+
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
| 1281 |
+
|
| 1282 |
+
const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
|
| 1283 |
+
newVisit['maxEndTime'],
|
| 1284 |
+
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
|
| 1285 |
+
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
| 1286 |
+
|
| 1287 |
+
const updatedVisit = {
|
| 1288 |
+
...newVisit,
|
| 1289 |
+
serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
|
| 1290 |
+
minStartTime: updatedMinStartTime,
|
| 1291 |
+
maxEndTime: updatedMaxEndTime
|
| 1292 |
+
};
|
| 1293 |
+
|
| 1294 |
+
let updatedVisitList = [...loadedRoutePlan['visits']];
|
| 1295 |
+
updatedVisitList.push(updatedVisit);
|
| 1296 |
+
let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
|
| 1297 |
+
|
| 1298 |
+
// see recommended-fit.js
|
| 1299 |
+
requestRecommendations(updatedVisit.id, updatedSolution, "/route-plans/recommendation");
|
| 1300 |
+
}
|
| 1301 |
+
}
|
| 1302 |
+
|
| 1303 |
+
function validateFormField(target, fieldName, inputName) {
|
| 1304 |
+
target[fieldName] = $(inputName).val();
|
| 1305 |
+
if ($(inputName).val() == "") {
|
| 1306 |
+
$(inputName).addClass("is-invalid");
|
| 1307 |
+
} else {
|
| 1308 |
+
$(inputName).removeClass("is-invalid");
|
| 1309 |
+
}
|
| 1310 |
+
return $(inputName).val() != "";
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
function applyRecommendationModal(recommendations) {
|
| 1314 |
+
let checkedRecommendation = null;
|
| 1315 |
+
recommendations.forEach((recommendation, index) => {
|
| 1316 |
+
if ($('#option' + index).is(":checked")) {
|
| 1317 |
+
checkedRecommendation = recommendations[index];
|
| 1318 |
+
}
|
| 1319 |
+
});
|
| 1320 |
+
|
| 1321 |
+
if (!checkedRecommendation) {
|
| 1322 |
+
alert("Please select a recommendation.");
|
| 1323 |
+
return;
|
| 1324 |
+
}
|
| 1325 |
+
|
| 1326 |
+
const updatedMinStartTime = JSJoda.LocalDateTime.parse(
|
| 1327 |
+
newVisit['minStartTime'],
|
| 1328 |
+
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
|
| 1329 |
+
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
| 1330 |
+
|
| 1331 |
+
const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
|
| 1332 |
+
newVisit['maxEndTime'],
|
| 1333 |
+
JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
|
| 1334 |
+
).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
|
| 1335 |
+
|
| 1336 |
+
const updatedVisit = {
|
| 1337 |
+
...newVisit,
|
| 1338 |
+
serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
|
| 1339 |
+
minStartTime: updatedMinStartTime,
|
| 1340 |
+
maxEndTime: updatedMaxEndTime
|
| 1341 |
+
};
|
| 1342 |
+
|
| 1343 |
+
let updatedVisitList = [...loadedRoutePlan['visits']];
|
| 1344 |
+
updatedVisitList.push(updatedVisit);
|
| 1345 |
+
let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
|
| 1346 |
+
|
| 1347 |
+
// see recommended-fit.js
|
| 1348 |
+
applyRecommendation(
|
| 1349 |
+
updatedSolution,
|
| 1350 |
+
newVisit.id,
|
| 1351 |
+
checkedRecommendation.proposition.vehicleId,
|
| 1352 |
+
checkedRecommendation.proposition.index,
|
| 1353 |
+
"/route-plans/recommendation/apply"
|
| 1354 |
+
);
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
async function updateSolutionWithNewVisit(newSolution) {
|
| 1358 |
+
loadedRoutePlan = newSolution;
|
| 1359 |
+
await renderRoutes(newSolution);
|
| 1360 |
+
renderTimelines(newSolution);
|
| 1361 |
+
$('#newVisitModal').modal('hide');
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
// TODO: move the general functionality to the webjar.
|
| 1365 |
+
|
| 1366 |
+
function setupAjax() {
|
| 1367 |
+
$.ajaxSetup({
|
| 1368 |
+
headers: {
|
| 1369 |
+
"Content-Type": "application/json",
|
| 1370 |
+
Accept: "application/json,text/plain", // plain text is required by solve() returning UUID of the solver job
|
| 1371 |
+
},
|
| 1372 |
+
});
|
| 1373 |
+
|
| 1374 |
+
// Extend jQuery to support $.put() and $.delete()
|
| 1375 |
+
jQuery.each(["put", "delete"], function (i, method) {
|
| 1376 |
+
jQuery[method] = function (url, data, callback, type) {
|
| 1377 |
+
if (jQuery.isFunction(data)) {
|
| 1378 |
+
type = type || callback;
|
| 1379 |
+
callback = data;
|
| 1380 |
+
data = undefined;
|
| 1381 |
+
}
|
| 1382 |
+
return jQuery.ajax({
|
| 1383 |
+
url: url,
|
| 1384 |
+
type: method,
|
| 1385 |
+
dataType: type,
|
| 1386 |
+
data: data,
|
| 1387 |
+
success: callback,
|
| 1388 |
+
});
|
| 1389 |
+
};
|
| 1390 |
+
});
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
function solve() {
|
| 1394 |
+
// Clear geometry cache - will be refreshed when solution updates
|
| 1395 |
+
routeGeometries = null;
|
| 1396 |
+
|
| 1397 |
+
// Disable button immediately to prevent double-clicks
|
| 1398 |
+
$("#solveButton").prop("disabled", true).addClass("disabled");
|
| 1399 |
+
|
| 1400 |
+
$.ajax({
|
| 1401 |
+
url: "/route-plans",
|
| 1402 |
+
type: "POST",
|
| 1403 |
+
data: JSON.stringify(loadedRoutePlan),
|
| 1404 |
+
contentType: "application/json",
|
| 1405 |
+
dataType: "text",
|
| 1406 |
+
success: function (data) {
|
| 1407 |
+
scheduleId = data.replace(/"/g, ""); // Remove quotes from UUID
|
| 1408 |
+
$("#solveButton").prop("disabled", false).removeClass("disabled");
|
| 1409 |
+
refreshSolvingButtons(true);
|
| 1410 |
+
},
|
| 1411 |
+
error: function (xhr, ajaxOptions, thrownError) {
|
| 1412 |
+
showError("Start solving failed.", xhr);
|
| 1413 |
+
$("#solveButton").prop("disabled", false).removeClass("disabled");
|
| 1414 |
+
refreshSolvingButtons(false);
|
| 1415 |
+
},
|
| 1416 |
+
});
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
function refreshSolvingButtons(solving) {
|
| 1420 |
+
optimizing = solving;
|
| 1421 |
+
if (solving) {
|
| 1422 |
+
$("#solveButton").hide();
|
| 1423 |
+
$("#visitButton").hide();
|
| 1424 |
+
$("#stopSolvingButton").show();
|
| 1425 |
+
$("#solvingSpinner").addClass("active");
|
| 1426 |
+
$("#mapHint").addClass("hidden");
|
| 1427 |
+
if (autoRefreshIntervalId == null) {
|
| 1428 |
+
autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000);
|
| 1429 |
+
}
|
| 1430 |
+
} else {
|
| 1431 |
+
$("#solveButton").show();
|
| 1432 |
+
$("#visitButton").show();
|
| 1433 |
+
$("#stopSolvingButton").hide();
|
| 1434 |
+
$("#solvingSpinner").removeClass("active");
|
| 1435 |
+
$("#mapHint").removeClass("hidden");
|
| 1436 |
+
if (autoRefreshIntervalId != null) {
|
| 1437 |
+
clearInterval(autoRefreshIntervalId);
|
| 1438 |
+
autoRefreshIntervalId = null;
|
| 1439 |
+
}
|
| 1440 |
+
}
|
| 1441 |
+
}
|
| 1442 |
+
|
| 1443 |
+
async function refreshRoutePlan() {
|
| 1444 |
+
let path = "/route-plans/" + scheduleId;
|
| 1445 |
+
let isLoadingDemoData = scheduleId === null;
|
| 1446 |
+
|
| 1447 |
+
if (isLoadingDemoData) {
|
| 1448 |
+
if (demoDataId === null) {
|
| 1449 |
+
alert("Please select a test data set.");
|
| 1450 |
+
return;
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
// Clear geometry cache when loading new demo data
|
| 1454 |
+
routeGeometries = null;
|
| 1455 |
+
|
| 1456 |
+
try {
|
| 1457 |
+
let routePlan;
|
| 1458 |
+
if (useRealRoads) {
|
| 1459 |
+
// Use SSE streaming for real roads to show progress
|
| 1460 |
+
routePlan = await loadDemoDataWithProgress(demoDataId);
|
| 1461 |
+
} else {
|
| 1462 |
+
// Use simple GET for haversine (instant, no loading overlay)
|
| 1463 |
+
routePlan = await $.getJSON(`/demo-data/${demoDataId}`);
|
| 1464 |
+
}
|
| 1465 |
+
loadedRoutePlan = routePlan;
|
| 1466 |
+
refreshSolvingButtons(
|
| 1467 |
+
routePlan.solverStatus != null &&
|
| 1468 |
+
routePlan.solverStatus !== "NOT_SOLVING",
|
| 1469 |
+
);
|
| 1470 |
+
await renderRoutes(routePlan);
|
| 1471 |
+
renderTimelines(routePlan);
|
| 1472 |
+
initialized = true;
|
| 1473 |
+
} catch (error) {
|
| 1474 |
+
showError("Getting demo data has failed: " + error.message, {});
|
| 1475 |
+
refreshSolvingButtons(false);
|
| 1476 |
+
}
|
| 1477 |
+
return;
|
| 1478 |
+
}
|
| 1479 |
+
|
| 1480 |
+
// Loading existing route plan (during solving)
|
| 1481 |
+
try {
|
| 1482 |
+
const routePlan = await $.getJSON(path);
|
| 1483 |
+
loadedRoutePlan = routePlan;
|
| 1484 |
+
refreshSolvingButtons(
|
| 1485 |
+
routePlan.solverStatus != null &&
|
| 1486 |
+
routePlan.solverStatus !== "NOT_SOLVING",
|
| 1487 |
+
);
|
| 1488 |
+
await renderRoutes(routePlan);
|
| 1489 |
+
renderTimelines(routePlan);
|
| 1490 |
+
initialized = true;
|
| 1491 |
+
} catch (error) {
|
| 1492 |
+
showError("Getting route plan has failed.", error);
|
| 1493 |
+
refreshSolvingButtons(false);
|
| 1494 |
+
}
|
| 1495 |
+
}
|
| 1496 |
+
|
| 1497 |
+
function stopSolving() {
|
| 1498 |
+
$.delete("/route-plans/" + scheduleId, function () {
|
| 1499 |
+
refreshSolvingButtons(false);
|
| 1500 |
+
refreshRoutePlan();
|
| 1501 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 1502 |
+
showError("Stop solving failed.", xhr);
|
| 1503 |
+
});
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
function fetchDemoData() {
|
| 1507 |
+
$.get("/demo-data", function (data) {
|
| 1508 |
+
data.forEach(function (item) {
|
| 1509 |
+
$("#testDataButton").append(
|
| 1510 |
+
$(
|
| 1511 |
+
'<a id="' +
|
| 1512 |
+
item +
|
| 1513 |
+
'TestData" class="dropdown-item" href="#">' +
|
| 1514 |
+
item +
|
| 1515 |
+
"</a>",
|
| 1516 |
+
),
|
| 1517 |
+
);
|
| 1518 |
+
|
| 1519 |
+
$("#" + item + "TestData").click(function () {
|
| 1520 |
+
switchDataDropDownItemActive(item);
|
| 1521 |
+
scheduleId = null;
|
| 1522 |
+
demoDataId = item;
|
| 1523 |
+
initialized = false;
|
| 1524 |
+
homeLocationGroup.clearLayers();
|
| 1525 |
+
homeLocationMarkerByIdMap.clear();
|
| 1526 |
+
visitGroup.clearLayers();
|
| 1527 |
+
visitMarkerByIdMap.clear();
|
| 1528 |
+
refreshRoutePlan();
|
| 1529 |
+
});
|
| 1530 |
+
});
|
| 1531 |
+
|
| 1532 |
+
demoDataId = data[0];
|
| 1533 |
+
switchDataDropDownItemActive(demoDataId);
|
| 1534 |
+
|
| 1535 |
+
refreshRoutePlan();
|
| 1536 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 1537 |
+
// disable this page as there is no data
|
| 1538 |
+
$("#demo").empty();
|
| 1539 |
+
$("#demo").html(
|
| 1540 |
+
'<h1><p style="justify-content: center">No test data available</p></h1>',
|
| 1541 |
+
);
|
| 1542 |
+
});
|
| 1543 |
+
}
|
| 1544 |
+
|
| 1545 |
+
function switchDataDropDownItemActive(newItem) {
|
| 1546 |
+
activeCssClass = "active";
|
| 1547 |
+
$("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
|
| 1548 |
+
$("#" + newItem + "TestData").addClass(activeCssClass);
|
| 1549 |
+
}
|
| 1550 |
+
|
| 1551 |
+
function copyTextToClipboard(id) {
|
| 1552 |
+
var text = $("#" + id)
|
| 1553 |
+
.text()
|
| 1554 |
+
.trim();
|
| 1555 |
+
|
| 1556 |
+
var dummy = document.createElement("textarea");
|
| 1557 |
+
document.body.appendChild(dummy);
|
| 1558 |
+
dummy.value = text;
|
| 1559 |
+
dummy.select();
|
| 1560 |
+
document.execCommand("copy");
|
| 1561 |
+
document.body.removeChild(dummy);
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
function replaceQuickstartSolverForgeAutoHeaderFooter() {
|
| 1565 |
+
const solverforgeHeader = $("header#solverforge-auto-header");
|
| 1566 |
+
if (solverforgeHeader != null) {
|
| 1567 |
+
solverforgeHeader.css("background-color", "#ffffff");
|
| 1568 |
+
solverforgeHeader.append(
|
| 1569 |
+
$(`<div class="container-fluid">
|
| 1570 |
+
<nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
|
| 1571 |
+
<a class="navbar-brand" href="https://www.solverforge.org">
|
| 1572 |
+
<img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
|
| 1573 |
+
</a>
|
| 1574 |
+
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 1575 |
+
<span class="navbar-toggler-icon"></span>
|
| 1576 |
+
</button>
|
| 1577 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 1578 |
+
<ul class="nav nav-pills">
|
| 1579 |
+
<li class="nav-item active" id="navUIItem">
|
| 1580 |
+
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
|
| 1581 |
+
</li>
|
| 1582 |
+
<li class="nav-item" id="navRestItem">
|
| 1583 |
+
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
|
| 1584 |
+
</li>
|
| 1585 |
+
<li class="nav-item" id="navOpenApiItem">
|
| 1586 |
+
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
|
| 1587 |
+
</li>
|
| 1588 |
+
</ul>
|
| 1589 |
+
</div>
|
| 1590 |
+
<div class="ms-auto d-flex align-items-center gap-3">
|
| 1591 |
+
<div class="form-check form-switch d-flex align-items-center" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Enable real road routing using OpenStreetMap data. Slower initial load (~5-15s for download), but shows accurate road routes instead of straight lines.">
|
| 1592 |
+
<input class="form-check-input" type="checkbox" id="realRoadRouting" checked style="width: 2.5em; height: 1.25em; cursor: pointer;">
|
| 1593 |
+
<label class="form-check-label ms-2" for="realRoadRouting" style="white-space: nowrap; cursor: pointer;">
|
| 1594 |
+
<i class="fas fa-road"></i> Real Roads
|
| 1595 |
+
</label>
|
| 1596 |
+
</div>
|
| 1597 |
+
<div class="dropdown">
|
| 1598 |
+
<button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
|
| 1599 |
+
Data
|
| 1600 |
+
</button>
|
| 1601 |
+
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
| 1602 |
+
</div>
|
| 1603 |
+
</div>
|
| 1604 |
+
</nav>
|
| 1605 |
+
</div>`),
|
| 1606 |
+
);
|
| 1607 |
+
}
|
| 1608 |
+
|
| 1609 |
+
const solverforgeFooter = $("footer#solverforge-auto-footer");
|
| 1610 |
+
if (solverforgeFooter != null) {
|
| 1611 |
+
solverforgeFooter.append(
|
| 1612 |
+
$(`<footer class="bg-black text-white-50">
|
| 1613 |
+
<div class="container">
|
| 1614 |
+
<div class="hstack gap-3 p-4">
|
| 1615 |
+
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
|
| 1616 |
+
<div class="vr"></div>
|
| 1617 |
+
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
|
| 1618 |
+
<div class="vr"></div>
|
| 1619 |
+
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
|
| 1620 |
+
<div class="vr"></div>
|
| 1621 |
+
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
|
| 1622 |
+
</div>
|
| 1623 |
+
</div>
|
| 1624 |
+
</footer>`),
|
| 1625 |
+
);
|
| 1626 |
+
}
|
| 1627 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
| 7 |
+
<title>Vehicle Routing - SolverForge for Rust</title>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css">
|
| 10 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
| 11 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
| 12 |
+
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
| 13 |
+
<link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
|
| 14 |
+
<link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
|
| 15 |
+
<style>
|
| 16 |
+
/* Customer marker icons */
|
| 17 |
+
.customer-marker, .vehicle-home-marker, .temp-vehicle-marker {
|
| 18 |
+
background: transparent !important;
|
| 19 |
+
border: none !important;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* Pulse animation for new vehicle placement */
|
| 23 |
+
@keyframes pulse {
|
| 24 |
+
0% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
|
| 25 |
+
50% { transform: scale(1.1); box-shadow: 0 4px 8px rgba(99, 102, 241, 0.6); }
|
| 26 |
+
100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/* Customer type buttons in modal */
|
| 30 |
+
.customer-type-btn {
|
| 31 |
+
transition: all 0.2s ease;
|
| 32 |
+
padding: 0.75rem 0.5rem;
|
| 33 |
+
}
|
| 34 |
+
.customer-type-btn:hover {
|
| 35 |
+
transform: translateY(-2px);
|
| 36 |
+
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* Vehicle table styling */
|
| 40 |
+
#vehicles tr.vehicle-row {
|
| 41 |
+
transition: background-color 0.2s ease;
|
| 42 |
+
}
|
| 43 |
+
#vehicles tr.vehicle-row:hover {
|
| 44 |
+
background-color: rgba(99, 102, 241, 0.1);
|
| 45 |
+
}
|
| 46 |
+
#vehicles tr.vehicle-row.table-active {
|
| 47 |
+
background-color: rgba(99, 102, 241, 0.15) !important;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/* Route number markers */
|
| 51 |
+
.route-number-marker {
|
| 52 |
+
background: transparent !important;
|
| 53 |
+
border: none !important;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/* Notification panel */
|
| 57 |
+
#notificationPanel {
|
| 58 |
+
z-index: 1050;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* Click hint for vehicle rows */
|
| 62 |
+
#vehicles tr.vehicle-row td:not(:last-child) {
|
| 63 |
+
cursor: pointer;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* Solving spinner */
|
| 67 |
+
#solvingSpinner {
|
| 68 |
+
display: none;
|
| 69 |
+
width: 1.25rem;
|
| 70 |
+
height: 1.25rem;
|
| 71 |
+
border: 2px solid #10b981;
|
| 72 |
+
border-top-color: transparent;
|
| 73 |
+
border-radius: 50%;
|
| 74 |
+
animation: spin 0.75s linear infinite;
|
| 75 |
+
vertical-align: middle;
|
| 76 |
+
}
|
| 77 |
+
#solvingSpinner.active {
|
| 78 |
+
display: inline-block;
|
| 79 |
+
}
|
| 80 |
+
@keyframes spin {
|
| 81 |
+
to { transform: rotate(360deg); }
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/* Progress bar text should stay horizontal and inside */
|
| 85 |
+
.progress-bar {
|
| 86 |
+
overflow: visible;
|
| 87 |
+
white-space: nowrap;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Map hint overlay */
|
| 91 |
+
.map-hint {
|
| 92 |
+
position: absolute;
|
| 93 |
+
bottom: 20px;
|
| 94 |
+
left: 50%;
|
| 95 |
+
transform: translateX(-50%);
|
| 96 |
+
background-color: rgba(255, 255, 255, 0.95);
|
| 97 |
+
padding: 8px 16px;
|
| 98 |
+
border-radius: 20px;
|
| 99 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
| 100 |
+
font-size: 0.9rem;
|
| 101 |
+
color: #374151;
|
| 102 |
+
z-index: 1000;
|
| 103 |
+
pointer-events: none;
|
| 104 |
+
transition: opacity 0.3s ease;
|
| 105 |
+
}
|
| 106 |
+
.map-hint i {
|
| 107 |
+
color: #10b981;
|
| 108 |
+
margin-right: 6px;
|
| 109 |
+
}
|
| 110 |
+
.map-hint.hidden {
|
| 111 |
+
opacity: 0;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/* Timeline stop badges */
|
| 115 |
+
.timeline-stop-badge {
|
| 116 |
+
background-color: #6366f1;
|
| 117 |
+
color: white;
|
| 118 |
+
padding: 1px 6px;
|
| 119 |
+
border-radius: 10px;
|
| 120 |
+
font-size: 0.7rem;
|
| 121 |
+
font-weight: bold;
|
| 122 |
+
margin-right: 4px;
|
| 123 |
+
}
|
| 124 |
+
.timeline-status-icon {
|
| 125 |
+
margin-left: 4px;
|
| 126 |
+
font-size: 0.85rem;
|
| 127 |
+
}
|
| 128 |
+
.timeline-status-ontime { color: #10b981; }
|
| 129 |
+
.timeline-status-late { color: #ef4444; }
|
| 130 |
+
.timeline-status-early { color: #3b82f6; }
|
| 131 |
+
.vis-item .vis-item-content {
|
| 132 |
+
font-size: 0.85rem;
|
| 133 |
+
padding: 2px 4px;
|
| 134 |
+
}
|
| 135 |
+
.vis-labelset .vis-label {
|
| 136 |
+
padding: 4px 8px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/* Loading overlay */
|
| 140 |
+
.loading-overlay {
|
| 141 |
+
position: fixed;
|
| 142 |
+
top: 0;
|
| 143 |
+
left: 0;
|
| 144 |
+
right: 0;
|
| 145 |
+
bottom: 0;
|
| 146 |
+
background: rgba(255, 255, 255, 0.95);
|
| 147 |
+
display: flex;
|
| 148 |
+
align-items: center;
|
| 149 |
+
justify-content: center;
|
| 150 |
+
z-index: 2000;
|
| 151 |
+
transition: opacity 0.3s ease;
|
| 152 |
+
}
|
| 153 |
+
.loading-overlay.hidden {
|
| 154 |
+
opacity: 0;
|
| 155 |
+
pointer-events: none;
|
| 156 |
+
}
|
| 157 |
+
.loading-content {
|
| 158 |
+
text-align: center;
|
| 159 |
+
padding: 2rem;
|
| 160 |
+
}
|
| 161 |
+
.loading-spinner {
|
| 162 |
+
width: 60px;
|
| 163 |
+
height: 60px;
|
| 164 |
+
border: 4px solid #e5e7eb;
|
| 165 |
+
border-top-color: #10b981;
|
| 166 |
+
border-radius: 50%;
|
| 167 |
+
animation: spin 1s linear infinite;
|
| 168 |
+
margin: 0 auto 1.5rem;
|
| 169 |
+
}
|
| 170 |
+
@keyframes spin {
|
| 171 |
+
to { transform: rotate(360deg); }
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/* Real Roads toggle styling */
|
| 175 |
+
#realRoadRouting:checked {
|
| 176 |
+
background-color: #10b981;
|
| 177 |
+
border-color: #10b981;
|
| 178 |
+
}
|
| 179 |
+
</style>
|
| 180 |
+
</head>
|
| 181 |
+
<body>
|
| 182 |
+
|
| 183 |
+
<header id="solverforge-auto-header">
|
| 184 |
+
<!-- Filled in by app.js -->
|
| 185 |
+
</header>
|
| 186 |
+
<div class="tab-content">
|
| 187 |
+
<div id="demo" class="tab-pane fade show active container-fluid">
|
| 188 |
+
<div class="sticky-top d-flex justify-content-center align-items-center">
|
| 189 |
+
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
| 190 |
+
</div>
|
| 191 |
+
<h1>Vehicle routing with capacity and time windows</h1>
|
| 192 |
+
<p>Generate optimal route plan of a vehicle fleet with limited vehicle capacity and time windows.</p>
|
| 193 |
+
<div class="container-fluid mb-2">
|
| 194 |
+
<div class="row justify-content-start">
|
| 195 |
+
<div class="col-9">
|
| 196 |
+
<ul class="nav nav-pills col" role="tablist">
|
| 197 |
+
<li class="nav-item" role="presentation">
|
| 198 |
+
<button class="nav-link active" id="mapTab" data-bs-toggle="tab" data-bs-target="#mapPanel"
|
| 199 |
+
type="button"
|
| 200 |
+
role="tab" aria-controls="mapPanel" aria-selected="false">Map
|
| 201 |
+
</button>
|
| 202 |
+
</li>
|
| 203 |
+
<li class="nav-item" role="presentation">
|
| 204 |
+
<button class="nav-link" id="byVehicleTab" data-bs-toggle="tab" data-bs-target="#byVehiclePanel"
|
| 205 |
+
type="button" role="tab" aria-controls="byVehiclePanel" aria-selected="false">By vehicle
|
| 206 |
+
</button>
|
| 207 |
+
</li>
|
| 208 |
+
<li class="nav-item" role="presentation">
|
| 209 |
+
<button class="nav-link" id="byVisitTab" data-bs-toggle="tab" data-bs-target="#byVisitPanel"
|
| 210 |
+
type="button" role="tab" aria-controls="byVisitPanel" aria-selected="false">By visit
|
| 211 |
+
</button>
|
| 212 |
+
</li>
|
| 213 |
+
</ul>
|
| 214 |
+
</div>
|
| 215 |
+
<div class="col-3">
|
| 216 |
+
<button id="solveButton" type="button" class="btn btn-success">
|
| 217 |
+
<i class="fas fa-play"></i> Solve
|
| 218 |
+
</button>
|
| 219 |
+
<button id="stopSolvingButton" type="button" class="btn btn-danger p-2">
|
| 220 |
+
<i class="fas fa-stop"></i> Stop solving
|
| 221 |
+
</button>
|
| 222 |
+
<span id="solvingSpinner" class="ms-2"></span>
|
| 223 |
+
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
| 224 |
+
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
| 225 |
+
<span class="fas fa-question"></span>
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div class="tab-content">
|
| 232 |
+
|
| 233 |
+
<div class="tab-pane fade show active" id="mapPanel" role="tabpanel" aria-labelledby="mapTab">
|
| 234 |
+
<div class="row">
|
| 235 |
+
<div class="col-7 col-lg-8 col-xl-9 position-relative">
|
| 236 |
+
<div id="map" style="width: 100%; height: 100vh;"></div>
|
| 237 |
+
<div id="mapHint" class="map-hint">
|
| 238 |
+
<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
<div class="col-5 col-lg-4 col-xl-3" style="height: 100vh; overflow-y: scroll;">
|
| 242 |
+
<div class="row pt-2 row-cols-1">
|
| 243 |
+
<div class="col">
|
| 244 |
+
<h5>
|
| 245 |
+
Solution summary
|
| 246 |
+
</h5>
|
| 247 |
+
<table class="table">
|
| 248 |
+
<tr>
|
| 249 |
+
<td>Total driving time:</td>
|
| 250 |
+
<td><span id="drivingTime">unknown</span></td>
|
| 251 |
+
</tr>
|
| 252 |
+
</table>
|
| 253 |
+
</div>
|
| 254 |
+
<div class="col mb-3">
|
| 255 |
+
<h5>Time Windows</h5>
|
| 256 |
+
<div class="d-flex flex-column gap-1">
|
| 257 |
+
<div><i class="fas fa-utensils" style="color: #f59e0b; width: 20px;"></i> <strong>Restaurant</strong> <small class="text-muted">06:00-10:00 · 20-40 min</small></div>
|
| 258 |
+
<div><i class="fas fa-building" style="color: #3b82f6; width: 20px;"></i> <strong>Business</strong> <small class="text-muted">09:00-17:00 · 15-30 min</small></div>
|
| 259 |
+
<div><i class="fas fa-home" style="color: #10b981; width: 20px;"></i> <strong>Residential</strong> <small class="text-muted">17:00-20:00 · 5-10 min</small></div>
|
| 260 |
+
</div>
|
| 261 |
+
</div>
|
| 262 |
+
<div class="col">
|
| 263 |
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
| 264 |
+
<div>
|
| 265 |
+
<h5 class="mb-0">Vehicles</h5>
|
| 266 |
+
<small class="text-muted"><i class="fas fa-hand-pointer"></i> Click to highlight route</small>
|
| 267 |
+
</div>
|
| 268 |
+
<div class="btn-group btn-group-sm" role="group" aria-label="Vehicle management">
|
| 269 |
+
<button type="button" class="btn btn-outline-danger" id="removeVehicleBtn" title="Remove last vehicle">
|
| 270 |
+
<i class="fas fa-minus"></i>
|
| 271 |
+
</button>
|
| 272 |
+
<button type="button" class="btn btn-outline-success" id="addVehicleBtn" title="Add new vehicle">
|
| 273 |
+
<i class="fas fa-plus"></i>
|
| 274 |
+
</button>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
<table class="table-sm w-100">
|
| 278 |
+
<thead>
|
| 279 |
+
<tr>
|
| 280 |
+
<th class="col-1"></th>
|
| 281 |
+
<th class="col-3">Name</th>
|
| 282 |
+
<th class="col-3">
|
| 283 |
+
Cargo
|
| 284 |
+
<i class="fas fa-info-circle" data-bs-toggle="tooltip" data-bs-placement="top"
|
| 285 |
+
data-html="true"
|
| 286 |
+
title="Units to deliver on this route. Each customer requires cargo units (e.g., packages, crates). Bar shows current load vs. vehicle capacity."></i>
|
| 287 |
+
</th>
|
| 288 |
+
<th class="col-2">Drive</th>
|
| 289 |
+
<th class="col-1"></th>
|
| 290 |
+
</tr>
|
| 291 |
+
</thead>
|
| 292 |
+
<tbody id="vehicles"></tbody>
|
| 293 |
+
</table>
|
| 294 |
+
</div>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
<div class="tab-pane fade" id="byVehiclePanel" role="tabpanel" aria-labelledby="byVehicleTab">
|
| 302 |
+
</div>
|
| 303 |
+
<div class="tab-pane fade" id="byVisitPanel" role="tabpanel" aria-labelledby="byVisitTab">
|
| 304 |
+
</div>
|
| 305 |
+
</div>
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
<div id="rest" class="tab-pane fade container-fluid">
|
| 309 |
+
<h1>REST API Guide</h1>
|
| 310 |
+
|
| 311 |
+
<h2>Vehicle routing with vehicle capacity and time windows - integration via cURL</h2>
|
| 312 |
+
|
| 313 |
+
<h3>1. Download demo data</h3>
|
| 314 |
+
<pre>
|
| 315 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 316 |
+
onclick="copyTextToClipboard('curl1')">Copy</button>
|
| 317 |
+
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8082/demo-data/FIRENZE -o sample.json</code>
|
| 318 |
+
</pre>
|
| 319 |
+
|
| 320 |
+
<h3>2. Post the sample data for solving</h3>
|
| 321 |
+
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
| 322 |
+
<pre>
|
| 323 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 324 |
+
onclick="copyTextToClipboard('curl2')">Copy</button>
|
| 325 |
+
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8082/route-plans -d@sample.json</code>
|
| 326 |
+
</pre>
|
| 327 |
+
|
| 328 |
+
<h3>3. Get the current status and score</h3>
|
| 329 |
+
<pre>
|
| 330 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 331 |
+
onclick="copyTextToClipboard('curl3')">Copy</button>
|
| 332 |
+
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}/status</code>
|
| 333 |
+
</pre>
|
| 334 |
+
|
| 335 |
+
<h3>4. Get the complete route plan</h3>
|
| 336 |
+
<pre>
|
| 337 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 338 |
+
onclick="copyTextToClipboard('curl4')">Copy</button>
|
| 339 |
+
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}</code>
|
| 340 |
+
</pre>
|
| 341 |
+
|
| 342 |
+
<h3>5. Terminate solving early</h3>
|
| 343 |
+
<pre>
|
| 344 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 345 |
+
onclick="copyTextToClipboard('curl5')">Copy</button>
|
| 346 |
+
<code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}</code>
|
| 347 |
+
</pre>
|
| 348 |
+
</div>
|
| 349 |
+
|
| 350 |
+
<div id="openapi" class="tab-pane fade container-fluid">
|
| 351 |
+
<h1>REST API Reference</h1>
|
| 352 |
+
<div class="ratio ratio-1x1">
|
| 353 |
+
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
| 354 |
+
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
<div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
| 359 |
+
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
| 360 |
+
<div class="modal-content">
|
| 361 |
+
<div class="modal-header">
|
| 362 |
+
<h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
|
| 363 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 364 |
+
</div>
|
| 365 |
+
<div class="modal-body" id="scoreAnalysisModalContent">
|
| 366 |
+
<!-- Filled in by app.js -->
|
| 367 |
+
</div>
|
| 368 |
+
<div class="modal-footer">
|
| 369 |
+
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
|
| 370 |
+
</div>
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
<form id='visitForm' class='needs-validation' novalidate>
|
| 375 |
+
<div class="modal fadebd-example-modal-lg" id="newVisitModal" tabindex="-1"
|
| 376 |
+
aria-labelledby="newVisitModalLabel"
|
| 377 |
+
aria-hidden="true">
|
| 378 |
+
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
| 379 |
+
<div class="modal-content">
|
| 380 |
+
<div class="modal-header">
|
| 381 |
+
<h1 class="modal-title fs-5" id="newVisitModalLabel">Add New Visit</h1>
|
| 382 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal"
|
| 383 |
+
aria-label="Close"></button>
|
| 384 |
+
</div>
|
| 385 |
+
<div class="modal-body" id="newVisitModalContent">
|
| 386 |
+
<!-- Filled in by app.js -->
|
| 387 |
+
</div>
|
| 388 |
+
<div class="modal-footer" id="newVisitModalFooter">
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
</div>
|
| 393 |
+
</form>
|
| 394 |
+
<!-- Add Vehicle Modal -->
|
| 395 |
+
<div class="modal fade" id="addVehicleModal" tabindex="-1" aria-labelledby="addVehicleModalLabel" aria-hidden="true">
|
| 396 |
+
<div class="modal-dialog">
|
| 397 |
+
<div class="modal-content">
|
| 398 |
+
<div class="modal-header">
|
| 399 |
+
<h5 class="modal-title" id="addVehicleModalLabel"><i class="fas fa-truck"></i> Add New Vehicle</h5>
|
| 400 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 401 |
+
</div>
|
| 402 |
+
<div class="modal-body">
|
| 403 |
+
<div class="mb-3">
|
| 404 |
+
<label for="vehicleName" class="form-label">Name</label>
|
| 405 |
+
<input type="text" class="form-control" id="vehicleName" placeholder="e.g., Kilo">
|
| 406 |
+
<div class="form-text">Unique name for the vehicle</div>
|
| 407 |
+
</div>
|
| 408 |
+
<div class="mb-3">
|
| 409 |
+
<label for="vehicleCapacity" class="form-label">Capacity</label>
|
| 410 |
+
<input type="number" class="form-control" id="vehicleCapacity" value="25" min="1">
|
| 411 |
+
<div class="form-text">Maximum cargo the vehicle can carry</div>
|
| 412 |
+
</div>
|
| 413 |
+
<div class="mb-3">
|
| 414 |
+
<label for="vehicleDepartureTime" class="form-label">Departure Time</label>
|
| 415 |
+
<input type="text" class="form-control" id="vehicleDepartureTime">
|
| 416 |
+
</div>
|
| 417 |
+
<div class="mb-3">
|
| 418 |
+
<label class="form-label">Home Location</label>
|
| 419 |
+
<div class="d-flex gap-2 mb-2">
|
| 420 |
+
<button type="button" class="btn btn-outline-primary btn-sm" id="pickLocationBtn">
|
| 421 |
+
<i class="fas fa-map-marker-alt"></i> Pick on Map
|
| 422 |
+
</button>
|
| 423 |
+
<span class="text-muted small align-self-center">or enter coordinates:</span>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="row g-2">
|
| 426 |
+
<div class="col-6">
|
| 427 |
+
<input type="number" step="any" class="form-control" id="vehicleHomeLat" placeholder="Latitude">
|
| 428 |
+
</div>
|
| 429 |
+
<div class="col-6">
|
| 430 |
+
<input type="number" step="any" class="form-control" id="vehicleHomeLng" placeholder="Longitude">
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
<div id="vehicleLocationPreview" class="mt-2 text-muted small"></div>
|
| 434 |
+
</div>
|
| 435 |
+
</div>
|
| 436 |
+
<div class="modal-footer">
|
| 437 |
+
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
| 438 |
+
<button type="button" class="btn btn-success" id="confirmAddVehicle"><i class="fas fa-plus"></i> Add Vehicle</button>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
<!-- Loading/Progress Overlay -->
|
| 445 |
+
<div id="loadingOverlay" class="loading-overlay hidden">
|
| 446 |
+
<div class="loading-content">
|
| 447 |
+
<div class="loading-spinner"></div>
|
| 448 |
+
<h5 id="loadingTitle">Loading Demo Data</h5>
|
| 449 |
+
<p id="loadingMessage" class="text-muted mb-2">Initializing...</p>
|
| 450 |
+
<div class="progress" style="width: 300px; height: 8px;">
|
| 451 |
+
<div id="loadingProgress" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
|
| 452 |
+
</div>
|
| 453 |
+
<small id="loadingDetail" class="text-muted mt-2 d-block"></small>
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
|
| 457 |
+
<footer id="solverforge-auto-footer"></footer>
|
| 458 |
+
|
| 459 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
|
| 460 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
|
| 461 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
|
| 462 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
| 463 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
|
| 464 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
|
| 465 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
|
| 466 |
+
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
| 467 |
+
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
| 468 |
+
<script src="/webjars/solverforge/js/solverforge-webui.js"></script>
|
| 469 |
+
<script src="/score-analysis.js"></script>
|
| 470 |
+
<script src="/recommended-fit.js"></script>
|
| 471 |
+
<script src="/app.js"></script>
|
| 472 |
+
</body>
|
| 473 |
+
</html>
|
static/recommended-fit.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Recommended Fit functionality for adding new visits with recommendations.
|
| 3 |
+
*
|
| 4 |
+
* This module provides:
|
| 5 |
+
* - Modal form for adding new visits
|
| 6 |
+
* - Integration with the recommendation API
|
| 7 |
+
* - Application of selected recommendations
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
// Customer type configurations (must match CUSTOMER_TYPES in app.js and demo_data.py)
|
| 11 |
+
const VISIT_CUSTOMER_TYPES = {
|
| 12 |
+
RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minDemand: 1, maxDemand: 2, minService: 5, maxService: 10 },
|
| 13 |
+
BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minDemand: 3, maxDemand: 6, minService: 15, maxService: 30 },
|
| 14 |
+
RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minDemand: 5, maxDemand: 10, minService: 20, maxService: 40 },
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
function addNewVisit(id, lat, lng, map, marker) {
|
| 18 |
+
$('#newVisitModal').modal('show');
|
| 19 |
+
const visitModalContent = $("#newVisitModalContent");
|
| 20 |
+
visitModalContent.children().remove();
|
| 21 |
+
|
| 22 |
+
let visitForm = "";
|
| 23 |
+
|
| 24 |
+
// Customer Type Selection (prominent at the top)
|
| 25 |
+
visitForm += "<div class='form-group mb-3'>" +
|
| 26 |
+
" <label class='form-label fw-bold'>Customer Type</label>" +
|
| 27 |
+
" <div class='row g-2' id='customerTypeButtons'>";
|
| 28 |
+
|
| 29 |
+
Object.entries(VISIT_CUSTOMER_TYPES).forEach(([type, config]) => {
|
| 30 |
+
const isDefault = type === 'RESIDENTIAL';
|
| 31 |
+
visitForm += `
|
| 32 |
+
<div class='col-4'>
|
| 33 |
+
<button type='button' class='btn w-100 customer-type-btn ${isDefault ? 'active' : ''}'
|
| 34 |
+
data-type='${type}'
|
| 35 |
+
style='border: 2px solid ${config.color}; ${isDefault ? `background-color: ${config.color}; color: white;` : `color: ${config.color};`}'>
|
| 36 |
+
<i class='fas ${config.icon}'></i><br>
|
| 37 |
+
<span class='fw-bold'>${config.label}</span><br>
|
| 38 |
+
<small>${config.windowStart}-${config.windowEnd}</small>
|
| 39 |
+
</button>
|
| 40 |
+
</div>`;
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
visitForm += " </div>" +
|
| 44 |
+
"</div>";
|
| 45 |
+
|
| 46 |
+
// Name and Location row
|
| 47 |
+
visitForm += "<div class='form-group mb-3'>" +
|
| 48 |
+
" <div class='row g-2'>" +
|
| 49 |
+
" <div class='col-4'>" +
|
| 50 |
+
" <label for='inputName' class='form-label'>Name</label>" +
|
| 51 |
+
` <input type='text' class='form-control' id='inputName' value='visit${id}' required>` +
|
| 52 |
+
" <div class='invalid-feedback'>Field is required</div>" +
|
| 53 |
+
" </div>" +
|
| 54 |
+
" <div class='col-4'>" +
|
| 55 |
+
" <label for='inputLatitude' class='form-label'>Latitude</label>" +
|
| 56 |
+
` <input type='text' disabled class='form-control' id='inputLatitude' value='${lat.toFixed(6)}'>` +
|
| 57 |
+
" </div>" +
|
| 58 |
+
" <div class='col-4'>" +
|
| 59 |
+
" <label for='inputLongitude' class='form-label'>Longitude</label>" +
|
| 60 |
+
` <input type='text' disabled class='form-control' id='inputLongitude' value='${lng.toFixed(6)}'>` +
|
| 61 |
+
" </div>" +
|
| 62 |
+
" </div>" +
|
| 63 |
+
"</div>";
|
| 64 |
+
|
| 65 |
+
// Cargo and Duration row
|
| 66 |
+
visitForm += "<div class='form-group mb-3'>" +
|
| 67 |
+
" <div class='row g-2'>" +
|
| 68 |
+
" <div class='col-6'>" +
|
| 69 |
+
" <label for='inputDemand' class='form-label'>Cargo (units) <small class='text-muted' id='demandHint'>(1-2 typical)</small></label>" +
|
| 70 |
+
" <input type='number' class='form-control' id='inputDemand' value='1' min='1' required>" +
|
| 71 |
+
" <div class='invalid-feedback'>Field is required</div>" +
|
| 72 |
+
" </div>" +
|
| 73 |
+
" <div class='col-6'>" +
|
| 74 |
+
" <label for='inputDuration' class='form-label'>Service Duration <small class='text-muted' id='durationHint'>(5-10 min typical)</small></label>" +
|
| 75 |
+
" <input type='number' class='form-control' id='inputDuration' value='7' min='1' required>" +
|
| 76 |
+
" <div class='invalid-feedback'>Field is required</div>" +
|
| 77 |
+
" </div>" +
|
| 78 |
+
" </div>" +
|
| 79 |
+
"</div>";
|
| 80 |
+
|
| 81 |
+
// Time window row
|
| 82 |
+
visitForm += "<div class='form-group mb-3'>" +
|
| 83 |
+
" <div class='row g-2'>" +
|
| 84 |
+
" <div class='col-6'>" +
|
| 85 |
+
" <label for='inputMinStartTime' class='form-label'>Time Window Start</label>" +
|
| 86 |
+
" <input class='form-control' id='inputMinStartTime' required>" +
|
| 87 |
+
" <div class='invalid-feedback'>Field is required</div>" +
|
| 88 |
+
" </div>" +
|
| 89 |
+
" <div class='col-6'>" +
|
| 90 |
+
" <label for='inputMaxStartTime' class='form-label'>Time Window End</label>" +
|
| 91 |
+
" <input class='form-control' id='inputMaxStartTime' required>" +
|
| 92 |
+
" <div class='invalid-feedback'>Field is required</div>" +
|
| 93 |
+
" </div>" +
|
| 94 |
+
" </div>" +
|
| 95 |
+
"</div>";
|
| 96 |
+
|
| 97 |
+
visitModalContent.append(visitForm);
|
| 98 |
+
|
| 99 |
+
// Initialize with Residential defaults
|
| 100 |
+
const defaultType = VISIT_CUSTOMER_TYPES.RESIDENTIAL;
|
| 101 |
+
const tomorrow = JSJoda.LocalDate.now().plusDays(1);
|
| 102 |
+
|
| 103 |
+
function parseTimeToDateTime(timeStr) {
|
| 104 |
+
const [hours, minutes] = timeStr.split(':').map(Number);
|
| 105 |
+
return tomorrow.atTime(JSJoda.LocalTime.of(hours, minutes));
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
let minStartPicker = flatpickr("#inputMinStartTime", {
|
| 109 |
+
enableTime: true,
|
| 110 |
+
dateFormat: "Y-m-d H:i",
|
| 111 |
+
defaultDate: parseTimeToDateTime(defaultType.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
let maxEndPicker = flatpickr("#inputMaxStartTime", {
|
| 115 |
+
enableTime: true,
|
| 116 |
+
dateFormat: "Y-m-d H:i",
|
| 117 |
+
defaultDate: parseTimeToDateTime(defaultType.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
// Customer type button click handler
|
| 121 |
+
$(".customer-type-btn").click(function() {
|
| 122 |
+
const selectedType = $(this).data('type');
|
| 123 |
+
const config = VISIT_CUSTOMER_TYPES[selectedType];
|
| 124 |
+
|
| 125 |
+
// Update button styles
|
| 126 |
+
$(".customer-type-btn").each(function() {
|
| 127 |
+
const btnType = $(this).data('type');
|
| 128 |
+
const btnConfig = VISIT_CUSTOMER_TYPES[btnType];
|
| 129 |
+
$(this).removeClass('active');
|
| 130 |
+
$(this).css({
|
| 131 |
+
'background-color': 'transparent',
|
| 132 |
+
'color': btnConfig.color
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
$(this).addClass('active');
|
| 136 |
+
$(this).css({
|
| 137 |
+
'background-color': config.color,
|
| 138 |
+
'color': 'white'
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
// Update time windows
|
| 142 |
+
minStartPicker.setDate(parseTimeToDateTime(config.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
|
| 143 |
+
maxEndPicker.setDate(parseTimeToDateTime(config.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
|
| 144 |
+
|
| 145 |
+
// Update demand hint and value
|
| 146 |
+
$("#demandHint").text(`(${config.minDemand}-${config.maxDemand} typical)`);
|
| 147 |
+
$("#inputDemand").val(config.minDemand);
|
| 148 |
+
|
| 149 |
+
// Update service duration hint and value (use midpoint of range)
|
| 150 |
+
const avgService = Math.round((config.minService + config.maxService) / 2);
|
| 151 |
+
$("#durationHint").text(`(${config.minService}-${config.maxService} min typical)`);
|
| 152 |
+
$("#inputDuration").val(avgService);
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
const visitModalFooter = $("#newVisitModalFooter");
|
| 156 |
+
visitModalFooter.children().remove();
|
| 157 |
+
visitModalFooter.append("<button id='recommendationButton' type='button' class='btn btn-success'><i class='fas fa-arrow-right'></i> Get Recommendations</button>");
|
| 158 |
+
$("#recommendationButton").click(getRecommendationsModal);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function requestRecommendations(visitId, solution, endpointPath) {
|
| 162 |
+
$.post(endpointPath, JSON.stringify({solution, visitId}), function (recommendations) {
|
| 163 |
+
const visitModalContent = $("#newVisitModalContent");
|
| 164 |
+
visitModalContent.children().remove();
|
| 165 |
+
|
| 166 |
+
if (!recommendations || recommendations.length === 0) {
|
| 167 |
+
visitModalContent.append("<div class='alert alert-warning'>No recommendations available. The recommendation API may not be fully implemented.</div>");
|
| 168 |
+
const visitModalFooter = $("#newVisitModalFooter");
|
| 169 |
+
visitModalFooter.children().remove();
|
| 170 |
+
visitModalFooter.append("<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button>");
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
let visitOptions = "";
|
| 175 |
+
const visit = solution.visits.find(c => c.id === visitId);
|
| 176 |
+
|
| 177 |
+
recommendations.forEach((recommendation, index) => {
|
| 178 |
+
const scoreDiffDisplay = recommendation.scoreDiff || "N/A";
|
| 179 |
+
visitOptions += "<div class='form-check'>" +
|
| 180 |
+
` <input class='form-check-input' type='radio' name='recommendationOptions' id='option${index}' value='option${index}' ${index === 0 ? 'checked=true' : ''}>` +
|
| 181 |
+
` <label class='form-check-label' for='option${index}'>` +
|
| 182 |
+
` Add <b>${visit.name}</b> to vehicle <b>${recommendation.proposition.vehicleId}</b> at position <b>${recommendation.proposition.index + 1}</b> (${scoreDiffDisplay})${index === 0 ? ' - <b>Best Solution</b>': ''}` +
|
| 183 |
+
" </label>" +
|
| 184 |
+
"</div>";
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
visitModalContent.append(visitOptions);
|
| 188 |
+
|
| 189 |
+
const visitModalFooter = $("#newVisitModalFooter");
|
| 190 |
+
visitModalFooter.children().remove();
|
| 191 |
+
visitModalFooter.append("<button id='applyRecommendationButton' type='button' class='btn btn-success'><i class='fas fa-check'></i> Accept</button>");
|
| 192 |
+
$("#applyRecommendationButton").click(_ => applyRecommendationModal(recommendations));
|
| 193 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 194 |
+
showError("Recommendations request failed.", xhr);
|
| 195 |
+
$('#newVisitModal').modal('hide');
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function applyRecommendation(solution, visitId, vehicleId, index, endpointPath) {
|
| 200 |
+
$.post(endpointPath, JSON.stringify({solution, visitId, vehicleId, index}), function (updatedSolution) {
|
| 201 |
+
updateSolutionWithNewVisit(updatedSolution);
|
| 202 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 203 |
+
showError("Apply recommendation request failed.", xhr);
|
| 204 |
+
$('#newVisitModal').modal('hide');
|
| 205 |
+
});
|
| 206 |
+
}
|
static/score-analysis.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function analyzeScore(solution, endpointPath) {
|
| 2 |
+
new bootstrap.Modal("#scoreAnalysisModal").show()
|
| 3 |
+
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
| 4 |
+
scoreAnalysisModalContent.children().remove();
|
| 5 |
+
scoreAnalysisModalContent.text("");
|
| 6 |
+
|
| 7 |
+
if (solution.score == null) {
|
| 8 |
+
scoreAnalysisModalContent.text("Score not ready for analysis, try to run the solver first or wait until it advances.");
|
| 9 |
+
} else {
|
| 10 |
+
visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath)
|
| 11 |
+
}
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath) {
|
| 15 |
+
$('#scoreAnalysisScoreLabel').text(`(${solution.score})`);
|
| 16 |
+
$.put(endpointPath, JSON.stringify(solution), function (scoreAnalysis) {
|
| 17 |
+
let constraints = scoreAnalysis.constraints;
|
| 18 |
+
constraints.sort(compareConstraintsBySeverity);
|
| 19 |
+
constraints.map(addDerivedScoreAttributes);
|
| 20 |
+
scoreAnalysis.constraints = constraints;
|
| 21 |
+
|
| 22 |
+
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
| 23 |
+
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
| 24 |
+
.append($(`<th></th>`))
|
| 25 |
+
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
| 26 |
+
.append($(`<th>Type</th>`))
|
| 27 |
+
.append($(`<th># Matches</th>`))
|
| 28 |
+
.append($(`<th>Weight</th>`))
|
| 29 |
+
.append($(`<th>Score</th>`))
|
| 30 |
+
.append($(`<th></th>`)));
|
| 31 |
+
analysisTable.append(analysisTHead);
|
| 32 |
+
const analysisTBody = $(`<tbody/>`)
|
| 33 |
+
$.each(scoreAnalysis.constraints, function (index, constraintAnalysis) {
|
| 34 |
+
visualizeConstraintAnalysis(analysisTBody, index, constraintAnalysis)
|
| 35 |
+
});
|
| 36 |
+
analysisTable.append(analysisTBody);
|
| 37 |
+
scoreAnalysisModalContent.append(analysisTable);
|
| 38 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 39 |
+
showError("Score analysis failed.", xhr);
|
| 40 |
+
},
|
| 41 |
+
"text");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function compareConstraintsBySeverity(a, b) {
|
| 45 |
+
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
| 46 |
+
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
| 47 |
+
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
| 48 |
+
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
| 49 |
+
return -1;
|
| 50 |
+
} else {
|
| 51 |
+
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
| 52 |
+
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
| 53 |
+
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
| 54 |
+
return -1;
|
| 55 |
+
} else {
|
| 56 |
+
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
| 57 |
+
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
| 58 |
+
|
| 59 |
+
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function addDerivedScoreAttributes(constraint) {
|
| 65 |
+
let components = getScoreComponents(constraint.weight);
|
| 66 |
+
constraint.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
| 67 |
+
constraint.weight = components[constraint.type];
|
| 68 |
+
let scores = getScoreComponents(constraint.score);
|
| 69 |
+
constraint.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function getScoreComponents(score) {
|
| 73 |
+
let components = {hard: 0, medium: 0, soft: 0};
|
| 74 |
+
|
| 75 |
+
$.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], function (i, parts) {
|
| 76 |
+
components[parts[2]] = parseInt(parts[1], 10);
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
return components;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
function visualizeConstraintAnalysis(analysisTBody, constraintIndex, constraintAnalysis, recommendation = false, recommendationIndex = null) {
|
| 83 |
+
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
| 84 |
+
if (!icon) icon = constraintAnalysis.weight < 0 && constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
| 85 |
+
|
| 86 |
+
let row = $(`<tr/>`);
|
| 87 |
+
row.append($(`<td/>`).html(icon))
|
| 88 |
+
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
| 89 |
+
.append($(`<td/>`).text(constraintAnalysis.type))
|
| 90 |
+
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
| 91 |
+
.append($(`<td/>`).text(constraintAnalysis.weight))
|
| 92 |
+
.append($(`<td/>`).text(recommendation ? constraintAnalysis.score : constraintAnalysis.implicitScore));
|
| 93 |
+
|
| 94 |
+
analysisTBody.append(row);
|
| 95 |
+
row.append($(`<td/>`));
|
| 96 |
+
}
|
static/webjars/solverforge/css/solverforge-webui.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
/* Keep in sync with .navbar height on a large screen. */
|
| 3 |
+
--ts-navbar-height: 109px;
|
| 4 |
+
|
| 5 |
+
--ts-green-1-rgb: #10b981;
|
| 6 |
+
--ts-green-2-rgb: #059669;
|
| 7 |
+
--ts-violet-1-rgb: #3E00FF;
|
| 8 |
+
--ts-violet-2-rgb: #3423A6;
|
| 9 |
+
--ts-violet-3-rgb: #2E1760;
|
| 10 |
+
--ts-violet-4-rgb: #200F4F;
|
| 11 |
+
--ts-violet-5-rgb: #000000; /* TODO FIXME */
|
| 12 |
+
--ts-violet-dark-1-rgb: #b6adfd;
|
| 13 |
+
--ts-violet-dark-2-rgb: #c1bbfd;
|
| 14 |
+
--ts-gray-rgb: #666666;
|
| 15 |
+
--ts-white-rgb: #FFFFFF;
|
| 16 |
+
--ts-light-rgb: #F2F2F2;
|
| 17 |
+
--ts-gray-border: #c5c5c5;
|
| 18 |
+
|
| 19 |
+
--tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
|
| 20 |
+
--bs-body-bg: var(--ts-light-rgb); /* link to html bg */
|
| 21 |
+
--bs-link-color: var(--ts-violet-1-rgb);
|
| 22 |
+
--bs-link-hover-color: var(--ts-violet-2-rgb);
|
| 23 |
+
|
| 24 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 25 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 26 |
+
--bs-nav-link-font-size: 18px;
|
| 27 |
+
--bs-nav-link-font-weight: 400;
|
| 28 |
+
--bs-nav-link-color: var(--ts-white-rgb);
|
| 29 |
+
--ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
|
| 30 |
+
}
|
| 31 |
+
.btn {
|
| 32 |
+
--bs-btn-border-radius: 1.5rem;
|
| 33 |
+
}
|
| 34 |
+
.btn-primary {
|
| 35 |
+
--bs-btn-bg: var(--ts-violet-1-rgb);
|
| 36 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 37 |
+
--bs-btn-hover-bg: var(--ts-violet-2-rgb);
|
| 38 |
+
--bs-btn-hover-border-color: var(--ts-violet-2-rgb);
|
| 39 |
+
--bs-btn-active-bg: var(--ts-violet-2-rgb);
|
| 40 |
+
--bs-btn-active-border-bg: var(--ts-violet-2-rgb);
|
| 41 |
+
--bs-btn-disabled-bg: var(--ts-violet-1-rgb);
|
| 42 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 43 |
+
}
|
| 44 |
+
.btn-outline-primary {
|
| 45 |
+
--bs-btn-color: var(--ts-violet-1-rgb);
|
| 46 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 47 |
+
--bs-btn-hover-bg: var(--ts-violet-1-rgb);
|
| 48 |
+
--bs-btn-hover-border-color: var(--ts-violet-1-rgb);
|
| 49 |
+
--bs-btn-active-bg: var(--ts-violet-1-rgb);
|
| 50 |
+
--bs-btn-active-border-color: var(--ts-violet-1-rgb);
|
| 51 |
+
--bs-btn-disabled-color: var(--ts-violet-1-rgb);
|
| 52 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 53 |
+
}
|
| 54 |
+
.navbar-dark {
|
| 55 |
+
--bs-link-color: var(--ts-violet-dark-1-rgb);
|
| 56 |
+
--bs-link-hover-color: var(--ts-violet-dark-2-rgb);
|
| 57 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 58 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 59 |
+
}
|
| 60 |
+
.nav-pills {
|
| 61 |
+
--bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
|
| 62 |
+
}
|
| 63 |
+
.nav-pills .nav-link:hover {
|
| 64 |
+
color: var(--ts-green-1-rgb);
|
| 65 |
+
}
|
| 66 |
+
.nav-pills .nav-link.active:hover {
|
| 67 |
+
color: var(--ts-white-rgb);
|
| 68 |
+
}
|
static/webjars/solverforge/img/solverforge-favicon.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-horizontal-white.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-horizontal.svg
ADDED
|
|
static/webjars/solverforge/img/solverforge-logo-stacked.svg
ADDED
|
|
static/webjars/solverforge/js/solverforge-webui.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function replaceSolverForgeAutoHeaderFooter() {
|
| 2 |
+
const solverforgeHeader = $("header#solverforge-auto-header");
|
| 3 |
+
if (solverforgeHeader != null) {
|
| 4 |
+
solverforgeHeader.addClass("bg-black")
|
| 5 |
+
solverforgeHeader.append(
|
| 6 |
+
$(`<div class="container-fluid">
|
| 7 |
+
<nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
|
| 8 |
+
<a class="navbar-brand" href="https://www.solverforge.org">
|
| 9 |
+
<img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
|
| 10 |
+
</a>
|
| 11 |
+
</nav>
|
| 12 |
+
</div>`));
|
| 13 |
+
}
|
| 14 |
+
const solverforgeFooter = $("footer#solverforge-auto-footer");
|
| 15 |
+
if (solverforgeFooter != null) {
|
| 16 |
+
solverforgeFooter.append(
|
| 17 |
+
$(`<footer class="bg-black text-white-50">
|
| 18 |
+
<div class="container">
|
| 19 |
+
<div class="hstack gap-3 p-4">
|
| 20 |
+
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
|
| 21 |
+
<div class="vr"></div>
|
| 22 |
+
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
|
| 23 |
+
<div class="vr"></div>
|
| 24 |
+
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
|
| 25 |
+
<div class="vr"></div>
|
| 26 |
+
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
|
| 27 |
+
</div>
|
| 28 |
+
</div>
|
| 29 |
+
<div id="applicationInfo" class="container text-center"></div>
|
| 30 |
+
</footer>`));
|
| 31 |
+
|
| 32 |
+
applicationInfo();
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function showSimpleError(title) {
|
| 38 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 39 |
+
.append($(`<div class="toast-header bg-danger">
|
| 40 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 41 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 42 |
+
</div>`))
|
| 43 |
+
.append($(`<div class="toast-body"/>`)
|
| 44 |
+
.append($(`<p/>`).text(title))
|
| 45 |
+
);
|
| 46 |
+
$("#notificationPanel").append(notification);
|
| 47 |
+
notification.toast({delay: 30000});
|
| 48 |
+
notification.toast('show');
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function showError(title, xhr) {
|
| 52 |
+
var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
|
| 53 |
+
var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
|
| 54 |
+
var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
|
| 55 |
+
var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
|
| 56 |
+
|
| 57 |
+
if (xhr.responseJSON && !serverErrorMessage) {
|
| 58 |
+
serverErrorMessage = JSON.stringify(xhr.responseJSON);
|
| 59 |
+
serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
|
| 60 |
+
serverErrorId = `----`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
|
| 64 |
+
const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
|
| 65 |
+
.append($(`<div class="toast-header bg-danger">
|
| 66 |
+
<strong class="me-auto text-dark">Error</strong>
|
| 67 |
+
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
| 68 |
+
</div>`))
|
| 69 |
+
.append($(`<div class="toast-body"/>`)
|
| 70 |
+
.append($(`<p/>`).text(title))
|
| 71 |
+
.append($(`<pre/>`)
|
| 72 |
+
.append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
|
| 73 |
+
)
|
| 74 |
+
);
|
| 75 |
+
$("#notificationPanel").append(notification);
|
| 76 |
+
notification.toast({delay: 30000});
|
| 77 |
+
notification.toast('show');
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ****************************************************************************
|
| 81 |
+
// Application info
|
| 82 |
+
// ****************************************************************************
|
| 83 |
+
|
| 84 |
+
function applicationInfo() {
|
| 85 |
+
$.getJSON("info", function (info) {
|
| 86 |
+
$("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
|
| 87 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 88 |
+
console.warn("Unable to collect application information");
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// ****************************************************************************
|
| 93 |
+
// TangoColorFactory
|
| 94 |
+
// ****************************************************************************
|
| 95 |
+
|
| 96 |
+
const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
|
| 97 |
+
const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
|
| 98 |
+
|
| 99 |
+
var colorMap = new Map;
|
| 100 |
+
var nextColorCount = 0;
|
| 101 |
+
|
| 102 |
+
function pickColor(object) {
|
| 103 |
+
let color = colorMap[object];
|
| 104 |
+
if (color !== undefined) {
|
| 105 |
+
return color;
|
| 106 |
+
}
|
| 107 |
+
color = nextColor();
|
| 108 |
+
colorMap[object] = color;
|
| 109 |
+
return color;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function nextColor() {
|
| 113 |
+
let color;
|
| 114 |
+
let colorIndex = nextColorCount % SEQUENCE_1.length;
|
| 115 |
+
let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
|
| 116 |
+
if (shadeIndex === 0) {
|
| 117 |
+
color = SEQUENCE_1[colorIndex];
|
| 118 |
+
} else if (shadeIndex === 1) {
|
| 119 |
+
color = SEQUENCE_2[colorIndex];
|
| 120 |
+
} else {
|
| 121 |
+
shadeIndex -= 3;
|
| 122 |
+
let floorColor = SEQUENCE_2[colorIndex];
|
| 123 |
+
let ceilColor = SEQUENCE_1[colorIndex];
|
| 124 |
+
let base = Math.floor((shadeIndex / 2) + 1);
|
| 125 |
+
let divisor = 2;
|
| 126 |
+
while (base >= divisor) {
|
| 127 |
+
divisor *= 2;
|
| 128 |
+
}
|
| 129 |
+
base = (base * 2) - divisor + 1;
|
| 130 |
+
let shadePercentage = base / divisor;
|
| 131 |
+
color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
|
| 132 |
+
}
|
| 133 |
+
nextColorCount++;
|
| 134 |
+
return "#" + color.toString(16);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
|
| 138 |
+
let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
|
| 139 |
+
let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
|
| 140 |
+
let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
|
| 141 |
+
return red | green | blue;
|
| 142 |
+
}
|