Spaces:
Running
feat(fsr): add snapshot-scoped route geometry
Browse filesHarden the Bergamo field-service routing demo around real road-network geometry and retained solver snapshots. The solver payload now keeps travel legs as the authoritative feasibility matrix while route polylines are served through a dedicated /jobs/{id}/routes endpoint keyed by snapshot revision. Geometry generation reports segment-level statuses so NoPath and SnapFailed legs can be represented as non-routed route segments instead of failing the whole route response.
Move OSM preparation to solve submission time and keep generated seed data lightweight. The demo is now standard-only, initial plans carry only identity travel legs, and solve creation prepares the full routing matrix before starting the retained job. Encoded polylines were removed from TravelLeg so the domain model no longer mixes solver facts with map rendering artifacts.
Update the frontend to treat route geometry as snapshot-scoped cache state. The app clears stale polylines whenever a newer snapshot renders, applies route fetches only when the response identity still matches the current plan, and leaves markers, route cards, timeline, and tables visible when geometry is missing or partially unavailable. The UI was split into smaller layout, route-state, map-rendering, and route-list modules to keep source files below the 300 LOC threshold.
Improve score analysis fidelity by giving each route constraint an explicit match counter. Match counts now describe the underlying visits, legs, lateness, capacity, territory, and workload matches instead of merely counting routes with non-zero scores, with regression coverage for the reported constraint analysis output.
Align the app on published support crates by using solverforge-ui 0.6.5 and solverforge-maps 2.1.4 from crates.io. README and solverforge.app.toml now describe the same published dependency surface, and root-level generated desktop bundle names are ignored so accidental Electron/Codex artifacts are not committed.
Verification performed: cargo fmt -- --check; cargo test; cargo clippy -- -D warnings; node --check static/*.js; stale version/path string scan; source/static 300 LOC guard. The configured pre-commit hooks also ran during git commit and passed.
- .gitignore +3 -0
- Cargo.lock +4 -4
- Cargo.toml +2 -2
- README.md +4 -4
- solverforge.app.toml +2 -3
- src/api/mod.rs +2 -0
- src/api/route_dto.rs +57 -0
- src/api/route_geometry.rs +273 -0
- src/api/routes.rs +28 -2
- src/constraints/balance_workload.rs +4 -1
- src/constraints/minimize_travel.rs +4 -1
- src/constraints/priority_slack.rs +4 -1
- src/constraints/reachable_legs.rs +2 -1
- src/constraints/required_parts.rs +4 -1
- src/constraints/required_skills.rs +4 -1
- src/constraints/route_constraint.rs +5 -2
- src/constraints/route_metrics.rs +49 -0
- src/constraints/route_metrics_tests.rs +22 -1
- src/constraints/shift_capacity.rs +4 -1
- src/constraints/territory_affinity.rs +4 -1
- src/constraints/time_windows.rs +4 -1
- src/data/data_seed.rs +61 -47
- src/data/mod.rs +4 -1
- src/domain/travel_leg.rs +0 -5
- static/app-dataset.js +9 -101
- static/app-layout.js +105 -0
- static/app-render-map.js +165 -0
- static/app-render-routes.js +106 -0
- static/app-render.js +29 -121
- static/app-route-state.js +101 -0
- static/app-utils.js +8 -0
- static/app.css +263 -0
- static/app.js +84 -120
- static/index.html +8 -3
|
@@ -1,3 +1,6 @@
|
|
| 1 |
/target
|
| 2 |
.osm_cache/
|
| 3 |
**/*.rs.bk
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/target
|
| 2 |
.osm_cache/
|
| 3 |
**/*.rs.bk
|
| 4 |
+
/app-session-*.js
|
| 5 |
+
/comment-preload.js
|
| 6 |
+
/main-*.js
|
|
@@ -1620,9 +1620,9 @@ dependencies = [
|
|
| 1620 |
|
| 1621 |
[[package]]
|
| 1622 |
name = "solverforge-maps"
|
| 1623 |
-
version = "2.1.
|
| 1624 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1625 |
-
checksum = "
|
| 1626 |
dependencies = [
|
| 1627 |
"rayon",
|
| 1628 |
"reqwest",
|
|
@@ -1664,9 +1664,9 @@ dependencies = [
|
|
| 1664 |
|
| 1665 |
[[package]]
|
| 1666 |
name = "solverforge-ui"
|
| 1667 |
-
version = "0.6.
|
| 1668 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1669 |
-
checksum = "
|
| 1670 |
dependencies = [
|
| 1671 |
"axum",
|
| 1672 |
"include_dir",
|
|
|
|
| 1620 |
|
| 1621 |
[[package]]
|
| 1622 |
name = "solverforge-maps"
|
| 1623 |
+
version = "2.1.4"
|
| 1624 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1625 |
+
checksum = "e31f816d221238ba3ade93315e6a605486b53d9ec26527477d165b507ece6a88"
|
| 1626 |
dependencies = [
|
| 1627 |
"rayon",
|
| 1628 |
"reqwest",
|
|
|
|
| 1664 |
|
| 1665 |
[[package]]
|
| 1666 |
name = "solverforge-ui"
|
| 1667 |
+
version = "0.6.5"
|
| 1668 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1669 |
+
checksum = "1c7fa2d78c84af9a1e264adcffc1bdf8cb4edab8d73a3543fb448d166c95596f"
|
| 1670 |
dependencies = [
|
| 1671 |
"axum",
|
| 1672 |
"include_dir",
|
|
@@ -11,8 +11,8 @@ path = "src/main.rs"
|
|
| 11 |
|
| 12 |
[dependencies]
|
| 13 |
solverforge = { version = "0.10.0", features = ["serde", "console", "verbose-logging"] }
|
| 14 |
-
solverforge-ui = { version = "0.6.
|
| 15 |
-
solverforge-maps = { version = "2.1.
|
| 16 |
# Web server
|
| 17 |
axum = "0.8.9"
|
| 18 |
tokio = { version = "1.52.1", features = ["full"] }
|
|
|
|
| 11 |
|
| 12 |
[dependencies]
|
| 13 |
solverforge = { version = "0.10.0", features = ["serde", "console", "verbose-logging"] }
|
| 14 |
+
solverforge-ui = { version = "0.6.5" }
|
| 15 |
+
solverforge-maps = { version = "2.1.4" }
|
| 16 |
# Web server
|
| 17 |
axum = "0.8.9"
|
| 18 |
tokio = { version = "1.52.1", features = ["full"] }
|
|
@@ -6,11 +6,11 @@ A SolverForge constraint optimization project (scaffold: `neutral scaffold`).
|
|
| 6 |
|
| 7 |
- CLI version used to scaffold this project: `2.0.2`
|
| 8 |
- SolverForge runtime target for this scaffold: `solverforge 0.10.0`
|
| 9 |
-
- SolverForge UI target for this scaffold: `solverforge-ui 0.6.
|
| 10 |
-
- SolverForge maps target for this scaffold: `solverforge-maps 2.1.
|
| 11 |
- Runtime dependency currently wired into `Cargo.toml`: `crates.io: solverforge 0.10.0`
|
| 12 |
-
- Frontend UI dependency currently wired into `Cargo.toml`: `crates.io: solverforge-ui 0.6.
|
| 13 |
-
- Maps dependency currently wired into `Cargo.toml`: `crates.io: solverforge-maps 2.1.
|
| 14 |
|
| 15 |
This project was scaffolded by `solverforge-cli`, and it currently targets `SolverForge crate target 0.10.0` through the configured crate dependency targets.
|
| 16 |
|
|
|
|
| 6 |
|
| 7 |
- CLI version used to scaffold this project: `2.0.2`
|
| 8 |
- SolverForge runtime target for this scaffold: `solverforge 0.10.0`
|
| 9 |
+
- SolverForge UI target for this scaffold: `solverforge-ui 0.6.5`
|
| 10 |
+
- SolverForge maps target for this scaffold: `solverforge-maps 2.1.4`
|
| 11 |
- Runtime dependency currently wired into `Cargo.toml`: `crates.io: solverforge 0.10.0`
|
| 12 |
+
- Frontend UI dependency currently wired into `Cargo.toml`: `crates.io: solverforge-ui 0.6.5`
|
| 13 |
+
- Maps dependency currently wired into `Cargo.toml`: `crates.io: solverforge-maps 2.1.4`
|
| 14 |
|
| 15 |
This project was scaffolded by `solverforge-cli`, and it currently targets `SolverForge crate target 0.10.0` through the configured crate dependency targets.
|
| 16 |
|
|
@@ -6,14 +6,13 @@ cli_version = "2.0.2"
|
|
| 6 |
[runtime]
|
| 7 |
target = "solverforge 0.10.0"
|
| 8 |
runtime_source = "crates.io: solverforge 0.10.0"
|
| 9 |
-
ui_source = "crates.io: solverforge-ui 0.6.
|
|
|
|
| 10 |
|
| 11 |
[demo]
|
| 12 |
default_size = "standard"
|
| 13 |
available_sizes = [
|
| 14 |
-
"small",
|
| 15 |
"standard",
|
| 16 |
-
"large",
|
| 17 |
]
|
| 18 |
|
| 19 |
[solution]
|
|
|
|
| 6 |
[runtime]
|
| 7 |
target = "solverforge 0.10.0"
|
| 8 |
runtime_source = "crates.io: solverforge 0.10.0"
|
| 9 |
+
ui_source = "crates.io: solverforge-ui 0.6.5"
|
| 10 |
+
maps_source = "crates.io: solverforge-maps 2.1.4"
|
| 11 |
|
| 12 |
[demo]
|
| 13 |
default_size = "standard"
|
| 14 |
available_sizes = [
|
|
|
|
| 15 |
"standard",
|
|
|
|
| 16 |
]
|
| 17 |
|
| 18 |
[solution]
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
mod dto;
|
|
|
|
|
|
|
| 2 |
mod routes;
|
| 3 |
mod sse;
|
| 4 |
|
|
|
|
| 1 |
mod dto;
|
| 2 |
+
mod route_dto;
|
| 3 |
+
mod route_geometry;
|
| 4 |
mod routes;
|
| 5 |
mod sse;
|
| 6 |
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use serde::Serialize;
|
| 2 |
+
|
| 3 |
+
#[derive(Debug, Clone, Serialize)]
|
| 4 |
+
#[serde(rename_all = "camelCase")]
|
| 5 |
+
pub struct JobRoutesDto {
|
| 6 |
+
pub id: String,
|
| 7 |
+
pub job_id: String,
|
| 8 |
+
pub snapshot_revision: u64,
|
| 9 |
+
pub routes: Vec<TechnicianRouteGeometryDto>,
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
#[derive(Debug, Clone, Serialize)]
|
| 13 |
+
#[serde(rename_all = "camelCase")]
|
| 14 |
+
pub struct TechnicianRouteGeometryDto {
|
| 15 |
+
pub route_id: String,
|
| 16 |
+
pub technician_id: String,
|
| 17 |
+
pub technician_name: String,
|
| 18 |
+
pub color: String,
|
| 19 |
+
pub segments: Vec<RouteSegmentDto>,
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
| 23 |
+
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
| 24 |
+
pub enum RouteGeometryStatus {
|
| 25 |
+
Routed,
|
| 26 |
+
UnreachableLeg,
|
| 27 |
+
SnapFailed,
|
| 28 |
+
NoPath,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[derive(Debug, Clone, Serialize)]
|
| 32 |
+
#[serde(rename_all = "camelCase")]
|
| 33 |
+
pub struct RouteSegmentDto {
|
| 34 |
+
pub route_id: String,
|
| 35 |
+
pub from_location_idx: usize,
|
| 36 |
+
pub to_location_idx: usize,
|
| 37 |
+
pub duration_seconds: i64,
|
| 38 |
+
pub distance_meters: i64,
|
| 39 |
+
pub reachable: bool,
|
| 40 |
+
pub geometry_status: RouteGeometryStatus,
|
| 41 |
+
pub encoded_polyline: String,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
impl JobRoutesDto {
|
| 45 |
+
pub fn new(
|
| 46 |
+
job_id: usize,
|
| 47 |
+
snapshot_revision: u64,
|
| 48 |
+
routes: Vec<TechnicianRouteGeometryDto>,
|
| 49 |
+
) -> Self {
|
| 50 |
+
Self {
|
| 51 |
+
id: job_id.to_string(),
|
| 52 |
+
job_id: job_id.to_string(),
|
| 53 |
+
snapshot_revision,
|
| 54 |
+
routes,
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use axum::http::StatusCode;
|
| 2 |
+
|
| 3 |
+
use super::route_dto::{RouteGeometryStatus, RouteSegmentDto, TechnicianRouteGeometryDto};
|
| 4 |
+
use crate::data::{load_network, DemoDataError};
|
| 5 |
+
use crate::domain::{FieldServicePlan, TravelLeg};
|
| 6 |
+
|
| 7 |
+
pub(super) fn status_from_routing_error(error: solverforge_maps::RoutingError) -> StatusCode {
|
| 8 |
+
eprintln!("Bergamo route geometry failed: {error}");
|
| 9 |
+
match error {
|
| 10 |
+
solverforge_maps::RoutingError::InvalidCoordinate { .. } => StatusCode::BAD_REQUEST,
|
| 11 |
+
solverforge_maps::RoutingError::Cancelled => StatusCode::REQUEST_TIMEOUT,
|
| 12 |
+
solverforge_maps::RoutingError::Network(_)
|
| 13 |
+
| solverforge_maps::RoutingError::Parse(_)
|
| 14 |
+
| solverforge_maps::RoutingError::Io(_)
|
| 15 |
+
| solverforge_maps::RoutingError::SnapFailed { .. }
|
| 16 |
+
| solverforge_maps::RoutingError::NoPath { .. } => StatusCode::BAD_GATEWAY,
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
pub(super) async fn build_route_geometry(
|
| 21 |
+
plan: &FieldServicePlan,
|
| 22 |
+
) -> Result<Vec<TechnicianRouteGeometryDto>, solverforge_maps::RoutingError> {
|
| 23 |
+
let network = load_network().await.map_err(|error| match error {
|
| 24 |
+
DemoDataError::Routing(error) => error,
|
| 25 |
+
})?;
|
| 26 |
+
let mut routes = Vec::with_capacity(plan.technician_routes.len());
|
| 27 |
+
|
| 28 |
+
for route in &plan.technician_routes {
|
| 29 |
+
let mut segments = Vec::new();
|
| 30 |
+
let mut previous_location_idx = route.start_location_idx;
|
| 31 |
+
for &visit_idx in &route.visits {
|
| 32 |
+
let Some(visit) = plan.service_visits.get(visit_idx) else {
|
| 33 |
+
continue;
|
| 34 |
+
};
|
| 35 |
+
segments.push(build_route_segment(
|
| 36 |
+
plan,
|
| 37 |
+
&network,
|
| 38 |
+
&route.id,
|
| 39 |
+
previous_location_idx,
|
| 40 |
+
visit.location_idx,
|
| 41 |
+
)?);
|
| 42 |
+
previous_location_idx = visit.location_idx;
|
| 43 |
+
}
|
| 44 |
+
if !route.visits.is_empty() {
|
| 45 |
+
segments.push(build_route_segment(
|
| 46 |
+
plan,
|
| 47 |
+
&network,
|
| 48 |
+
&route.id,
|
| 49 |
+
previous_location_idx,
|
| 50 |
+
route.end_location_idx,
|
| 51 |
+
)?);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
routes.push(TechnicianRouteGeometryDto {
|
| 55 |
+
route_id: route.id.clone(),
|
| 56 |
+
technician_id: route.technician_id.clone(),
|
| 57 |
+
technician_name: route.technician_name.clone(),
|
| 58 |
+
color: route.color.clone(),
|
| 59 |
+
segments,
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
Ok(routes)
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
fn build_route_segment(
|
| 67 |
+
plan: &FieldServicePlan,
|
| 68 |
+
network: &solverforge_maps::RoadNetwork,
|
| 69 |
+
route_id: &str,
|
| 70 |
+
from_location_idx: usize,
|
| 71 |
+
to_location_idx: usize,
|
| 72 |
+
) -> Result<RouteSegmentDto, solverforge_maps::RoutingError> {
|
| 73 |
+
let travel_leg = find_travel_leg(plan, from_location_idx, to_location_idx);
|
| 74 |
+
if !travel_leg.is_some_and(|leg| leg.reachable) {
|
| 75 |
+
return Ok(non_routed_segment(
|
| 76 |
+
route_id,
|
| 77 |
+
from_location_idx,
|
| 78 |
+
to_location_idx,
|
| 79 |
+
travel_leg,
|
| 80 |
+
RouteGeometryStatus::UnreachableLeg,
|
| 81 |
+
));
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
let from = plan.locations.get(from_location_idx).ok_or_else(|| {
|
| 85 |
+
solverforge_maps::RoutingError::Network("route source location missing".into())
|
| 86 |
+
})?;
|
| 87 |
+
let to = plan.locations.get(to_location_idx).ok_or_else(|| {
|
| 88 |
+
solverforge_maps::RoutingError::Network("route target location missing".into())
|
| 89 |
+
})?;
|
| 90 |
+
|
| 91 |
+
let route_result = network.route(
|
| 92 |
+
solverforge_maps::Coord::new(from.lat(), from.lng()),
|
| 93 |
+
solverforge_maps::Coord::new(to.lat(), to.lng()),
|
| 94 |
+
);
|
| 95 |
+
let route = match route_result {
|
| 96 |
+
Ok(route) => route.simplify(12.0),
|
| 97 |
+
Err(error) => {
|
| 98 |
+
if let Some(status) = recoverable_geometry_status(&error) {
|
| 99 |
+
return Ok(non_routed_segment(
|
| 100 |
+
route_id,
|
| 101 |
+
from_location_idx,
|
| 102 |
+
to_location_idx,
|
| 103 |
+
travel_leg,
|
| 104 |
+
status,
|
| 105 |
+
));
|
| 106 |
+
}
|
| 107 |
+
return Err(error);
|
| 108 |
+
}
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
Ok(RouteSegmentDto {
|
| 112 |
+
route_id: route_id.to_string(),
|
| 113 |
+
from_location_idx,
|
| 114 |
+
to_location_idx,
|
| 115 |
+
duration_seconds: route.duration_seconds,
|
| 116 |
+
distance_meters: route.distance_meters.round() as i64,
|
| 117 |
+
reachable: true,
|
| 118 |
+
geometry_status: RouteGeometryStatus::Routed,
|
| 119 |
+
encoded_polyline: solverforge_maps::encode_polyline(&route.geometry),
|
| 120 |
+
})
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
fn find_travel_leg(
|
| 124 |
+
plan: &FieldServicePlan,
|
| 125 |
+
from_location_idx: usize,
|
| 126 |
+
to_location_idx: usize,
|
| 127 |
+
) -> Option<&TravelLeg> {
|
| 128 |
+
let width = plan.locations.len();
|
| 129 |
+
plan.travel_legs
|
| 130 |
+
.get(from_location_idx.checked_mul(width)? + to_location_idx)
|
| 131 |
+
.filter(|leg| {
|
| 132 |
+
leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
|
| 133 |
+
})
|
| 134 |
+
.or_else(|| {
|
| 135 |
+
plan.travel_legs.iter().find(|leg| {
|
| 136 |
+
leg.from_location_idx == from_location_idx && leg.to_location_idx == to_location_idx
|
| 137 |
+
})
|
| 138 |
+
})
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
fn non_routed_segment(
|
| 142 |
+
route_id: &str,
|
| 143 |
+
from_location_idx: usize,
|
| 144 |
+
to_location_idx: usize,
|
| 145 |
+
travel_leg: Option<&TravelLeg>,
|
| 146 |
+
geometry_status: RouteGeometryStatus,
|
| 147 |
+
) -> RouteSegmentDto {
|
| 148 |
+
RouteSegmentDto {
|
| 149 |
+
route_id: route_id.to_string(),
|
| 150 |
+
from_location_idx,
|
| 151 |
+
to_location_idx,
|
| 152 |
+
duration_seconds: travel_leg.map_or(0, |leg| leg.duration_seconds),
|
| 153 |
+
distance_meters: travel_leg.map_or(0, |leg| leg.distance_meters),
|
| 154 |
+
reachable: false,
|
| 155 |
+
geometry_status,
|
| 156 |
+
encoded_polyline: String::new(),
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
fn recoverable_geometry_status(
|
| 161 |
+
error: &solverforge_maps::RoutingError,
|
| 162 |
+
) -> Option<RouteGeometryStatus> {
|
| 163 |
+
match error {
|
| 164 |
+
solverforge_maps::RoutingError::SnapFailed { .. } => Some(RouteGeometryStatus::SnapFailed),
|
| 165 |
+
solverforge_maps::RoutingError::NoPath { .. } => Some(RouteGeometryStatus::NoPath),
|
| 166 |
+
_ => None,
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
#[cfg(test)]
|
| 171 |
+
mod tests {
|
| 172 |
+
use super::*;
|
| 173 |
+
use crate::domain::{FieldServicePlan, Location, TravelLegInit};
|
| 174 |
+
|
| 175 |
+
#[test]
|
| 176 |
+
fn finds_dense_or_sparse_travel_leg() {
|
| 177 |
+
let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
|
| 178 |
+
id: "leg-01-02".to_string(),
|
| 179 |
+
name: "leg-01-02".to_string(),
|
| 180 |
+
from_location_idx: 1,
|
| 181 |
+
to_location_idx: 2,
|
| 182 |
+
duration_seconds: 42,
|
| 183 |
+
distance_meters: 1000,
|
| 184 |
+
reachable: true,
|
| 185 |
+
})]);
|
| 186 |
+
|
| 187 |
+
let leg = find_travel_leg(&plan, 1, 2).expect("travel leg");
|
| 188 |
+
|
| 189 |
+
assert_eq!(leg.duration_seconds, 42);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#[test]
|
| 193 |
+
fn non_routed_segment_preserves_known_metrics() {
|
| 194 |
+
let plan = test_plan(vec![TravelLeg::new(TravelLegInit {
|
| 195 |
+
id: "leg-00-01".to_string(),
|
| 196 |
+
name: "leg-00-01".to_string(),
|
| 197 |
+
from_location_idx: 0,
|
| 198 |
+
to_location_idx: 1,
|
| 199 |
+
duration_seconds: 90,
|
| 200 |
+
distance_meters: 1200,
|
| 201 |
+
reachable: false,
|
| 202 |
+
})]);
|
| 203 |
+
|
| 204 |
+
let segment = non_routed_segment(
|
| 205 |
+
"route-00",
|
| 206 |
+
0,
|
| 207 |
+
1,
|
| 208 |
+
find_travel_leg(&plan, 0, 1),
|
| 209 |
+
RouteGeometryStatus::UnreachableLeg,
|
| 210 |
+
);
|
| 211 |
+
|
| 212 |
+
assert!(!segment.reachable);
|
| 213 |
+
assert_eq!(segment.geometry_status, RouteGeometryStatus::UnreachableLeg);
|
| 214 |
+
assert_eq!(segment.duration_seconds, 90);
|
| 215 |
+
assert!(segment.encoded_polyline.is_empty());
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
#[test]
|
| 219 |
+
fn only_snap_and_no_path_are_recoverable_segment_failures() {
|
| 220 |
+
let from = solverforge_maps::Coord::new(45.0, 9.0);
|
| 221 |
+
let to = solverforge_maps::Coord::new(46.0, 10.0);
|
| 222 |
+
|
| 223 |
+
assert_eq!(
|
| 224 |
+
recoverable_geometry_status(&solverforge_maps::RoutingError::NoPath { from, to }),
|
| 225 |
+
Some(RouteGeometryStatus::NoPath)
|
| 226 |
+
);
|
| 227 |
+
assert_eq!(
|
| 228 |
+
recoverable_geometry_status(&solverforge_maps::RoutingError::SnapFailed {
|
| 229 |
+
coord: from,
|
| 230 |
+
nearest_distance_m: None,
|
| 231 |
+
}),
|
| 232 |
+
Some(RouteGeometryStatus::SnapFailed)
|
| 233 |
+
);
|
| 234 |
+
assert_eq!(
|
| 235 |
+
recoverable_geometry_status(&solverforge_maps::RoutingError::Network("down".into())),
|
| 236 |
+
None
|
| 237 |
+
);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
fn test_plan(travel_legs: Vec<TravelLeg>) -> FieldServicePlan {
|
| 241 |
+
FieldServicePlan::new(
|
| 242 |
+
vec![
|
| 243 |
+
Location::new(
|
| 244 |
+
"loc-0",
|
| 245 |
+
"loc-0",
|
| 246 |
+
"A".into(),
|
| 247 |
+
45_000_000,
|
| 248 |
+
9_000_000,
|
| 249 |
+
"x".into(),
|
| 250 |
+
),
|
| 251 |
+
Location::new(
|
| 252 |
+
"loc-1",
|
| 253 |
+
"loc-1",
|
| 254 |
+
"B".into(),
|
| 255 |
+
45_001_000,
|
| 256 |
+
9_001_000,
|
| 257 |
+
"x".into(),
|
| 258 |
+
),
|
| 259 |
+
Location::new(
|
| 260 |
+
"loc-2",
|
| 261 |
+
"loc-2",
|
| 262 |
+
"C".into(),
|
| 263 |
+
45_002_000,
|
| 264 |
+
9_002_000,
|
| 265 |
+
"x".into(),
|
| 266 |
+
),
|
| 267 |
+
],
|
| 268 |
+
Vec::new(),
|
| 269 |
+
travel_legs,
|
| 270 |
+
Vec::new(),
|
| 271 |
+
)
|
| 272 |
+
}
|
| 273 |
+
}
|
|
@@ -8,8 +8,10 @@ use serde::{Deserialize, Serialize};
|
|
| 8 |
use std::sync::Arc;
|
| 9 |
|
| 10 |
use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto};
|
|
|
|
|
|
|
| 11 |
use super::sse;
|
| 12 |
-
use crate::data::{generate, DemoData, DemoDataError};
|
| 13 |
use crate::solver::SolverService;
|
| 14 |
|
| 15 |
/// Shared application state.
|
|
@@ -43,6 +45,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
|
| 43 |
.route("/jobs/{id}/status", get(get_job_status))
|
| 44 |
.route("/jobs/{id}/snapshot", get(get_snapshot))
|
| 45 |
.route("/jobs/{id}/analysis", get(analyze_by_id))
|
|
|
|
| 46 |
.route("/jobs/{id}/pause", post(pause_job))
|
| 47 |
.route("/jobs/{id}/resume", post(resume_job))
|
| 48 |
.route("/jobs/{id}/cancel", post(cancel_job))
|
|
@@ -108,7 +111,10 @@ async fn create_job(
|
|
| 108 |
State(state): State<Arc<AppState>>,
|
| 109 |
Json(dto): Json<PlanDto>,
|
| 110 |
) -> Result<Json<CreateJobResponse>, StatusCode> {
|
| 111 |
-
let plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?;
|
|
|
|
|
|
|
|
|
|
| 112 |
let id = state
|
| 113 |
.solver
|
| 114 |
.start_job(plan)
|
|
@@ -168,6 +174,26 @@ async fn analyze_by_id(
|
|
| 168 |
)))
|
| 169 |
}
|
| 170 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
async fn pause_job(
|
| 172 |
State(state): State<Arc<AppState>>,
|
| 173 |
Path(id): Path<String>,
|
|
|
|
| 8 |
use std::sync::Arc;
|
| 9 |
|
| 10 |
use super::dto::{analysis_response, JobAnalysisDto, JobSnapshotDto, JobSummaryDto, PlanDto};
|
| 11 |
+
use super::route_dto::JobRoutesDto;
|
| 12 |
+
use super::route_geometry::{build_route_geometry, status_from_routing_error};
|
| 13 |
use super::sse;
|
| 14 |
+
use crate::data::{generate, prepare_routing, DemoData, DemoDataError};
|
| 15 |
use crate::solver::SolverService;
|
| 16 |
|
| 17 |
/// Shared application state.
|
|
|
|
| 45 |
.route("/jobs/{id}/status", get(get_job_status))
|
| 46 |
.route("/jobs/{id}/snapshot", get(get_snapshot))
|
| 47 |
.route("/jobs/{id}/analysis", get(analyze_by_id))
|
| 48 |
+
.route("/jobs/{id}/routes", get(get_routes))
|
| 49 |
.route("/jobs/{id}/pause", post(pause_job))
|
| 50 |
.route("/jobs/{id}/resume", post(resume_job))
|
| 51 |
.route("/jobs/{id}/cancel", post(cancel_job))
|
|
|
|
| 111 |
State(state): State<Arc<AppState>>,
|
| 112 |
Json(dto): Json<PlanDto>,
|
| 113 |
) -> Result<Json<CreateJobResponse>, StatusCode> {
|
| 114 |
+
let mut plan = dto.to_domain().map_err(|_| StatusCode::BAD_REQUEST)?;
|
| 115 |
+
prepare_routing(&mut plan)
|
| 116 |
+
.await
|
| 117 |
+
.map_err(status_from_demo_data_error)?;
|
| 118 |
let id = state
|
| 119 |
.solver
|
| 120 |
.start_job(plan)
|
|
|
|
| 174 |
)))
|
| 175 |
}
|
| 176 |
|
| 177 |
+
async fn get_routes(
|
| 178 |
+
State(state): State<Arc<AppState>>,
|
| 179 |
+
Path(id): Path<String>,
|
| 180 |
+
Query(query): Query<SnapshotQuery>,
|
| 181 |
+
) -> Result<Json<JobRoutesDto>, StatusCode> {
|
| 182 |
+
let job_id = parse_job_id(&id)?;
|
| 183 |
+
let snapshot = state
|
| 184 |
+
.solver
|
| 185 |
+
.get_snapshot(&id, query.snapshot_revision)
|
| 186 |
+
.map_err(status_from_solver_error)?;
|
| 187 |
+
let routes = build_route_geometry(&snapshot.solution)
|
| 188 |
+
.await
|
| 189 |
+
.map_err(status_from_routing_error)?;
|
| 190 |
+
Ok(Json(JobRoutesDto::new(
|
| 191 |
+
job_id,
|
| 192 |
+
snapshot.snapshot_revision,
|
| 193 |
+
routes,
|
| 194 |
+
)))
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
async fn pause_job(
|
| 198 |
State(state): State<Arc<AppState>>,
|
| 199 |
Path(id): Path<String>,
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
false,
|
| 11 |
HardSoftScore::of(0, 1),
|
| 12 |
balance_workload_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
balance_workload_match_count, balance_workload_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
false,
|
| 13 |
HardSoftScore::of(0, 1),
|
| 14 |
balance_workload_score,
|
| 15 |
+
balance_workload_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
false,
|
| 11 |
HardSoftScore::of(0, 1),
|
| 12 |
minimize_travel_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
minimize_travel_match_count, minimize_travel_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
false,
|
| 13 |
HardSoftScore::of(0, 1),
|
| 14 |
minimize_travel_score,
|
| 15 |
+
minimize_travel_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
false,
|
| 11 |
HardSoftScore::of(0, 1),
|
| 12 |
priority_slack_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
priority_slack_match_count, priority_slack_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
false,
|
| 13 |
HardSoftScore::of(0, 1),
|
| 14 |
priority_slack_score,
|
| 15 |
+
priority_slack_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{reachable_score, RouteConstraint};
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +10,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
reachable_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{reachable_match_count, reachable_score, RouteConstraint};
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
reachable_score,
|
| 13 |
+
reachable_match_count,
|
| 14 |
)
|
| 15 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
required_parts_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
required_parts_match_count, required_parts_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
true,
|
| 13 |
HardSoftScore::of(1, 0),
|
| 14 |
required_parts_score,
|
| 15 |
+
required_parts_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
required_skills_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
required_skills_match_count, required_skills_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
true,
|
| 13 |
HardSoftScore::of(1, 0),
|
| 14 |
required_skills_score,
|
| 15 |
+
required_skills_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -7,6 +7,7 @@ pub struct RouteConstraint {
|
|
| 7 |
hard: bool,
|
| 8 |
weight: HardSoftScore,
|
| 9 |
scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
impl RouteConstraint {
|
|
@@ -15,12 +16,14 @@ impl RouteConstraint {
|
|
| 15 |
hard: bool,
|
| 16 |
weight: HardSoftScore,
|
| 17 |
scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
|
|
|
|
| 18 |
) -> Self {
|
| 19 |
Self {
|
| 20 |
name,
|
| 21 |
hard,
|
| 22 |
weight,
|
| 23 |
scorer,
|
|
|
|
| 24 |
}
|
| 25 |
}
|
| 26 |
|
|
@@ -46,8 +49,8 @@ impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for RouteConstraint
|
|
| 46 |
solution
|
| 47 |
.technician_routes
|
| 48 |
.iter()
|
| 49 |
-
.
|
| 50 |
-
.
|
| 51 |
}
|
| 52 |
|
| 53 |
fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
|
|
|
|
| 7 |
hard: bool,
|
| 8 |
weight: HardSoftScore,
|
| 9 |
scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
|
| 10 |
+
match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize,
|
| 11 |
}
|
| 12 |
|
| 13 |
impl RouteConstraint {
|
|
|
|
| 16 |
hard: bool,
|
| 17 |
weight: HardSoftScore,
|
| 18 |
scorer: fn(&FieldServicePlan, &TechnicianRoute) -> HardSoftScore,
|
| 19 |
+
match_counter: fn(&FieldServicePlan, &TechnicianRoute) -> usize,
|
| 20 |
) -> Self {
|
| 21 |
Self {
|
| 22 |
name,
|
| 23 |
hard,
|
| 24 |
weight,
|
| 25 |
scorer,
|
| 26 |
+
match_counter,
|
| 27 |
}
|
| 28 |
}
|
| 29 |
|
|
|
|
| 49 |
solution
|
| 50 |
.technician_routes
|
| 51 |
.iter()
|
| 52 |
+
.map(|route| (self.match_counter)(solution, route))
|
| 53 |
+
.sum()
|
| 54 |
}
|
| 55 |
|
| 56 |
fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
|
|
@@ -6,9 +6,12 @@ pub use super::route_constraint::RouteConstraint;
|
|
| 6 |
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
| 7 |
pub struct RouteStats {
|
| 8 |
pub invalid_visits: i64,
|
|
|
|
|
|
|
| 9 |
pub unreachable_legs: i64,
|
| 10 |
pub missing_skill_visits: i64,
|
| 11 |
pub missing_part_visits: i64,
|
|
|
|
| 12 |
pub late_minutes: i64,
|
| 13 |
pub overtime_minutes: i64,
|
| 14 |
pub travel_seconds: i64,
|
|
@@ -47,6 +50,7 @@ pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteSta
|
|
| 47 |
stats.invalid_visits += 1;
|
| 48 |
continue;
|
| 49 |
};
|
|
|
|
| 50 |
|
| 51 |
apply_leg(
|
| 52 |
plan,
|
|
@@ -61,6 +65,7 @@ pub fn route_stats(plan: &FieldServicePlan, route: &TechnicianRoute) -> RouteSta
|
|
| 61 |
clock = visit.earliest_minute;
|
| 62 |
}
|
| 63 |
if clock > visit.latest_minute {
|
|
|
|
| 64 |
stats.late_minutes += i64::from(clock - visit.latest_minute);
|
| 65 |
}
|
| 66 |
|
|
@@ -106,22 +111,43 @@ pub fn reachable_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> Hard
|
|
| 106 |
HardSoftScore::of(-(stats.invalid_visits + stats.unreachable_legs), 0)
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
pub fn required_skills_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 110 |
HardSoftScore::of(-route_stats(plan, route).missing_skill_visits, 0)
|
| 111 |
}
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
pub fn required_parts_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 114 |
HardSoftScore::of(-route_stats(plan, route).missing_part_visits, 0)
|
| 115 |
}
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
pub fn time_windows_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 118 |
HardSoftScore::of(-route_stats(plan, route).late_minutes, 0)
|
| 119 |
}
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
pub fn shift_capacity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 122 |
HardSoftScore::of(-route_stats(plan, route).overtime_minutes, 0)
|
| 123 |
}
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
pub fn minimize_travel_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 126 |
let stats = route_stats(plan, route);
|
| 127 |
let travel_minutes = stats.travel_minutes();
|
|
@@ -129,19 +155,35 @@ pub fn minimize_travel_score(plan: &FieldServicePlan, route: &TechnicianRoute) -
|
|
| 129 |
HardSoftScore::of(0, -(travel_minutes + travel_km))
|
| 130 |
}
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
pub fn balance_workload_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 133 |
let normalized = (route_stats(plan, route).route_minutes / 15).max(0);
|
| 134 |
HardSoftScore::of(0, -(normalized * normalized))
|
| 135 |
}
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
pub fn territory_affinity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 138 |
HardSoftScore::of(0, route_stats(plan, route).territory_matches * 25)
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
pub fn priority_slack_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 142 |
HardSoftScore::of(0, route_stats(plan, route).priority_slack)
|
| 143 |
}
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
pub fn leg_for(
|
| 146 |
plan: &FieldServicePlan,
|
| 147 |
from_location_idx: usize,
|
|
@@ -182,6 +224,9 @@ fn apply_leg(
|
|
| 182 |
|
| 183 |
stats.travel_seconds += leg.duration_seconds.max(0);
|
| 184 |
stats.distance_meters += leg.distance_meters.max(0);
|
|
|
|
|
|
|
|
|
|
| 185 |
*clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32);
|
| 186 |
}
|
| 187 |
|
|
@@ -212,3 +257,7 @@ fn div_ceil(value: i64, divisor: i64) -> i64 {
|
|
| 212 |
(value + divisor - 1) / divisor
|
| 213 |
}
|
| 214 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
| 7 |
pub struct RouteStats {
|
| 8 |
pub invalid_visits: i64,
|
| 9 |
+
pub valid_visits: i64,
|
| 10 |
+
pub scored_travel_legs: i64,
|
| 11 |
pub unreachable_legs: i64,
|
| 12 |
pub missing_skill_visits: i64,
|
| 13 |
pub missing_part_visits: i64,
|
| 14 |
+
pub late_visits: i64,
|
| 15 |
pub late_minutes: i64,
|
| 16 |
pub overtime_minutes: i64,
|
| 17 |
pub travel_seconds: i64,
|
|
|
|
| 50 |
stats.invalid_visits += 1;
|
| 51 |
continue;
|
| 52 |
};
|
| 53 |
+
stats.valid_visits += 1;
|
| 54 |
|
| 55 |
apply_leg(
|
| 56 |
plan,
|
|
|
|
| 65 |
clock = visit.earliest_minute;
|
| 66 |
}
|
| 67 |
if clock > visit.latest_minute {
|
| 68 |
+
stats.late_visits += 1;
|
| 69 |
stats.late_minutes += i64::from(clock - visit.latest_minute);
|
| 70 |
}
|
| 71 |
|
|
|
|
| 111 |
HardSoftScore::of(-(stats.invalid_visits + stats.unreachable_legs), 0)
|
| 112 |
}
|
| 113 |
|
| 114 |
+
pub fn reachable_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 115 |
+
let stats = route_stats(plan, route);
|
| 116 |
+
count_matches(stats.invalid_visits + stats.unreachable_legs)
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
pub fn required_skills_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 120 |
HardSoftScore::of(-route_stats(plan, route).missing_skill_visits, 0)
|
| 121 |
}
|
| 122 |
|
| 123 |
+
pub fn required_skills_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 124 |
+
count_matches(route_stats(plan, route).missing_skill_visits)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
pub fn required_parts_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 128 |
HardSoftScore::of(-route_stats(plan, route).missing_part_visits, 0)
|
| 129 |
}
|
| 130 |
|
| 131 |
+
pub fn required_parts_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 132 |
+
count_matches(route_stats(plan, route).missing_part_visits)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
pub fn time_windows_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 136 |
HardSoftScore::of(-route_stats(plan, route).late_minutes, 0)
|
| 137 |
}
|
| 138 |
|
| 139 |
+
pub fn time_windows_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 140 |
+
count_matches(route_stats(plan, route).late_visits)
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
pub fn shift_capacity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 144 |
HardSoftScore::of(-route_stats(plan, route).overtime_minutes, 0)
|
| 145 |
}
|
| 146 |
|
| 147 |
+
pub fn shift_capacity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 148 |
+
usize::from(route_stats(plan, route).overtime_minutes > 0)
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
pub fn minimize_travel_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 152 |
let stats = route_stats(plan, route);
|
| 153 |
let travel_minutes = stats.travel_minutes();
|
|
|
|
| 155 |
HardSoftScore::of(0, -(travel_minutes + travel_km))
|
| 156 |
}
|
| 157 |
|
| 158 |
+
pub fn minimize_travel_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 159 |
+
count_matches(route_stats(plan, route).scored_travel_legs)
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
pub fn balance_workload_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 163 |
let normalized = (route_stats(plan, route).route_minutes / 15).max(0);
|
| 164 |
HardSoftScore::of(0, -(normalized * normalized))
|
| 165 |
}
|
| 166 |
|
| 167 |
+
pub fn balance_workload_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 168 |
+
usize::from(route_stats(plan, route).route_minutes > 0)
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
pub fn territory_affinity_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 172 |
HardSoftScore::of(0, route_stats(plan, route).territory_matches * 25)
|
| 173 |
}
|
| 174 |
|
| 175 |
+
pub fn territory_affinity_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 176 |
+
count_matches(route_stats(plan, route).territory_matches)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
pub fn priority_slack_score(plan: &FieldServicePlan, route: &TechnicianRoute) -> HardSoftScore {
|
| 180 |
HardSoftScore::of(0, route_stats(plan, route).priority_slack)
|
| 181 |
}
|
| 182 |
|
| 183 |
+
pub fn priority_slack_match_count(plan: &FieldServicePlan, route: &TechnicianRoute) -> usize {
|
| 184 |
+
count_matches(route_stats(plan, route).valid_visits)
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
pub fn leg_for(
|
| 188 |
plan: &FieldServicePlan,
|
| 189 |
from_location_idx: usize,
|
|
|
|
| 224 |
|
| 225 |
stats.travel_seconds += leg.duration_seconds.max(0);
|
| 226 |
stats.distance_meters += leg.distance_meters.max(0);
|
| 227 |
+
if leg.duration_seconds > 0 || leg.distance_meters > 0 {
|
| 228 |
+
stats.scored_travel_legs += 1;
|
| 229 |
+
}
|
| 230 |
*clock = clock.saturating_add(div_ceil(leg.duration_seconds.max(0), 60) as i32);
|
| 231 |
}
|
| 232 |
|
|
|
|
| 257 |
(value + divisor - 1) / divisor
|
| 258 |
}
|
| 259 |
}
|
| 260 |
+
|
| 261 |
+
fn count_matches(value: i64) -> usize {
|
| 262 |
+
usize::try_from(value.max(0)).unwrap_or(usize::MAX)
|
| 263 |
+
}
|
|
@@ -15,6 +15,8 @@ fn route_stats_accounts_for_travel_service_and_lateness() {
|
|
| 15 |
assert_eq!(stats.late_minutes, 0);
|
| 16 |
assert_eq!(stats.route_minutes, 125);
|
| 17 |
assert_eq!(stats.overtime_minutes, 55);
|
|
|
|
|
|
|
| 18 |
assert_eq!(stats.missing_skill_visits, 0);
|
| 19 |
assert_eq!(stats.missing_part_visits, 1);
|
| 20 |
}
|
|
@@ -37,6 +39,26 @@ fn full_constraint_set_reports_expected_hard_penalties() {
|
|
| 37 |
assert!(score.soft() < 0);
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
|
| 41 |
let locations = vec![
|
| 42 |
Location::new(
|
|
@@ -124,7 +146,6 @@ fn row_major_legs(width: usize) -> Vec<TravelLeg> {
|
|
| 124 |
to_location_idx: to,
|
| 125 |
duration_seconds: if same { 0 } else { 600 },
|
| 126 |
distance_meters: if same { 0 } else { 2_000 },
|
| 127 |
-
encoded_polyline: String::new(),
|
| 128 |
reachable: true,
|
| 129 |
})
|
| 130 |
})
|
|
|
|
| 15 |
assert_eq!(stats.late_minutes, 0);
|
| 16 |
assert_eq!(stats.route_minutes, 125);
|
| 17 |
assert_eq!(stats.overtime_minutes, 55);
|
| 18 |
+
assert_eq!(stats.valid_visits, 2);
|
| 19 |
+
assert_eq!(stats.scored_travel_legs, 3);
|
| 20 |
assert_eq!(stats.missing_skill_visits, 0);
|
| 21 |
assert_eq!(stats.missing_part_visits, 1);
|
| 22 |
}
|
|
|
|
| 39 |
assert!(score.soft() < 0);
|
| 40 |
}
|
| 41 |
|
| 42 |
+
#[test]
|
| 43 |
+
fn route_constraint_match_counts_describe_underlying_route_matches() {
|
| 44 |
+
let constraints = crate::constraints::create_constraints();
|
| 45 |
+
let results = constraints.evaluate_each(&sample_plan(vec![0, 1]));
|
| 46 |
+
let match_count = |name: &str| {
|
| 47 |
+
results
|
| 48 |
+
.iter()
|
| 49 |
+
.find(|result| result.name == name)
|
| 50 |
+
.map(|result| result.match_count)
|
| 51 |
+
.unwrap_or_else(|| panic!("missing constraint result for {name}"))
|
| 52 |
+
};
|
| 53 |
+
|
| 54 |
+
assert_eq!(match_count("Balance Workload"), 1);
|
| 55 |
+
assert_eq!(match_count("Minimize Travel"), 3);
|
| 56 |
+
assert_eq!(match_count("Priority Slack"), 2);
|
| 57 |
+
assert_eq!(match_count("Required Parts"), 1);
|
| 58 |
+
assert_eq!(match_count("Shift Capacity"), 1);
|
| 59 |
+
assert_eq!(match_count("Territory Affinity"), 2);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
fn sample_plan(visits: Vec<usize>) -> FieldServicePlan {
|
| 63 |
let locations = vec![
|
| 64 |
Location::new(
|
|
|
|
| 146 |
to_location_idx: to,
|
| 147 |
duration_seconds: if same { 0 } else { 600 },
|
| 148 |
distance_meters: if same { 0 } else { 2_000 },
|
|
|
|
| 149 |
reachable: true,
|
| 150 |
})
|
| 151 |
})
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
shift_capacity_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
shift_capacity_match_count, shift_capacity_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
true,
|
| 13 |
HardSoftScore::of(1, 0),
|
| 14 |
shift_capacity_score,
|
| 15 |
+
shift_capacity_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
false,
|
| 11 |
HardSoftScore::of(0, 1),
|
| 12 |
territory_affinity_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
territory_affinity_match_count, territory_affinity_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
false,
|
| 13 |
HardSoftScore::of(0, 1),
|
| 14 |
territory_affinity_score,
|
| 15 |
+
territory_affinity_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -1,4 +1,6 @@
|
|
| 1 |
-
use crate::constraints::route_metrics::{
|
|
|
|
|
|
|
| 2 |
use crate::domain::FieldServicePlan;
|
| 3 |
use solverforge::prelude::*;
|
| 4 |
use solverforge::IncrementalConstraint;
|
|
@@ -10,5 +12,6 @@ pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScor
|
|
| 10 |
true,
|
| 11 |
HardSoftScore::of(1, 0),
|
| 12 |
time_windows_score,
|
|
|
|
| 13 |
)
|
| 14 |
}
|
|
|
|
| 1 |
+
use crate::constraints::route_metrics::{
|
| 2 |
+
time_windows_match_count, time_windows_score, RouteConstraint,
|
| 3 |
+
};
|
| 4 |
use crate::domain::FieldServicePlan;
|
| 5 |
use solverforge::prelude::*;
|
| 6 |
use solverforge::IncrementalConstraint;
|
|
|
|
| 12 |
true,
|
| 13 |
HardSoftScore::of(1, 0),
|
| 14 |
time_windows_score,
|
| 15 |
+
time_windows_match_count,
|
| 16 |
)
|
| 17 |
}
|
|
@@ -4,7 +4,7 @@ use std::str::FromStr;
|
|
| 4 |
use std::time::Duration;
|
| 5 |
|
| 6 |
use solverforge_maps::{
|
| 7 |
-
|
| 8 |
};
|
| 9 |
|
| 10 |
use super::bergamo_locations::{DEPOTS, SERVICE_LOCATIONS};
|
|
@@ -24,9 +24,7 @@ const BERGAMO_BBOX: BoundingBox = BoundingBox {
|
|
| 24 |
|
| 25 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 26 |
pub enum DemoData {
|
| 27 |
-
Small,
|
| 28 |
Standard,
|
| 29 |
-
Large,
|
| 30 |
}
|
| 31 |
|
| 32 |
#[derive(Debug)]
|
|
@@ -50,7 +48,7 @@ impl From<RoutingError> for DemoDataError {
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
| 53 |
-
const AVAILABLE_DEMO_DATA: &[DemoData] = &[DemoData::
|
| 54 |
const DEFAULT_DEMO_DATA: DemoData = DemoData::Standard;
|
| 55 |
|
| 56 |
pub fn default_demo_data() -> DemoData {
|
|
@@ -64,9 +62,7 @@ pub fn available_demo_data() -> &'static [DemoData] {
|
|
| 64 |
impl DemoData {
|
| 65 |
pub fn id(self) -> &'static str {
|
| 66 |
match self {
|
| 67 |
-
DemoData::Small => "SMALL",
|
| 68 |
DemoData::Standard => "STANDARD",
|
| 69 |
-
DemoData::Large => "LARGE",
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
|
@@ -80,17 +76,13 @@ impl DemoData {
|
|
| 80 |
|
| 81 |
fn technician_count(self) -> usize {
|
| 82 |
match self {
|
| 83 |
-
Self::
|
| 84 |
-
Self::Standard => 4,
|
| 85 |
-
Self::Large => 6,
|
| 86 |
}
|
| 87 |
}
|
| 88 |
|
| 89 |
fn visit_count(self) -> usize {
|
| 90 |
match self {
|
| 91 |
-
Self::
|
| 92 |
-
Self::Standard => 14,
|
| 93 |
-
Self::Large => SERVICE_LOCATIONS.len() * 2,
|
| 94 |
}
|
| 95 |
}
|
| 96 |
}
|
|
@@ -100,9 +92,7 @@ impl FromStr for DemoData {
|
|
| 100 |
|
| 101 |
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
| 102 |
match s.to_ascii_uppercase().as_str() {
|
| 103 |
-
"SMALL" => Ok(DemoData::Small),
|
| 104 |
"STANDARD" => Ok(DemoData::Standard),
|
| 105 |
-
"LARGE" => Ok(DemoData::Large),
|
| 106 |
_ => Err(()),
|
| 107 |
}
|
| 108 |
}
|
|
@@ -110,13 +100,7 @@ impl FromStr for DemoData {
|
|
| 110 |
|
| 111 |
pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError> {
|
| 112 |
let locations = build_locations(demo);
|
| 113 |
-
let
|
| 114 |
-
.iter()
|
| 115 |
-
.map(|location| Coord::new(location.lat(), location.lng()))
|
| 116 |
-
.collect::<Vec<_>>();
|
| 117 |
-
let network = RoadNetwork::load_or_fetch(&BERGAMO_BBOX, &network_config(), None).await?;
|
| 118 |
-
let matrix = network.compute_matrix(&coords, None).await;
|
| 119 |
-
let travel_legs = build_travel_legs(&network, &matrix, &coords);
|
| 120 |
let service_visits = build_service_visits(demo);
|
| 121 |
let technician_routes = build_technician_routes(demo);
|
| 122 |
|
|
@@ -128,7 +112,25 @@ pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError>
|
|
| 128 |
))
|
| 129 |
}
|
| 130 |
|
| 131 |
-
fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
NetworkConfig::default()
|
| 133 |
.cache_dir(PathBuf::from(".osm_cache/field-service-routing/bergamo"))
|
| 134 |
.connect_timeout(Duration::from_secs(10))
|
|
@@ -198,37 +200,36 @@ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
|
|
| 198 |
.collect()
|
| 199 |
}
|
| 200 |
|
| 201 |
-
fn
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
let mut legs = Vec::with_capacity(width * width);
|
| 208 |
|
| 209 |
for from in 0..width {
|
| 210 |
for to in 0..width {
|
| 211 |
-
let (duration_seconds, distance_meters,
|
| 212 |
-
(0, 0,
|
| 213 |
} else {
|
| 214 |
let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE);
|
| 215 |
-
|
| 216 |
-
|
|
|
|
| 217 |
} else {
|
| 218 |
-
|
| 219 |
-
Ok(route) => {
|
| 220 |
-
let duration_seconds = route.duration_seconds;
|
| 221 |
-
let distance_meters = route.distance_meters.round() as i64;
|
| 222 |
-
let simplified = route.simplify(12.0);
|
| 223 |
-
(
|
| 224 |
-
duration_seconds,
|
| 225 |
-
distance_meters,
|
| 226 |
-
encode_polyline(&simplified.geometry),
|
| 227 |
-
true,
|
| 228 |
-
)
|
| 229 |
-
}
|
| 230 |
-
Err(_) => (0, 0, String::new(), false),
|
| 231 |
-
}
|
| 232 |
}
|
| 233 |
};
|
| 234 |
|
|
@@ -239,7 +240,6 @@ fn build_travel_legs(
|
|
| 239 |
to_location_idx: to,
|
| 240 |
duration_seconds,
|
| 241 |
distance_meters,
|
| 242 |
-
encoded_polyline,
|
| 243 |
reachable,
|
| 244 |
}));
|
| 245 |
}
|
|
@@ -261,4 +261,18 @@ mod tests {
|
|
| 261 |
assert!(routes.iter().all(|route| route.visits.is_empty()));
|
| 262 |
}
|
| 263 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
}
|
|
|
|
| 4 |
use std::time::Duration;
|
| 5 |
|
| 6 |
use solverforge_maps::{
|
| 7 |
+
BoundingBox, Coord, NetworkConfig, NetworkRef, RoadNetwork, RoutingError, UNREACHABLE,
|
| 8 |
};
|
| 9 |
|
| 10 |
use super::bergamo_locations::{DEPOTS, SERVICE_LOCATIONS};
|
|
|
|
| 24 |
|
| 25 |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 26 |
pub enum DemoData {
|
|
|
|
| 27 |
Standard,
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
#[derive(Debug)]
|
|
|
|
| 48 |
}
|
| 49 |
}
|
| 50 |
|
| 51 |
+
const AVAILABLE_DEMO_DATA: &[DemoData] = &[DemoData::Standard];
|
| 52 |
const DEFAULT_DEMO_DATA: DemoData = DemoData::Standard;
|
| 53 |
|
| 54 |
pub fn default_demo_data() -> DemoData {
|
|
|
|
| 62 |
impl DemoData {
|
| 63 |
pub fn id(self) -> &'static str {
|
| 64 |
match self {
|
|
|
|
| 65 |
DemoData::Standard => "STANDARD",
|
|
|
|
| 66 |
}
|
| 67 |
}
|
| 68 |
|
|
|
|
| 76 |
|
| 77 |
fn technician_count(self) -> usize {
|
| 78 |
match self {
|
| 79 |
+
Self::Standard => 6,
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
}
|
| 82 |
|
| 83 |
fn visit_count(self) -> usize {
|
| 84 |
match self {
|
| 85 |
+
Self::Standard => SERVICE_LOCATIONS.len() * 2,
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
}
|
| 88 |
}
|
|
|
|
| 92 |
|
| 93 |
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
| 94 |
match s.to_ascii_uppercase().as_str() {
|
|
|
|
| 95 |
"STANDARD" => Ok(DemoData::Standard),
|
|
|
|
| 96 |
_ => Err(()),
|
| 97 |
}
|
| 98 |
}
|
|
|
|
| 100 |
|
| 101 |
pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError> {
|
| 102 |
let locations = build_locations(demo);
|
| 103 |
+
let travel_legs = build_seed_travel_legs(locations.len());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
let service_visits = build_service_visits(demo);
|
| 105 |
let technician_routes = build_technician_routes(demo);
|
| 106 |
|
|
|
|
| 112 |
))
|
| 113 |
}
|
| 114 |
|
| 115 |
+
pub async fn prepare_routing(plan: &mut FieldServicePlan) -> Result<(), DemoDataError> {
|
| 116 |
+
let coords = plan
|
| 117 |
+
.locations
|
| 118 |
+
.iter()
|
| 119 |
+
.map(|location| Coord::new(location.lat(), location.lng()))
|
| 120 |
+
.collect::<Vec<_>>();
|
| 121 |
+
let network = load_network().await?;
|
| 122 |
+
let matrix = network.compute_matrix(&coords, None).await;
|
| 123 |
+
plan.travel_legs = build_travel_legs(&matrix, coords.len());
|
| 124 |
+
Ok(())
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
pub async fn load_network() -> Result<NetworkRef, DemoDataError> {
|
| 128 |
+
RoadNetwork::load_or_fetch(&BERGAMO_BBOX, &network_config(), None)
|
| 129 |
+
.await
|
| 130 |
+
.map_err(DemoDataError::from)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
pub fn network_config() -> NetworkConfig {
|
| 134 |
NetworkConfig::default()
|
| 135 |
.cache_dir(PathBuf::from(".osm_cache/field-service-routing/bergamo"))
|
| 136 |
.connect_timeout(Duration::from_secs(10))
|
|
|
|
| 200 |
.collect()
|
| 201 |
}
|
| 202 |
|
| 203 |
+
fn build_seed_travel_legs(width: usize) -> Vec<TravelLeg> {
|
| 204 |
+
(0..width)
|
| 205 |
+
.map(|idx| {
|
| 206 |
+
TravelLeg::new(TravelLegInit {
|
| 207 |
+
id: format!("leg-{idx:02}-{idx:02}"),
|
| 208 |
+
name: format!("leg-{idx:02}-{idx:02}"),
|
| 209 |
+
from_location_idx: idx,
|
| 210 |
+
to_location_idx: idx,
|
| 211 |
+
duration_seconds: 0,
|
| 212 |
+
distance_meters: 0,
|
| 213 |
+
reachable: true,
|
| 214 |
+
})
|
| 215 |
+
})
|
| 216 |
+
.collect()
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
fn build_travel_legs(matrix: &solverforge_maps::TravelTimeMatrix, width: usize) -> Vec<TravelLeg> {
|
| 220 |
let mut legs = Vec::with_capacity(width * width);
|
| 221 |
|
| 222 |
for from in 0..width {
|
| 223 |
for to in 0..width {
|
| 224 |
+
let (duration_seconds, distance_meters, reachable) = if from == to {
|
| 225 |
+
(0, 0, true)
|
| 226 |
} else {
|
| 227 |
let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE);
|
| 228 |
+
let matrix_distance = matrix.distance_meters(from, to).unwrap_or(UNREACHABLE);
|
| 229 |
+
if matrix_duration == UNREACHABLE || matrix_distance == UNREACHABLE {
|
| 230 |
+
(0, 0, false)
|
| 231 |
} else {
|
| 232 |
+
(matrix_duration, matrix_distance, true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
}
|
| 234 |
};
|
| 235 |
|
|
|
|
| 240 |
to_location_idx: to,
|
| 241 |
duration_seconds,
|
| 242 |
distance_meters,
|
|
|
|
| 243 |
reachable,
|
| 244 |
}));
|
| 245 |
}
|
|
|
|
| 261 |
assert!(routes.iter().all(|route| route.visits.is_empty()));
|
| 262 |
}
|
| 263 |
}
|
| 264 |
+
|
| 265 |
+
#[tokio::test]
|
| 266 |
+
async fn generated_seed_plan_has_only_identity_travel_legs() {
|
| 267 |
+
let plan = generate(DemoData::Standard).await.unwrap();
|
| 268 |
+
|
| 269 |
+
assert_eq!(plan.travel_legs.len(), plan.locations.len());
|
| 270 |
+
assert!(plan.travel_legs.iter().enumerate().all(|(idx, leg)| {
|
| 271 |
+
leg.from_location_idx == idx
|
| 272 |
+
&& leg.to_location_idx == idx
|
| 273 |
+
&& leg.duration_seconds == 0
|
| 274 |
+
&& leg.distance_meters == 0
|
| 275 |
+
&& leg.reachable
|
| 276 |
+
}));
|
| 277 |
+
}
|
| 278 |
}
|
|
@@ -4,4 +4,7 @@ mod bergamo_profiles;
|
|
| 4 |
mod bergamo_technicians;
|
| 5 |
mod data_seed;
|
| 6 |
|
| 7 |
-
pub use data_seed::{
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
mod bergamo_technicians;
|
| 5 |
mod data_seed;
|
| 6 |
|
| 7 |
+
pub use data_seed::{
|
| 8 |
+
available_demo_data, default_demo_data, generate, load_network, prepare_routing, DemoData,
|
| 9 |
+
DemoDataError,
|
| 10 |
+
};
|
|
@@ -12,7 +12,6 @@ pub struct TravelLeg {
|
|
| 12 |
pub to_location_idx: usize,
|
| 13 |
pub duration_seconds: i64,
|
| 14 |
pub distance_meters: i64,
|
| 15 |
-
pub encoded_polyline: String,
|
| 16 |
pub reachable: bool,
|
| 17 |
}
|
| 18 |
|
|
@@ -24,7 +23,6 @@ pub struct TravelLegInit {
|
|
| 24 |
pub to_location_idx: usize,
|
| 25 |
pub duration_seconds: i64,
|
| 26 |
pub distance_meters: i64,
|
| 27 |
-
pub encoded_polyline: String,
|
| 28 |
pub reachable: bool,
|
| 29 |
}
|
| 30 |
|
|
@@ -37,7 +35,6 @@ impl TravelLeg {
|
|
| 37 |
to_location_idx: init.to_location_idx,
|
| 38 |
duration_seconds: init.duration_seconds,
|
| 39 |
distance_meters: init.distance_meters,
|
| 40 |
-
encoded_polyline: init.encoded_polyline,
|
| 41 |
reachable: init.reachable,
|
| 42 |
}
|
| 43 |
}
|
|
@@ -56,7 +53,6 @@ mod tests {
|
|
| 56 |
to_location_idx: Default::default(),
|
| 57 |
duration_seconds: Default::default(),
|
| 58 |
distance_meters: Default::default(),
|
| 59 |
-
encoded_polyline: "test".to_string(),
|
| 60 |
reachable: false,
|
| 61 |
});
|
| 62 |
assert_eq!(fact.id, "test-id");
|
|
@@ -65,7 +61,6 @@ mod tests {
|
|
| 65 |
let _ = &fact.to_location_idx;
|
| 66 |
let _ = &fact.duration_seconds;
|
| 67 |
let _ = &fact.distance_meters;
|
| 68 |
-
let _ = &fact.encoded_polyline;
|
| 69 |
let _ = &fact.reachable;
|
| 70 |
}
|
| 71 |
}
|
|
|
|
| 12 |
pub to_location_idx: usize,
|
| 13 |
pub duration_seconds: i64,
|
| 14 |
pub distance_meters: i64,
|
|
|
|
| 15 |
pub reachable: bool,
|
| 16 |
}
|
| 17 |
|
|
|
|
| 23 |
pub to_location_idx: usize,
|
| 24 |
pub duration_seconds: i64,
|
| 25 |
pub distance_meters: i64,
|
|
|
|
| 26 |
pub reachable: bool,
|
| 27 |
}
|
| 28 |
|
|
|
|
| 35 |
to_location_idx: init.to_location_idx,
|
| 36 |
duration_seconds: init.duration_seconds,
|
| 37 |
distance_meters: init.distance_meters,
|
|
|
|
| 38 |
reachable: init.reachable,
|
| 39 |
}
|
| 40 |
}
|
|
|
|
| 53 |
to_location_idx: Default::default(),
|
| 54 |
duration_seconds: Default::default(),
|
| 55 |
distance_meters: Default::default(),
|
|
|
|
| 56 |
reachable: false,
|
| 57 |
});
|
| 58 |
assert_eq!(fact.id, "test-id");
|
|
|
|
| 61 |
let _ = &fact.to_location_idx;
|
| 62 |
let _ = &fact.duration_seconds;
|
| 63 |
let _ = &fact.distance_meters;
|
|
|
|
| 64 |
let _ = &fact.reachable;
|
| 65 |
}
|
| 66 |
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* app-dataset.js - demo dataset
|
| 2 |
|
| 3 |
(function () {
|
| 4 |
'use strict';
|
|
@@ -6,41 +6,11 @@
|
|
| 6 |
var FSR = window.FSR = window.FSR || {};
|
| 7 |
|
| 8 |
FSR.createDemoDataController = function (options) {
|
| 9 |
-
var SF = options.SF;
|
| 10 |
var utils = options.utils;
|
| 11 |
-
var
|
| 12 |
-
var
|
| 13 |
var loading = false;
|
| 14 |
-
var
|
| 15 |
-
|
| 16 |
-
var select = SF.el('select', {
|
| 17 |
-
id: 'fsr-dataset-select',
|
| 18 |
-
'aria-label': 'Dataset',
|
| 19 |
-
style: {
|
| 20 |
-
minWidth: '160px',
|
| 21 |
-
padding: '8px 10px',
|
| 22 |
-
border: '1px solid #cbd5e1',
|
| 23 |
-
borderRadius: '6px',
|
| 24 |
-
background: '#ffffff',
|
| 25 |
-
},
|
| 26 |
-
});
|
| 27 |
-
var status = SF.el('span', { style: { color: '#64748b', fontSize: '13px' } }, 'Loading');
|
| 28 |
-
var el = SF.el('div', {
|
| 29 |
-
className: 'sf-content',
|
| 30 |
-
style: {
|
| 31 |
-
alignItems: 'center',
|
| 32 |
-
display: 'flex',
|
| 33 |
-
gap: '10px',
|
| 34 |
-
paddingBottom: '12px',
|
| 35 |
-
},
|
| 36 |
-
},
|
| 37 |
-
SF.el('label', { for: 'fsr-dataset-select', style: { fontWeight: '600' } }, 'Dataset'),
|
| 38 |
-
select,
|
| 39 |
-
status);
|
| 40 |
-
|
| 41 |
-
select.addEventListener('change', function () {
|
| 42 |
-
switchTo(select.value);
|
| 43 |
-
});
|
| 44 |
|
| 45 |
return {
|
| 46 |
bootstrap: bootstrap,
|
|
@@ -49,85 +19,28 @@
|
|
| 49 |
getSelectedId: function () { return selectedId; },
|
| 50 |
isLoading: function () { return loading; },
|
| 51 |
resolvePlan: resolvePlan,
|
| 52 |
-
setDisabled: setDisabled,
|
| 53 |
};
|
| 54 |
|
| 55 |
function bootstrap() {
|
| 56 |
-
setLoading(true
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
catalog = nextCatalog;
|
| 60 |
-
selectedId = nextCatalog.defaultId;
|
| 61 |
-
renderOptions();
|
| 62 |
-
notifyCatalog();
|
| 63 |
-
return utils.fetchDemoPlan(selectedId);
|
| 64 |
-
})
|
| 65 |
.then(function (plan) { notifyPlan(plan); })
|
| 66 |
.catch(function (err) { notifyError(err); })
|
| 67 |
-
.finally(function () { setLoading(false
|
| 68 |
}
|
| 69 |
|
| 70 |
function resolvePlan() {
|
| 71 |
var currentPlan = options.getCurrentPlan();
|
| 72 |
if (currentPlan) return Promise.resolve(utils.clonePlan(currentPlan));
|
| 73 |
-
if (!selectedId) return Promise.reject(new Error('demo data catalog is unavailable'));
|
| 74 |
return utils.fetchDemoPlan(selectedId);
|
| 75 |
}
|
| 76 |
|
| 77 |
-
function
|
| 78 |
-
if (!nextId || nextId === selectedId) return;
|
| 79 |
-
if (loading || (options.canSwitch && !options.canSwitch())) {
|
| 80 |
-
setSelected(selectedId);
|
| 81 |
-
return;
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
var previousId = selectedId;
|
| 85 |
-
setLoading(true, 'Loading ' + displayLabel(nextId));
|
| 86 |
-
Promise.resolve(options.cleanupTerminalJob ? options.cleanupTerminalJob() : null)
|
| 87 |
-
.then(function () { return utils.fetchDemoPlan(nextId); })
|
| 88 |
-
.then(function (plan) {
|
| 89 |
-
selectedId = nextId;
|
| 90 |
-
setSelected(nextId);
|
| 91 |
-
notifyCatalog();
|
| 92 |
-
notifyPlan(plan);
|
| 93 |
-
})
|
| 94 |
-
.catch(function (err) {
|
| 95 |
-
setSelected(previousId);
|
| 96 |
-
notifyError(err);
|
| 97 |
-
})
|
| 98 |
-
.finally(function () { setLoading(false, selectedId ? displayLabel(selectedId) : 'Unavailable'); });
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
-
function renderOptions() {
|
| 102 |
-
select.innerHTML = '';
|
| 103 |
-
catalog.availableIds.forEach(function (id) {
|
| 104 |
-
select.appendChild(SF.el('option', { value: id }, displayLabel(id)));
|
| 105 |
-
});
|
| 106 |
-
setSelected(selectedId);
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
function setSelected(nextId) {
|
| 110 |
-
selectedId = nextId;
|
| 111 |
-
select.value = nextId || '';
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
function setLoading(nextLoading, nextStatus) {
|
| 115 |
loading = nextLoading;
|
| 116 |
-
status.textContent = nextStatus || '';
|
| 117 |
-
applyDisabled();
|
| 118 |
if (options.onLoadingChange) options.onLoadingChange(loading);
|
| 119 |
}
|
| 120 |
|
| 121 |
-
function setDisabled(disabled) {
|
| 122 |
-
externallyDisabled = !!disabled;
|
| 123 |
-
applyDisabled();
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
function applyDisabled() {
|
| 127 |
-
select.disabled = loading || externallyDisabled || !catalog.availableIds.length;
|
| 128 |
-
select.setAttribute('aria-disabled', select.disabled ? 'true' : 'false');
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
function notifyCatalog() {
|
| 132 |
if (options.onCatalog) options.onCatalog(catalog, selectedId);
|
| 133 |
}
|
|
@@ -139,10 +52,5 @@
|
|
| 139 |
function notifyError(err) {
|
| 140 |
if (options.onError) options.onError(err);
|
| 141 |
}
|
| 142 |
-
|
| 143 |
-
function displayLabel(id) {
|
| 144 |
-
var value = String(id || '').toLowerCase();
|
| 145 |
-
return value.charAt(0).toUpperCase() + value.slice(1);
|
| 146 |
-
}
|
| 147 |
};
|
| 148 |
})();
|
|
|
|
| 1 |
+
/* app-dataset.js - demo dataset loader for the Bergamo FSR demo */
|
| 2 |
|
| 3 |
(function () {
|
| 4 |
'use strict';
|
|
|
|
| 6 |
var FSR = window.FSR = window.FSR || {};
|
| 7 |
|
| 8 |
FSR.createDemoDataController = function (options) {
|
|
|
|
| 9 |
var utils = options.utils;
|
| 10 |
+
var selectedId = 'STANDARD';
|
| 11 |
+
var catalog = { defaultId: selectedId, availableIds: [selectedId] };
|
| 12 |
var loading = false;
|
| 13 |
+
var el = document.createDocumentFragment();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
return {
|
| 16 |
bootstrap: bootstrap,
|
|
|
|
| 19 |
getSelectedId: function () { return selectedId; },
|
| 20 |
isLoading: function () { return loading; },
|
| 21 |
resolvePlan: resolvePlan,
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
function bootstrap() {
|
| 25 |
+
setLoading(true);
|
| 26 |
+
notifyCatalog();
|
| 27 |
+
return utils.fetchDemoPlan(selectedId)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
.then(function (plan) { notifyPlan(plan); })
|
| 29 |
.catch(function (err) { notifyError(err); })
|
| 30 |
+
.finally(function () { setLoading(false); });
|
| 31 |
}
|
| 32 |
|
| 33 |
function resolvePlan() {
|
| 34 |
var currentPlan = options.getCurrentPlan();
|
| 35 |
if (currentPlan) return Promise.resolve(utils.clonePlan(currentPlan));
|
|
|
|
| 36 |
return utils.fetchDemoPlan(selectedId);
|
| 37 |
}
|
| 38 |
|
| 39 |
+
function setLoading(nextLoading) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
loading = nextLoading;
|
|
|
|
|
|
|
| 41 |
if (options.onLoadingChange) options.onLoadingChange(loading);
|
| 42 |
}
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
function notifyCatalog() {
|
| 45 |
if (options.onCatalog) options.onCatalog(catalog, selectedId);
|
| 46 |
}
|
|
|
|
| 52 |
function notifyError(err) {
|
| 53 |
if (options.onError) options.onError(err);
|
| 54 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
};
|
| 56 |
})();
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-layout.js - application shell construction for the Bergamo FSR demo */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
|
| 8 |
+
FSR.createAppLayout = function (options) {
|
| 9 |
+
var SF = options.SF;
|
| 10 |
+
var app = options.app;
|
| 11 |
+
var panels = {
|
| 12 |
+
map: SF.el('div', { className: 'sf-content' }),
|
| 13 |
+
routes: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
|
| 14 |
+
data: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
|
| 15 |
+
api: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
var header = SF.createHeader({
|
| 19 |
+
logo: '/sf/img/ouroboros.svg',
|
| 20 |
+
title: options.config.title,
|
| 21 |
+
subtitle: options.config.subtitle,
|
| 22 |
+
tabs: [
|
| 23 |
+
{ id: 'map', label: 'Map', icon: 'fa-map-location-dot', active: true },
|
| 24 |
+
{ id: 'routes', label: 'Routes', icon: 'fa-list-ol' },
|
| 25 |
+
{ id: 'data', label: 'Data', icon: 'fa-table' },
|
| 26 |
+
{ id: 'api', label: 'REST API', icon: 'fa-book' },
|
| 27 |
+
],
|
| 28 |
+
actions: {
|
| 29 |
+
onSolve: options.onSolve,
|
| 30 |
+
onPause: options.onPause,
|
| 31 |
+
onResume: options.onResume,
|
| 32 |
+
onCancel: options.onCancel,
|
| 33 |
+
onAnalyze: options.onAnalyze,
|
| 34 |
+
},
|
| 35 |
+
onTabChange: function (tab) {
|
| 36 |
+
Object.keys(panels).forEach(function (key) {
|
| 37 |
+
panels[key].style.display = key === tab ? '' : 'none';
|
| 38 |
+
});
|
| 39 |
+
if (tab === 'map' && options.onMapTabShown) options.onMapTabShown();
|
| 40 |
+
},
|
| 41 |
+
});
|
| 42 |
+
|
| 43 |
+
app.appendChild(header);
|
| 44 |
+
options.statusBar.bindHeader(header);
|
| 45 |
+
app.appendChild(options.statusBar.el);
|
| 46 |
+
|
| 47 |
+
var bootstrapNotice = SF.el('div', {
|
| 48 |
+
className: 'sf-content',
|
| 49 |
+
style: {
|
| 50 |
+
display: 'none',
|
| 51 |
+
padding: '12px 16px',
|
| 52 |
+
marginBottom: '12px',
|
| 53 |
+
border: '1px solid #dc2626',
|
| 54 |
+
borderRadius: '8px',
|
| 55 |
+
background: '#fef2f2',
|
| 56 |
+
color: '#991b1b',
|
| 57 |
+
},
|
| 58 |
+
});
|
| 59 |
+
app.appendChild(bootstrapNotice);
|
| 60 |
+
|
| 61 |
+
var summaryContainer = SF.el('div', { className: 'fsr-summary' });
|
| 62 |
+
var mapShell = SF.el('div', { className: 'fsr-map-shell' });
|
| 63 |
+
var routeListCard = SF.el('section', { className: 'sf-section fsr-routes-card' });
|
| 64 |
+
var routeCards = SF.el('div', { className: 'fsr-route-list' });
|
| 65 |
+
var mapCard = SF.el('section', { className: 'sf-section fsr-map-card' });
|
| 66 |
+
var mapContainer = SF.el('div', { id: 'fsr-map', className: 'sf-map-container fsr-map' });
|
| 67 |
+
|
| 68 |
+
routeListCard.appendChild(SF.el('h3', null, 'Routes'));
|
| 69 |
+
routeListCard.appendChild(routeCards);
|
| 70 |
+
mapCard.appendChild(SF.el('h3', null, 'Map'));
|
| 71 |
+
mapCard.appendChild(mapContainer);
|
| 72 |
+
mapShell.appendChild(routeListCard);
|
| 73 |
+
mapShell.appendChild(mapCard);
|
| 74 |
+
panels.map.appendChild(summaryContainer);
|
| 75 |
+
panels.map.appendChild(mapShell);
|
| 76 |
+
|
| 77 |
+
var timelineContainer = SF.el('div');
|
| 78 |
+
var tablesContainer = SF.el('div');
|
| 79 |
+
var apiGuideContainer = SF.el('div');
|
| 80 |
+
panels.routes.appendChild(timelineContainer);
|
| 81 |
+
panels.data.appendChild(tablesContainer);
|
| 82 |
+
panels.api.appendChild(apiGuideContainer);
|
| 83 |
+
|
| 84 |
+
app.appendChild(panels.map);
|
| 85 |
+
app.appendChild(panels.routes);
|
| 86 |
+
app.appendChild(panels.data);
|
| 87 |
+
app.appendChild(panels.api);
|
| 88 |
+
app.appendChild(SF.createFooter({
|
| 89 |
+
links: [
|
| 90 |
+
{ label: 'SolverForge', url: 'https://www.solverforge.org' },
|
| 91 |
+
{ label: 'Docs', url: 'https://www.solverforge.org/docs' },
|
| 92 |
+
],
|
| 93 |
+
}));
|
| 94 |
+
|
| 95 |
+
return {
|
| 96 |
+
apiGuideContainer: apiGuideContainer,
|
| 97 |
+
bootstrapNotice: bootstrapNotice,
|
| 98 |
+
header: header,
|
| 99 |
+
routeCards: routeCards,
|
| 100 |
+
summaryContainer: summaryContainer,
|
| 101 |
+
tablesContainer: tablesContainer,
|
| 102 |
+
timelineContainer: timelineContainer,
|
| 103 |
+
};
|
| 104 |
+
};
|
| 105 |
+
})();
|
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-render-map.js - Leaflet map rendering for the Bergamo FSR demo */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
var utils = FSR.utils;
|
| 8 |
+
|
| 9 |
+
FSR.createMapRenderer = function (options) {
|
| 10 |
+
return { renderMap: renderMap };
|
| 11 |
+
|
| 12 |
+
function renderMap(plan, routeGeometry) {
|
| 13 |
+
if (!options.routeMap) return;
|
| 14 |
+
options.routeMap.clearAll();
|
| 15 |
+
|
| 16 |
+
var locations = plan.locations || [];
|
| 17 |
+
var visits = plan.service_visits || [];
|
| 18 |
+
var routes = plan.technician_routes || [];
|
| 19 |
+
var assigned = utils.assignedVisitSet(routes);
|
| 20 |
+
var focusedRouteId = options.getFocusedRouteId ? options.getFocusedRouteId() : null;
|
| 21 |
+
var fitPoints = [];
|
| 22 |
+
var stopNumbers = [];
|
| 23 |
+
var routeGeometryById = geometryByRouteId(routeGeometry);
|
| 24 |
+
|
| 25 |
+
routes.forEach(function (route) {
|
| 26 |
+
var start = locations[route.start_location_idx];
|
| 27 |
+
if (!start) return;
|
| 28 |
+
fitPoints.push([utils.locationLat(start), utils.locationLng(start)]);
|
| 29 |
+
options.routeMap.addVehicleMarker({
|
| 30 |
+
lat: utils.locationLat(start),
|
| 31 |
+
lng: utils.locationLng(start),
|
| 32 |
+
color: route.color || '#2563eb',
|
| 33 |
+
});
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
visits.forEach(function (visit, idx) {
|
| 37 |
+
var location = locations[visit.location_idx];
|
| 38 |
+
if (!location) return;
|
| 39 |
+
fitPoints.push([utils.locationLat(location), utils.locationLng(location)]);
|
| 40 |
+
options.routeMap.addVisitMarker({
|
| 41 |
+
lat: utils.locationLat(location),
|
| 42 |
+
lng: utils.locationLng(location),
|
| 43 |
+
color: assigned[idx] ? assigned[idx].color : '#64748b',
|
| 44 |
+
icon: utils.iconForVisit(visit),
|
| 45 |
+
assigned: !!assigned[idx],
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
routes.forEach(function (route, routeIdx) {
|
| 50 |
+
var routeId = routeKey(route, routeIdx);
|
| 51 |
+
var style = routeStyle(route, routeId, focusedRouteId);
|
| 52 |
+
drawRouteGeometry(routeGeometryById[routeId], route.color, style);
|
| 53 |
+
(route.visits || []).forEach(function (visitIdx, sequenceIdx) {
|
| 54 |
+
var visit = visits[visitIdx];
|
| 55 |
+
if (!visit) return;
|
| 56 |
+
if (!focusedRouteId || focusedRouteId === routeId) {
|
| 57 |
+
stopNumbers.push({
|
| 58 |
+
location: locations[visit.location_idx],
|
| 59 |
+
number: sequenceIdx + 1,
|
| 60 |
+
color: route.color,
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
});
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
placeStopNumbers(stopNumbers);
|
| 67 |
+
fitMapToPoints(fitPoints);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
function fitMapToPoints(points) {
|
| 71 |
+
if (!points.length || !options.routeMap) return;
|
| 72 |
+
if (options.routeMap.map && options.routeMap.map.invalidateSize) {
|
| 73 |
+
options.routeMap.map.invalidateSize();
|
| 74 |
+
}
|
| 75 |
+
if (window.L && options.routeMap.map && options.routeMap.map.fitBounds) {
|
| 76 |
+
options.routeMap.map.fitBounds(window.L.latLngBounds(points), {
|
| 77 |
+
maxZoom: 12,
|
| 78 |
+
padding: [70, 70],
|
| 79 |
+
});
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
options.routeMap.fitBounds();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function placeStopNumbers(stops) {
|
| 86 |
+
var groups = {};
|
| 87 |
+
stops.forEach(function (stop) {
|
| 88 |
+
if (!stop.location) return;
|
| 89 |
+
var key = stopLocationKey(stop.location);
|
| 90 |
+
if (!groups[key]) groups[key] = [];
|
| 91 |
+
groups[key].push(stop);
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
Object.keys(groups).forEach(function (key) {
|
| 95 |
+
var group = groups[key];
|
| 96 |
+
group.forEach(function (stop, idx) {
|
| 97 |
+
addStopNumber(stop.location, stop.number, stop.color, stopOffset(idx, group.length));
|
| 98 |
+
});
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function stopLocationKey(location) {
|
| 103 |
+
return [
|
| 104 |
+
utils.locationLat(location).toFixed(6),
|
| 105 |
+
utils.locationLng(location).toFixed(6),
|
| 106 |
+
].join(',');
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function stopOffset(index, count) {
|
| 110 |
+
if (count <= 1) return { x: 0, y: 0 };
|
| 111 |
+
var angle = (-Math.PI / 2) + ((Math.PI * 2 * index) / count);
|
| 112 |
+
var radius = count === 2 ? 14 : 18;
|
| 113 |
+
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function addStopNumber(location, number, color, offset) {
|
| 117 |
+
if (!location) return;
|
| 118 |
+
var marker = options.routeMap.addStopNumber({
|
| 119 |
+
lat: utils.locationLat(location),
|
| 120 |
+
lng: utils.locationLng(location),
|
| 121 |
+
number: number,
|
| 122 |
+
color: color || '#2563eb',
|
| 123 |
+
});
|
| 124 |
+
applyStopOffset(marker, offset || { x: 0, y: 0 });
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function applyStopOffset(marker, offset) {
|
| 128 |
+
if (!marker || (!offset.x && !offset.y)) return;
|
| 129 |
+
var element = marker.getElement ? marker.getElement() : marker._icon;
|
| 130 |
+
var stop = element && element.querySelector ? element.querySelector('.sf-marker-stop') : null;
|
| 131 |
+
if (stop) stop.style.transform = 'translate(' + offset.x.toFixed(1) + 'px, ' + offset.y.toFixed(1) + 'px)';
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function geometryByRouteId(routeGeometry) {
|
| 135 |
+
return ((routeGeometry && routeGeometry.routes) || []).reduce(function (index, route) {
|
| 136 |
+
index[String(route.routeId)] = route;
|
| 137 |
+
return index;
|
| 138 |
+
}, {});
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
function drawRouteGeometry(routeGeometry, color, style) {
|
| 142 |
+
if (!routeGeometry || !routeGeometry.segments) return;
|
| 143 |
+
routeGeometry.segments.forEach(function (segment) {
|
| 144 |
+
if (segment.geometryStatus !== 'ROUTED' || !segment.reachable || !segment.encodedPolyline) return;
|
| 145 |
+
options.routeMap.drawEncodedRoute({
|
| 146 |
+
encoded: segment.encodedPolyline,
|
| 147 |
+
color: color || '#2563eb',
|
| 148 |
+
opacity: style && style.opacity,
|
| 149 |
+
weight: style && style.weight,
|
| 150 |
+
});
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function routeKey(route, idx) {
|
| 155 |
+
return String(route.id || route.technician_name || ('route-' + idx));
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
function routeStyle(route, routeId, focusedRouteId) {
|
| 159 |
+
if (!focusedRouteId) return { color: route.color || '#2563eb', opacity: 0.82, weight: 3 };
|
| 160 |
+
return focusedRouteId === routeId
|
| 161 |
+
? { color: route.color || '#2563eb', opacity: 1, weight: 5 }
|
| 162 |
+
: { color: route.color || '#2563eb', opacity: 0.18, weight: 2 };
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
+
})();
|
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-render-routes.js - route list rendering for the Bergamo FSR demo */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
var utils = FSR.utils;
|
| 8 |
+
|
| 9 |
+
FSR.createRouteListRenderer = function (options) {
|
| 10 |
+
var SF = options.SF;
|
| 11 |
+
return { renderRouteCards: renderRouteCards };
|
| 12 |
+
|
| 13 |
+
function renderRouteCards(plan, routeGeometry) {
|
| 14 |
+
var routeGeometryById = geometryByRouteId(routeGeometry);
|
| 15 |
+
options.routeCards.innerHTML = '';
|
| 16 |
+
(plan.technician_routes || []).forEach(function (route, routeIdx) {
|
| 17 |
+
var stats = utils.routeStats(plan, route);
|
| 18 |
+
var routeId = routeKey(route, routeIdx);
|
| 19 |
+
var focusedRouteId = options.getFocusedRouteId ? options.getFocusedRouteId() : null;
|
| 20 |
+
var isFocused = focusedRouteId === routeId;
|
| 21 |
+
var card = SF.el('div', {
|
| 22 |
+
className: 'fsr-route-row' + (isFocused ? ' is-focused' : ''),
|
| 23 |
+
role: 'button',
|
| 24 |
+
tabIndex: 0,
|
| 25 |
+
dataset: { routeId: routeId },
|
| 26 |
+
});
|
| 27 |
+
card.addEventListener('click', function () { focusRoute(routeId); });
|
| 28 |
+
card.addEventListener('keydown', function (event) {
|
| 29 |
+
if (event.key === 'Enter' || event.key === ' ') {
|
| 30 |
+
event.preventDefault();
|
| 31 |
+
focusRoute(routeId);
|
| 32 |
+
}
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
var top = SF.el('div', { className: 'fsr-route-row__top' });
|
| 36 |
+
top.appendChild(SF.el('strong', null, route.technician_name || route.id || ('Technician ' + (routeIdx + 1))));
|
| 37 |
+
top.appendChild(SF.el('span', { className: 'fsr-route-tag' }, String((route.visits || []).length) + ' stops'));
|
| 38 |
+
card.appendChild(top);
|
| 39 |
+
|
| 40 |
+
var meta = SF.el('div', { className: 'fsr-route-row__meta' });
|
| 41 |
+
meta.appendChild(SF.el('span', null, utils.formatDuration(stats.travelMinutes) + ' travel'));
|
| 42 |
+
meta.appendChild(SF.el('span', null, utils.formatDuration(stats.serviceMinutes) + ' service'));
|
| 43 |
+
meta.appendChild(SF.el('span', null, route.territory || 'no territory'));
|
| 44 |
+
if (stats.lateMinutes) meta.appendChild(SF.el('span', null, utils.formatDuration(stats.lateMinutes) + ' late'));
|
| 45 |
+
if (stats.overtimeMinutes) meta.appendChild(SF.el('span', null, utils.formatDuration(stats.overtimeMinutes) + ' overtime'));
|
| 46 |
+
if (stats.unreachable || stats.missingSkills || stats.missingParts) {
|
| 47 |
+
meta.appendChild(SF.el('span', null, String(stats.unreachable + stats.missingSkills + stats.missingParts) + ' hard issues'));
|
| 48 |
+
}
|
| 49 |
+
if (hasGeometryGaps(routeGeometryById[routeId])) meta.appendChild(SF.el('span', null, 'Geometry gaps'));
|
| 50 |
+
card.appendChild(meta);
|
| 51 |
+
|
| 52 |
+
var action = SF.createButton({
|
| 53 |
+
text: isFocused ? 'Show All' : 'Highlight',
|
| 54 |
+
variant: isFocused ? 'default' : 'ghost',
|
| 55 |
+
});
|
| 56 |
+
action.addEventListener('click', function (event) {
|
| 57 |
+
event.stopPropagation();
|
| 58 |
+
focusRoute(routeId);
|
| 59 |
+
});
|
| 60 |
+
card.appendChild(SF.el('div', { className: 'fsr-route-row__actions' }, action));
|
| 61 |
+
options.routeCards.appendChild(card);
|
| 62 |
+
});
|
| 63 |
+
renderUnassignedCard(plan);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function renderUnassignedCard(plan) {
|
| 67 |
+
var assigned = utils.assignedVisitSet(plan.technician_routes || []);
|
| 68 |
+
var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
|
| 69 |
+
if (assigned[idx]) return items;
|
| 70 |
+
items.push([
|
| 71 |
+
visit.customer || visit.name || visit.id,
|
| 72 |
+
utils.timeLabel(visit.earliest_minute) + '-' + utils.timeLabel(visit.latest_minute),
|
| 73 |
+
utils.formatDuration(visit.duration_minutes || 0),
|
| 74 |
+
]);
|
| 75 |
+
return items;
|
| 76 |
+
}, []);
|
| 77 |
+
if (!rows.length) return;
|
| 78 |
+
|
| 79 |
+
var card = SF.el('div', { className: 'fsr-route-empty' });
|
| 80 |
+
card.appendChild(SF.el('strong', null, 'Unassigned visits'));
|
| 81 |
+
card.appendChild(SF.createTable({ columns: ['Visit', 'Window', 'Duration'], rows: rows }));
|
| 82 |
+
options.routeCards.appendChild(card);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function geometryByRouteId(routeGeometry) {
|
| 86 |
+
return ((routeGeometry && routeGeometry.routes) || []).reduce(function (index, route) {
|
| 87 |
+
index[String(route.routeId)] = route;
|
| 88 |
+
return index;
|
| 89 |
+
}, {});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function hasGeometryGaps(routeGeometry) {
|
| 93 |
+
return !!routeGeometry && (routeGeometry.segments || []).some(function (segment) {
|
| 94 |
+
return segment.geometryStatus !== 'ROUTED';
|
| 95 |
+
});
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function focusRoute(routeId) {
|
| 99 |
+
if (options.onFocusRoute) options.onFocusRoute(routeId);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function routeKey(route, idx) {
|
| 103 |
+
return String(route.id || route.technician_name || ('route-' + idx));
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
})();
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* app-render.js
|
| 2 |
|
| 3 |
(function () {
|
| 4 |
'use strict';
|
|
@@ -9,18 +9,20 @@
|
|
| 9 |
FSR.createRenderer = function (options) {
|
| 10 |
var SF = options.SF;
|
| 11 |
var routeTimeline = null;
|
|
|
|
|
|
|
| 12 |
|
| 13 |
return {
|
| 14 |
-
|
| 15 |
destroy: destroy,
|
| 16 |
renderAll: renderAll,
|
| 17 |
renderApiGuide: renderApiGuide,
|
| 18 |
};
|
| 19 |
|
| 20 |
-
function renderAll(plan) {
|
| 21 |
renderSummary(plan);
|
| 22 |
-
renderMap(plan);
|
| 23 |
-
renderRouteCards(plan);
|
| 24 |
renderTimeline(plan);
|
| 25 |
renderTables(plan);
|
| 26 |
}
|
|
@@ -53,106 +55,6 @@
|
|
| 53 |
}));
|
| 54 |
}
|
| 55 |
|
| 56 |
-
function renderMap(plan) {
|
| 57 |
-
if (!options.routeMap) return;
|
| 58 |
-
options.routeMap.clearAll();
|
| 59 |
-
|
| 60 |
-
var locations = plan.locations || [];
|
| 61 |
-
var visits = plan.service_visits || [];
|
| 62 |
-
var routes = plan.technician_routes || [];
|
| 63 |
-
var assigned = utils.assignedVisitSet(routes);
|
| 64 |
-
|
| 65 |
-
routes.forEach(function (route) {
|
| 66 |
-
var start = locations[route.start_location_idx];
|
| 67 |
-
if (!start) return;
|
| 68 |
-
options.routeMap.addVehicleMarker({
|
| 69 |
-
lat: utils.locationLat(start),
|
| 70 |
-
lng: utils.locationLng(start),
|
| 71 |
-
color: route.color || '#2563eb',
|
| 72 |
-
});
|
| 73 |
-
});
|
| 74 |
-
|
| 75 |
-
visits.forEach(function (visit, idx) {
|
| 76 |
-
var location = locations[visit.location_idx];
|
| 77 |
-
if (!location) return;
|
| 78 |
-
options.routeMap.addVisitMarker({
|
| 79 |
-
lat: utils.locationLat(location),
|
| 80 |
-
lng: utils.locationLng(location),
|
| 81 |
-
color: assigned[idx] ? assigned[idx].color : '#64748b',
|
| 82 |
-
icon: utils.iconForVisit(visit),
|
| 83 |
-
assigned: !!assigned[idx],
|
| 84 |
-
});
|
| 85 |
-
});
|
| 86 |
-
|
| 87 |
-
routes.forEach(function (route) {
|
| 88 |
-
var previous = route.start_location_idx;
|
| 89 |
-
(route.visits || []).forEach(function (visitIdx, sequenceIdx) {
|
| 90 |
-
var visit = visits[visitIdx];
|
| 91 |
-
if (!visit) return;
|
| 92 |
-
drawLeg(plan, previous, visit.location_idx, route.color);
|
| 93 |
-
addStopNumber(locations[visit.location_idx], sequenceIdx + 1, route.color);
|
| 94 |
-
previous = visit.location_idx;
|
| 95 |
-
});
|
| 96 |
-
drawLeg(plan, previous, route.end_location_idx, route.color);
|
| 97 |
-
});
|
| 98 |
-
|
| 99 |
-
options.routeMap.fitBounds();
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
-
function addStopNumber(location, number, color) {
|
| 103 |
-
if (!location) return;
|
| 104 |
-
options.routeMap.addStopNumber({
|
| 105 |
-
lat: utils.locationLat(location),
|
| 106 |
-
lng: utils.locationLng(location),
|
| 107 |
-
number: number,
|
| 108 |
-
color: color || '#2563eb',
|
| 109 |
-
});
|
| 110 |
-
}
|
| 111 |
-
|
| 112 |
-
function renderRouteCards(plan) {
|
| 113 |
-
options.routeCards.innerHTML = '';
|
| 114 |
-
renderUnassignedCard(plan);
|
| 115 |
-
(plan.technician_routes || []).forEach(function (route) {
|
| 116 |
-
var stats = utils.routeStats(plan, route);
|
| 117 |
-
var card = SF.el('div', {
|
| 118 |
-
className: 'sf-section',
|
| 119 |
-
style: { borderLeft: '4px solid ' + (route.color || '#2563eb'), padding: '12px', borderRadius: '8px' },
|
| 120 |
-
});
|
| 121 |
-
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, route.technician_name || route.id));
|
| 122 |
-
card.appendChild(SF.createTable({
|
| 123 |
-
columns: ['Stops', 'Travel', 'Service'],
|
| 124 |
-
rows: [[String((route.visits || []).length), utils.formatDuration(stats.travelMinutes), utils.formatDuration(stats.serviceMinutes)]],
|
| 125 |
-
}));
|
| 126 |
-
card.appendChild(SF.createTable({
|
| 127 |
-
columns: ['Territory', 'Late', 'Overtime'],
|
| 128 |
-
rows: [[route.territory || '-', utils.formatDuration(stats.lateMinutes), utils.formatDuration(stats.overtimeMinutes)]],
|
| 129 |
-
}));
|
| 130 |
-
options.routeCards.appendChild(card);
|
| 131 |
-
});
|
| 132 |
-
}
|
| 133 |
-
|
| 134 |
-
function renderUnassignedCard(plan) {
|
| 135 |
-
var assigned = utils.assignedVisitSet(plan.technician_routes || []);
|
| 136 |
-
var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
|
| 137 |
-
if (assigned[idx]) return items;
|
| 138 |
-
items.push([
|
| 139 |
-
visit.customer || visit.name || visit.id,
|
| 140 |
-
utils.timeLabel(visit.earliest_minute) + '-' + utils.timeLabel(visit.latest_minute),
|
| 141 |
-
utils.formatDuration(visit.duration_minutes || 0),
|
| 142 |
-
]);
|
| 143 |
-
return items;
|
| 144 |
-
}, []);
|
| 145 |
-
if (!rows.length) return;
|
| 146 |
-
|
| 147 |
-
var card = SF.el('div', {
|
| 148 |
-
className: 'sf-section',
|
| 149 |
-
style: { borderLeft: '4px solid #64748b', padding: '12px', borderRadius: '8px' },
|
| 150 |
-
});
|
| 151 |
-
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
|
| 152 |
-
card.appendChild(SF.createTable({ columns: ['Visit', 'Window', 'Duration'], rows: rows }));
|
| 153 |
-
options.routeCards.appendChild(card);
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
function renderTimeline(plan) {
|
| 157 |
var timelineConfig = buildTimelineConfig(plan);
|
| 158 |
options.timelineContainer.innerHTML = '';
|
|
@@ -226,7 +128,7 @@
|
|
| 226 |
['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
|
| 227 |
var rows = plan[key] || [];
|
| 228 |
if (!rows.length) return;
|
| 229 |
-
var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score'
|
| 230 |
var values = rows.map(function (row) {
|
| 231 |
return columns.map(function (column) {
|
| 232 |
var value = row[column];
|
|
@@ -249,30 +151,36 @@
|
|
| 249 |
options.apiGuideContainer.appendChild(SF.createApiGuide({
|
| 250 |
endpoints: [
|
| 251 |
{ method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') },
|
| 252 |
-
{ method: 'GET', path: '/demo-data/' + (demoId || '{id}'), description: 'Fetch Bergamo
|
| 253 |
{ method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) },
|
| 254 |
{ method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') },
|
| 255 |
{ method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') },
|
|
|
|
| 256 |
{ method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') },
|
| 257 |
],
|
| 258 |
}));
|
| 259 |
}
|
| 260 |
|
| 261 |
-
function
|
| 262 |
-
var
|
| 263 |
-
if (!
|
| 264 |
-
|
| 265 |
-
|
|
|
|
| 266 |
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
}
|
| 277 |
|
| 278 |
function destroy() {
|
|
|
|
| 1 |
+
/* app-render.js - top-level rendering coordinator for the Bergamo FSR demo */
|
| 2 |
|
| 3 |
(function () {
|
| 4 |
'use strict';
|
|
|
|
| 9 |
FSR.createRenderer = function (options) {
|
| 10 |
var SF = options.SF;
|
| 11 |
var routeTimeline = null;
|
| 12 |
+
var mapRenderer = FSR.createMapRenderer(options);
|
| 13 |
+
var routeListRenderer = FSR.createRouteListRenderer(options);
|
| 14 |
|
| 15 |
return {
|
| 16 |
+
buildAnalysisBody: buildAnalysisBody,
|
| 17 |
destroy: destroy,
|
| 18 |
renderAll: renderAll,
|
| 19 |
renderApiGuide: renderApiGuide,
|
| 20 |
};
|
| 21 |
|
| 22 |
+
function renderAll(plan, routeGeometry) {
|
| 23 |
renderSummary(plan);
|
| 24 |
+
mapRenderer.renderMap(plan, routeGeometry);
|
| 25 |
+
routeListRenderer.renderRouteCards(plan, routeGeometry);
|
| 26 |
renderTimeline(plan);
|
| 27 |
renderTables(plan);
|
| 28 |
}
|
|
|
|
| 55 |
}));
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
function renderTimeline(plan) {
|
| 59 |
var timelineConfig = buildTimelineConfig(plan);
|
| 60 |
options.timelineContainer.innerHTML = '';
|
|
|
|
| 128 |
['technician_routes', 'service_visits', 'locations'].forEach(function (key) {
|
| 129 |
var rows = plan[key] || [];
|
| 130 |
if (!rows.length) return;
|
| 131 |
+
var columns = Object.keys(rows[0]).filter(function (column) { return column !== 'score'; });
|
| 132 |
var values = rows.map(function (row) {
|
| 133 |
return columns.map(function (column) {
|
| 134 |
var value = row[column];
|
|
|
|
| 151 |
options.apiGuideContainer.appendChild(SF.createApiGuide({
|
| 152 |
endpoints: [
|
| 153 |
{ method: 'GET', path: '/demo-data', description: 'Discover demo datasets', curl: utils.buildCurl('GET', '/demo-data') },
|
| 154 |
+
{ method: 'GET', path: '/demo-data/' + (demoId || '{id}'), description: 'Fetch Bergamo seed data', curl: utils.buildCurl('GET', '/demo-data/' + (demoId || 'STANDARD')) },
|
| 155 |
{ method: 'POST', path: '/jobs', description: 'Create a retained solve job', curl: utils.buildCurl('POST', '/jobs', true) },
|
| 156 |
{ method: 'GET', path: '/jobs/{id}/events', description: 'Stream typed SolverForge lifecycle events', curl: utils.buildCurl('GET', '/jobs/{id}/events') },
|
| 157 |
{ method: 'GET', path: '/jobs/{id}/snapshot', description: 'Fetch latest route snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/snapshot') },
|
| 158 |
+
{ method: 'GET', path: '/jobs/{id}/routes?snapshot_revision={n}', description: 'Fetch encoded route geometry for a retained snapshot', curl: utils.buildCurl('GET', '/jobs/{id}/routes?snapshot_revision={n}') },
|
| 159 |
{ method: 'GET', path: '/jobs/{id}/analysis', description: 'Analyze the latest retained score', curl: utils.buildCurl('GET', '/jobs/{id}/analysis') },
|
| 160 |
],
|
| 161 |
}));
|
| 162 |
}
|
| 163 |
|
| 164 |
+
function buildAnalysisBody(analysis) {
|
| 165 |
+
var container = SF.el('div', { className: 'fsr-analysis-body' });
|
| 166 |
+
if (!analysis || !analysis.constraints) {
|
| 167 |
+
container.appendChild(SF.el('p', null, 'No analysis available.'));
|
| 168 |
+
return container;
|
| 169 |
+
}
|
| 170 |
|
| 171 |
+
container.appendChild(SF.el('p', null, SF.el('strong', null, 'Score: '), String(analysis.score)));
|
| 172 |
+
container.appendChild(SF.createTable({
|
| 173 |
+
columns: ['Constraint', 'Weight', 'Score', 'Matches'],
|
| 174 |
+
rows: analysis.constraints.map(function (constraint) {
|
| 175 |
+
return [
|
| 176 |
+
constraint.name,
|
| 177 |
+
constraint.weight,
|
| 178 |
+
constraint.score,
|
| 179 |
+
String(constraint.matchCount || 0),
|
| 180 |
+
];
|
| 181 |
+
}),
|
| 182 |
+
}));
|
| 183 |
+
return container;
|
| 184 |
}
|
| 185 |
|
| 186 |
function destroy() {
|
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app-route-state.js - snapshot-scoped route geometry loading */
|
| 2 |
+
|
| 3 |
+
(function () {
|
| 4 |
+
'use strict';
|
| 5 |
+
|
| 6 |
+
var FSR = window.FSR = window.FSR || {};
|
| 7 |
+
|
| 8 |
+
FSR.createRouteGeometryController = function (options) {
|
| 9 |
+
var currentPlanIdentity = null;
|
| 10 |
+
var currentRouteIdentity = null;
|
| 11 |
+
var routeInFlightIdentity = null;
|
| 12 |
+
var latestRouteIdentity = null;
|
| 13 |
+
var routeRequestToken = 0;
|
| 14 |
+
|
| 15 |
+
return {
|
| 16 |
+
identityFrom: identityFrom,
|
| 17 |
+
invalidate: invalidate,
|
| 18 |
+
load: load,
|
| 19 |
+
setPlanIdentity: setPlanIdentity,
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
function invalidate() {
|
| 23 |
+
routeRequestToken += 1;
|
| 24 |
+
currentPlanIdentity = null;
|
| 25 |
+
currentRouteIdentity = null;
|
| 26 |
+
routeInFlightIdentity = null;
|
| 27 |
+
latestRouteIdentity = null;
|
| 28 |
+
if (options.onClearFocus) options.onClearFocus();
|
| 29 |
+
options.onRoutesChange(null);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function setPlanIdentity(identity) {
|
| 33 |
+
currentPlanIdentity = identity;
|
| 34 |
+
if (!identityEquals(currentRouteIdentity, identity)) {
|
| 35 |
+
currentRouteIdentity = null;
|
| 36 |
+
options.onRoutesChange(null);
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function load(identity) {
|
| 41 |
+
if (!identity) return Promise.resolve();
|
| 42 |
+
latestRouteIdentity = identity;
|
| 43 |
+
if (routeInFlightIdentity || identityEquals(currentRouteIdentity, identity)) {
|
| 44 |
+
return Promise.resolve();
|
| 45 |
+
}
|
| 46 |
+
return fetchLatest();
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function fetchLatest() {
|
| 50 |
+
var identity = latestRouteIdentity;
|
| 51 |
+
if (!identity || routeInFlightIdentity || identityEquals(currentRouteIdentity, identity)) {
|
| 52 |
+
return Promise.resolve();
|
| 53 |
+
}
|
| 54 |
+
var token = routeRequestToken;
|
| 55 |
+
routeInFlightIdentity = identity;
|
| 56 |
+
return options.utils.fetchJobRoutes(identity.jobId, identity.snapshotRevision)
|
| 57 |
+
.then(function (routes) {
|
| 58 |
+
if (!responseStillCurrent(token, identity)) return;
|
| 59 |
+
currentRouteIdentity = identity;
|
| 60 |
+
options.onRoutesChange(routes);
|
| 61 |
+
})
|
| 62 |
+
.catch(function (err) {
|
| 63 |
+
if (!responseStillCurrent(token, identity)) return;
|
| 64 |
+
currentRouteIdentity = null;
|
| 65 |
+
options.onRoutesChange(null);
|
| 66 |
+
console.error('Route geometry failed:', err);
|
| 67 |
+
})
|
| 68 |
+
.then(function () {
|
| 69 |
+
if (identityEquals(routeInFlightIdentity, identity)) routeInFlightIdentity = null;
|
| 70 |
+
if (
|
| 71 |
+
latestRouteIdentity
|
| 72 |
+
&& !identityEquals(latestRouteIdentity, identity)
|
| 73 |
+
&& responseStillCurrent(token, latestRouteIdentity)
|
| 74 |
+
&& !identityEquals(currentRouteIdentity, latestRouteIdentity)
|
| 75 |
+
) {
|
| 76 |
+
return fetchLatest();
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function identityFrom(meta) {
|
| 82 |
+
var jobId = meta && meta.jobId != null ? meta.jobId : options.solver.getJobId();
|
| 83 |
+
var snapshotRevision = meta && meta.snapshotRevision != null
|
| 84 |
+
? meta.snapshotRevision
|
| 85 |
+
: options.solver.getSnapshotRevision();
|
| 86 |
+
if (jobId == null || jobId === '' || snapshotRevision == null) return null;
|
| 87 |
+
return { jobId: String(jobId), snapshotRevision: String(snapshotRevision) };
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function responseStillCurrent(token, identity) {
|
| 91 |
+
return token === routeRequestToken && identityEquals(currentPlanIdentity, identity);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function identityEquals(left, right) {
|
| 95 |
+
return !!left
|
| 96 |
+
&& !!right
|
| 97 |
+
&& left.jobId === right.jobId
|
| 98 |
+
&& left.snapshotRevision === right.snapshotRevision;
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
})();
|
|
@@ -11,6 +11,7 @@
|
|
| 11 |
clonePlan: clonePlan,
|
| 12 |
fetchDemoCatalog: fetchDemoCatalog,
|
| 13 |
fetchDemoPlan: fetchDemoPlan,
|
|
|
|
| 14 |
findHeaderButton: findHeaderButton,
|
| 15 |
formatDuration: formatDuration,
|
| 16 |
iconForVisit: iconForVisit,
|
|
@@ -47,6 +48,13 @@
|
|
| 47 |
return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
|
| 48 |
}
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
function requestJson(path, label) {
|
| 51 |
return fetch(path).then(function (response) {
|
| 52 |
if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status);
|
|
|
|
| 11 |
clonePlan: clonePlan,
|
| 12 |
fetchDemoCatalog: fetchDemoCatalog,
|
| 13 |
fetchDemoPlan: fetchDemoPlan,
|
| 14 |
+
fetchJobRoutes: fetchJobRoutes,
|
| 15 |
findHeaderButton: findHeaderButton,
|
| 16 |
formatDuration: formatDuration,
|
| 17 |
iconForVisit: iconForVisit,
|
|
|
|
| 48 |
return requestJson('/demo-data/' + encodeURIComponent(demoId), 'demo data "' + demoId + '"');
|
| 49 |
}
|
| 50 |
|
| 51 |
+
function fetchJobRoutes(jobId, snapshotRevision) {
|
| 52 |
+
return requestJson(
|
| 53 |
+
'/jobs/' + encodeURIComponent(jobId) + '/routes?snapshot_revision=' + encodeURIComponent(snapshotRevision),
|
| 54 |
+
'route geometry for job "' + jobId + '"'
|
| 55 |
+
);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
function requestJson(path, label) {
|
| 59 |
return fetch(path).then(function (response) {
|
| 60 |
if (!response.ok) throw new Error(label + ' returned HTTP ' + response.status);
|
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Bergamo FSR app shell and map layout */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
color-scheme: light;
|
| 5 |
+
--fsr-bg: #f6f8fb;
|
| 6 |
+
--fsr-surface: #ffffff;
|
| 7 |
+
--fsr-surface-muted: #f8fafc;
|
| 8 |
+
--fsr-text: #0f172a;
|
| 9 |
+
--fsr-text-muted: #475569;
|
| 10 |
+
--fsr-border: #d8e0ea;
|
| 11 |
+
--fsr-map-rail: #e7edf4;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
html,
|
| 15 |
+
body {
|
| 16 |
+
min-height: 100%;
|
| 17 |
+
margin: 0;
|
| 18 |
+
background: var(--fsr-bg);
|
| 19 |
+
color: var(--fsr-text);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
body {
|
| 23 |
+
color-scheme: light;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
#sf-app.fsr-app {
|
| 27 |
+
min-height: 100vh;
|
| 28 |
+
max-width: 100vw;
|
| 29 |
+
overflow-x: hidden;
|
| 30 |
+
background: var(--fsr-bg);
|
| 31 |
+
color: var(--fsr-text);
|
| 32 |
+
color-scheme: light;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.fsr-app,
|
| 36 |
+
.fsr-app * {
|
| 37 |
+
box-sizing: border-box;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
.fsr-app .sf-content {
|
| 41 |
+
width: 100%;
|
| 42 |
+
padding: 14px 24px;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.fsr-app .sf-section {
|
| 46 |
+
background: var(--fsr-surface);
|
| 47 |
+
border: 1px solid var(--fsr-border);
|
| 48 |
+
color: var(--fsr-text);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.fsr-app .sf-statusbar,
|
| 52 |
+
.fsr-app .sf-footer {
|
| 53 |
+
background: var(--fsr-surface-muted);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.fsr-app .sf-table-container,
|
| 57 |
+
.fsr-app .sf-table,
|
| 58 |
+
.fsr-app .sf-modal,
|
| 59 |
+
.fsr-app select,
|
| 60 |
+
.fsr-app input,
|
| 61 |
+
.fsr-app textarea,
|
| 62 |
+
.fsr-app button {
|
| 63 |
+
color-scheme: light;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.fsr-app .sf-table-container,
|
| 67 |
+
.fsr-app .sf-table {
|
| 68 |
+
background: var(--fsr-surface);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.fsr-app .sf-table th {
|
| 72 |
+
background: #f1f5f9;
|
| 73 |
+
color: #334155;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.fsr-app .sf-table td {
|
| 77 |
+
background: var(--fsr-surface);
|
| 78 |
+
color: var(--fsr-text);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.fsr-summary {
|
| 82 |
+
padding-bottom: 10px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.fsr-map-shell {
|
| 86 |
+
display: grid;
|
| 87 |
+
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
| 88 |
+
gap: 16px;
|
| 89 |
+
align-items: stretch;
|
| 90 |
+
min-height: clamp(520px, 67vh, 760px);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.fsr-map {
|
| 94 |
+
flex: 1;
|
| 95 |
+
min-height: clamp(520px, 67vh, 760px);
|
| 96 |
+
overflow: hidden;
|
| 97 |
+
border-radius: 8px;
|
| 98 |
+
background: #dbe7d3;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.fsr-map-card,
|
| 102 |
+
.fsr-routes-card {
|
| 103 |
+
display: flex;
|
| 104 |
+
flex-direction: column;
|
| 105 |
+
min-height: 100%;
|
| 106 |
+
padding: 16px;
|
| 107 |
+
border-radius: 8px;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.fsr-map-card h3,
|
| 111 |
+
.fsr-routes-card h3 {
|
| 112 |
+
margin: 0 0 10px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.fsr-route-list {
|
| 116 |
+
display: grid;
|
| 117 |
+
gap: 10px;
|
| 118 |
+
align-content: start;
|
| 119 |
+
max-height: clamp(520px, 67vh, 760px);
|
| 120 |
+
overflow: auto;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.fsr-route-row,
|
| 124 |
+
.fsr-route-empty {
|
| 125 |
+
display: grid;
|
| 126 |
+
gap: 8px;
|
| 127 |
+
padding: 12px;
|
| 128 |
+
border: 1px solid rgba(15, 23, 42, 0.1);
|
| 129 |
+
border-radius: 8px;
|
| 130 |
+
background: #ffffff;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.fsr-route-row {
|
| 134 |
+
cursor: pointer;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.fsr-route-row.is-focused {
|
| 138 |
+
border-color: rgba(16, 185, 129, 0.58);
|
| 139 |
+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.13);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.fsr-route-row:focus-visible {
|
| 143 |
+
outline: 3px solid rgba(16, 185, 129, 0.35);
|
| 144 |
+
outline-offset: 2px;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.fsr-route-row__top,
|
| 148 |
+
.fsr-route-row__meta,
|
| 149 |
+
.fsr-route-row__actions {
|
| 150 |
+
display: flex;
|
| 151 |
+
flex-wrap: wrap;
|
| 152 |
+
gap: 8px;
|
| 153 |
+
align-items: center;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.fsr-route-row__top {
|
| 157 |
+
justify-content: space-between;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.fsr-route-row__meta {
|
| 161 |
+
color: var(--fsr-text-muted);
|
| 162 |
+
font-size: 13px;
|
| 163 |
+
line-height: 1.35;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.fsr-route-tag {
|
| 167 |
+
border-radius: 999px;
|
| 168 |
+
background: #ecfdf5;
|
| 169 |
+
color: #047857;
|
| 170 |
+
font-size: 12px;
|
| 171 |
+
font-weight: 700;
|
| 172 |
+
padding: 3px 8px;
|
| 173 |
+
white-space: nowrap;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
.fsr-route-row .sf-btn--ghost {
|
| 177 |
+
background: #f8fafc;
|
| 178 |
+
border: 1px solid rgba(15, 23, 42, 0.1);
|
| 179 |
+
color: var(--fsr-text);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.fsr-route-row .sf-btn--ghost:hover {
|
| 183 |
+
background: #eef2f7;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.fsr-route-empty {
|
| 187 |
+
color: var(--fsr-text);
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.fsr-map .leaflet-container,
|
| 191 |
+
.fsr-map .leaflet-pane,
|
| 192 |
+
.fsr-map .leaflet-tile-pane {
|
| 193 |
+
background: #dbe7d3;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
@media (max-width: 980px) {
|
| 197 |
+
.fsr-app .sf-content {
|
| 198 |
+
padding-inline: 14px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.fsr-map-shell {
|
| 202 |
+
grid-template-columns: 1fr;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.fsr-route-list {
|
| 206 |
+
max-height: none;
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
@media (max-width: 760px) {
|
| 211 |
+
.fsr-app .sf-header {
|
| 212 |
+
height: auto;
|
| 213 |
+
min-height: var(--sf-header-height);
|
| 214 |
+
flex-wrap: wrap;
|
| 215 |
+
gap: 8px 12px;
|
| 216 |
+
padding: 10px 14px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.fsr-app .sf-header-brand {
|
| 220 |
+
flex: 1 1 calc(100% - 70px);
|
| 221 |
+
min-width: 0;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.fsr-app .sf-header-title,
|
| 225 |
+
.fsr-app .sf-header-subtitle {
|
| 226 |
+
overflow: hidden;
|
| 227 |
+
text-overflow: ellipsis;
|
| 228 |
+
white-space: nowrap;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.fsr-app .sf-header-nav {
|
| 232 |
+
order: 3;
|
| 233 |
+
flex: 1 1 100%;
|
| 234 |
+
margin-left: 0;
|
| 235 |
+
overflow-x: auto;
|
| 236 |
+
padding-bottom: 2px;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.fsr-app .sf-header-actions {
|
| 240 |
+
order: 4;
|
| 241 |
+
margin-left: 0;
|
| 242 |
+
overflow-x: auto;
|
| 243 |
+
padding-bottom: 2px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.fsr-app .sf-statusbar {
|
| 247 |
+
overflow-x: auto;
|
| 248 |
+
padding-inline: 14px;
|
| 249 |
+
}
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
@media (prefers-color-scheme: dark) {
|
| 253 |
+
:root {
|
| 254 |
+
color-scheme: light;
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
html,
|
| 258 |
+
body,
|
| 259 |
+
#sf-app.fsr-app {
|
| 260 |
+
background: var(--fsr-bg);
|
| 261 |
+
color: var(--fsr-text);
|
| 262 |
+
}
|
| 263 |
+
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
/* app.js
|
| 2 |
|
| 3 |
(async function () {
|
| 4 |
'use strict';
|
|
@@ -11,71 +11,40 @@
|
|
| 11 |
var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
|
| 12 |
var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
|
| 13 |
var app = document.getElementById('sf-app');
|
|
|
|
|
|
|
| 14 |
var backend = SF.createBackend({ baseUrl: '' });
|
| 15 |
var statusBar = SF.createStatusBar({ constraints: uiModel.constraints || [] });
|
| 16 |
-
|
| 17 |
var currentPlan = null;
|
|
|
|
| 18 |
var demoData = null;
|
| 19 |
var bootstrapError = null;
|
| 20 |
var lastAnalysis = null;
|
| 21 |
var routeMap = null;
|
| 22 |
var renderer = null;
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
var
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
{ id: 'api', label: 'REST API', icon: 'fa-book' },
|
| 40 |
-
],
|
| 41 |
-
actions: {
|
| 42 |
-
onSolve: function () { loadAndSolve(); },
|
| 43 |
-
onPause: function () { pauseSolve(); },
|
| 44 |
-
onResume: function () { resumeSolve(); },
|
| 45 |
-
onCancel: function () { cancelSolve(); },
|
| 46 |
-
onAnalyze: function () { openAnalysis(); },
|
| 47 |
-
},
|
| 48 |
-
onTabChange: function (tab) {
|
| 49 |
-
Object.keys(panels).forEach(function (key) {
|
| 50 |
-
panels[key].style.display = key === tab ? '' : 'none';
|
| 51 |
-
});
|
| 52 |
-
if (tab === 'map' && routeMap) {
|
| 53 |
-
window.setTimeout(function () { routeMap.fitBounds(); }, 80);
|
| 54 |
-
}
|
| 55 |
},
|
| 56 |
});
|
| 57 |
|
| 58 |
-
app.appendChild(header);
|
| 59 |
-
statusBar.bindHeader(header);
|
| 60 |
-
app.appendChild(statusBar.el);
|
| 61 |
-
|
| 62 |
-
var bootstrapNotice = SF.el('div', {
|
| 63 |
-
className: 'sf-content',
|
| 64 |
-
style: {
|
| 65 |
-
display: 'none',
|
| 66 |
-
padding: '12px 16px',
|
| 67 |
-
marginBottom: '12px',
|
| 68 |
-
border: '1px solid #dc2626',
|
| 69 |
-
borderRadius: '8px',
|
| 70 |
-
background: '#fef2f2',
|
| 71 |
-
color: '#991b1b',
|
| 72 |
-
},
|
| 73 |
-
});
|
| 74 |
-
app.appendChild(bootstrapNotice);
|
| 75 |
demoData = window.FSR.createDemoDataController({
|
| 76 |
SF: SF,
|
| 77 |
-
canSwitch: canSwitchDemoData,
|
| 78 |
-
cleanupTerminalJob: cleanupTerminalJob,
|
| 79 |
getCurrentPlan: function () { return currentPlan; },
|
| 80 |
onCatalog: function () { if (renderer) renderer.renderApiGuide(); },
|
| 81 |
onError: reportBootstrapError,
|
|
@@ -83,62 +52,23 @@
|
|
| 83 |
onPlan: handleDemoPlanLoaded,
|
| 84 |
utils: utils,
|
| 85 |
});
|
| 86 |
-
app.
|
| 87 |
-
|
| 88 |
-
var summaryContainer = SF.el('div');
|
| 89 |
-
var mapShell = SF.el('div', {
|
| 90 |
-
className: 'sf-section',
|
| 91 |
-
style: {
|
| 92 |
-
display: 'grid',
|
| 93 |
-
gridTemplateColumns: 'minmax(360px, 1fr) 340px',
|
| 94 |
-
gap: '16px',
|
| 95 |
-
alignItems: 'stretch',
|
| 96 |
-
},
|
| 97 |
-
});
|
| 98 |
-
var mapContainer = SF.el('div', {
|
| 99 |
-
id: 'fsr-map',
|
| 100 |
-
className: 'sf-map-container',
|
| 101 |
-
style: { minHeight: '560px', borderRadius: '8px' },
|
| 102 |
-
});
|
| 103 |
-
var routeCards = SF.el('div', {
|
| 104 |
-
style: { display: 'grid', gap: '10px', alignContent: 'start', maxHeight: '560px', overflow: 'auto' },
|
| 105 |
-
});
|
| 106 |
-
mapShell.appendChild(mapContainer);
|
| 107 |
-
mapShell.appendChild(routeCards);
|
| 108 |
-
panels.map.appendChild(summaryContainer);
|
| 109 |
-
panels.map.appendChild(mapShell);
|
| 110 |
-
|
| 111 |
-
var timelineContainer = SF.el('div');
|
| 112 |
-
var tablesContainer = SF.el('div');
|
| 113 |
-
var apiGuideContainer = SF.el('div');
|
| 114 |
-
panels.routes.appendChild(timelineContainer);
|
| 115 |
-
panels.data.appendChild(tablesContainer);
|
| 116 |
-
panels.api.appendChild(apiGuideContainer);
|
| 117 |
-
|
| 118 |
-
app.appendChild(panels.map);
|
| 119 |
-
app.appendChild(panels.routes);
|
| 120 |
-
app.appendChild(panels.data);
|
| 121 |
-
app.appendChild(panels.api);
|
| 122 |
-
app.appendChild(SF.createFooter({
|
| 123 |
-
links: [
|
| 124 |
-
{ label: 'SolverForge', url: 'https://www.solverforge.org' },
|
| 125 |
-
{ label: 'Docs', url: 'https://www.solverforge.org/docs' },
|
| 126 |
-
],
|
| 127 |
-
}));
|
| 128 |
|
| 129 |
routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
|
| 130 |
renderer = window.FSR.createRenderer({
|
| 131 |
SF: SF,
|
| 132 |
-
apiGuideContainer: apiGuideContainer,
|
| 133 |
dayEnd: DAY_END,
|
| 134 |
dayStart: DAY_START,
|
|
|
|
| 135 |
getDemoCatalog: function () { return demoData.getCatalog(); },
|
| 136 |
getSelectedDemoId: function () { return demoData.getSelectedId(); },
|
| 137 |
-
|
|
|
|
| 138 |
routeMap: routeMap,
|
| 139 |
-
summaryContainer: summaryContainer,
|
| 140 |
-
tablesContainer: tablesContainer,
|
| 141 |
-
timelineContainer: timelineContainer,
|
| 142 |
});
|
| 143 |
|
| 144 |
var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
|
|
@@ -148,24 +78,24 @@
|
|
| 148 |
onProgress: function (meta) { syncLifecycleMarkers(meta); },
|
| 149 |
onPauseRequested: function (meta) { syncLifecycleMarkers(meta); },
|
| 150 |
onSolution: function (snapshot, meta) {
|
| 151 |
-
renderSnapshot(snapshot);
|
| 152 |
syncLifecycleMarkers(meta);
|
| 153 |
},
|
| 154 |
onPaused: function (snapshot, meta) {
|
| 155 |
-
renderSnapshot(snapshot);
|
| 156 |
syncLifecycleMarkers(meta);
|
| 157 |
},
|
| 158 |
onResumed: function (meta) { syncLifecycleMarkers(meta); },
|
| 159 |
onCancelled: function (snapshot, meta) {
|
| 160 |
-
renderSnapshot(snapshot);
|
| 161 |
syncLifecycleMarkers(meta);
|
| 162 |
},
|
| 163 |
onComplete: function (snapshot, meta) {
|
| 164 |
-
renderSnapshot(snapshot);
|
| 165 |
syncLifecycleMarkers(meta);
|
| 166 |
},
|
| 167 |
onFailure: function (message, meta, snapshot, analysis) {
|
| 168 |
-
renderSnapshot(snapshot);
|
| 169 |
if (analysis) lastAnalysis = analysis;
|
| 170 |
console.error('Solver job failed:', message);
|
| 171 |
syncLifecycleMarkers(meta);
|
|
@@ -180,6 +110,16 @@
|
|
| 180 |
},
|
| 181 |
});
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
renderer.renderApiGuide();
|
| 184 |
updateSolveActionAvailability();
|
| 185 |
demoData.bootstrap();
|
|
@@ -187,6 +127,7 @@
|
|
| 187 |
|
| 188 |
function loadAndSolve() {
|
| 189 |
if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
|
|
|
|
| 190 |
cleanupTerminalJob()
|
| 191 |
.then(function () { return demoData.resolvePlan(); })
|
| 192 |
.then(function (data) { return solver.start(data); })
|
|
@@ -207,11 +148,12 @@
|
|
| 207 |
}
|
| 208 |
|
| 209 |
function openAnalysis() {
|
| 210 |
-
|
|
|
|
| 211 |
solver.analyzeSnapshot()
|
| 212 |
.then(function (analysis) {
|
| 213 |
lastAnalysis = analysis;
|
| 214 |
-
analysisModal.setBody(renderer.
|
| 215 |
analysisModal.open();
|
| 216 |
})
|
| 217 |
.catch(function (err) { console.error('Analysis failed:', err); });
|
|
@@ -219,7 +161,8 @@
|
|
| 219 |
|
| 220 |
function cleanupTerminalJob() {
|
| 221 |
var state = solver.getLifecycleState();
|
| 222 |
-
|
|
|
|
| 223 |
return Promise.resolve(null);
|
| 224 |
}
|
| 225 |
return solver.delete().then(function () {
|
|
@@ -228,12 +171,18 @@
|
|
| 228 |
});
|
| 229 |
}
|
| 230 |
|
| 231 |
-
function renderSnapshot(snapshot) {
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
}
|
| 234 |
|
| 235 |
function handleDemoPlanLoaded(plan) {
|
| 236 |
lastAnalysis = null;
|
|
|
|
| 237 |
clearBootstrapError();
|
| 238 |
renderPlan(plan);
|
| 239 |
renderer.renderApiGuide();
|
|
@@ -242,21 +191,37 @@
|
|
| 242 |
|
| 243 |
function renderPlan(plan) {
|
| 244 |
currentPlan = utils.clonePlan(plan);
|
| 245 |
-
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
-
function
|
| 249 |
-
|
| 250 |
}
|
| 251 |
|
| 252 |
-
function
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
function reportBootstrapError(err) {
|
| 257 |
bootstrapError = err && err.message ? err.message : String(err || 'unknown error');
|
| 258 |
-
bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
|
| 259 |
-
bootstrapNotice.style.display = '';
|
| 260 |
app.dataset.bootstrapError = 'true';
|
| 261 |
updateSolveActionAvailability();
|
| 262 |
console.error('Demo data bootstrap failed:', err);
|
|
@@ -264,13 +229,13 @@
|
|
| 264 |
|
| 265 |
function clearBootstrapError() {
|
| 266 |
bootstrapError = null;
|
| 267 |
-
bootstrapNotice.textContent = '';
|
| 268 |
-
bootstrapNotice.style.display = 'none';
|
| 269 |
delete app.dataset.bootstrapError;
|
| 270 |
}
|
| 271 |
|
| 272 |
function updateSolveActionAvailability() {
|
| 273 |
-
var solveButton = utils.findHeaderButton(header, 'Solve');
|
| 274 |
if (!solveButton) return;
|
| 275 |
var disabled = !canSolve();
|
| 276 |
solveButton.disabled = disabled;
|
|
@@ -278,7 +243,6 @@
|
|
| 278 |
solveButton.title = disabled
|
| 279 |
? (bootstrapError ? 'Bergamo road data could not be loaded.' : 'Loading Bergamo demo data...')
|
| 280 |
: '';
|
| 281 |
-
if (demoData) demoData.setDisabled(!canSwitchDemoData());
|
| 282 |
}
|
| 283 |
|
| 284 |
function syncLifecycleMarkers(meta) {
|
|
|
|
| 1 |
+
/* app.js - lifecycle bootstrap for the Bergamo field service routing demo */
|
| 2 |
|
| 3 |
(async function () {
|
| 4 |
'use strict';
|
|
|
|
| 11 |
var config = await fetch('/sf-config.json').then(function (response) { return response.json(); });
|
| 12 |
var uiModel = await fetch('/generated/ui-model.json').then(function (response) { return response.json(); });
|
| 13 |
var app = document.getElementById('sf-app');
|
| 14 |
+
app.className = 'sf-app fsr-app';
|
| 15 |
+
|
| 16 |
var backend = SF.createBackend({ baseUrl: '' });
|
| 17 |
var statusBar = SF.createStatusBar({ constraints: uiModel.constraints || [] });
|
|
|
|
| 18 |
var currentPlan = null;
|
| 19 |
+
var currentRoutes = null;
|
| 20 |
var demoData = null;
|
| 21 |
var bootstrapError = null;
|
| 22 |
var lastAnalysis = null;
|
| 23 |
var routeMap = null;
|
| 24 |
var renderer = null;
|
| 25 |
+
var routeGeometry = null;
|
| 26 |
+
var focusedRouteId = null;
|
| 27 |
|
| 28 |
+
var layout = window.FSR.createAppLayout({
|
| 29 |
+
SF: SF,
|
| 30 |
+
app: app,
|
| 31 |
+
config: config,
|
| 32 |
+
statusBar: statusBar,
|
| 33 |
+
onSolve: function () { loadAndSolve(); },
|
| 34 |
+
onPause: function () { pauseSolve(); },
|
| 35 |
+
onResume: function () { resumeSolve(); },
|
| 36 |
+
onCancel: function () { cancelSolve(); },
|
| 37 |
+
onAnalyze: function () { openAnalysis(); },
|
| 38 |
+
onMapTabShown: function () {
|
| 39 |
+
window.setTimeout(function () {
|
| 40 |
+
if (routeMap && routeMap.map && routeMap.map.invalidateSize) routeMap.map.invalidateSize();
|
| 41 |
+
renderCurrentPlan();
|
| 42 |
+
}, 80);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
},
|
| 44 |
});
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
demoData = window.FSR.createDemoDataController({
|
| 47 |
SF: SF,
|
|
|
|
|
|
|
| 48 |
getCurrentPlan: function () { return currentPlan; },
|
| 49 |
onCatalog: function () { if (renderer) renderer.renderApiGuide(); },
|
| 50 |
onError: reportBootstrapError,
|
|
|
|
| 52 |
onPlan: handleDemoPlanLoaded,
|
| 53 |
utils: utils,
|
| 54 |
});
|
| 55 |
+
app.insertBefore(demoData.el, layout.bootstrapNotice.nextSibling);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
routeMap = SF.map.create({ container: 'fsr-map', center: DEFAULT_CENTER, zoom: 13 });
|
| 58 |
renderer = window.FSR.createRenderer({
|
| 59 |
SF: SF,
|
| 60 |
+
apiGuideContainer: layout.apiGuideContainer,
|
| 61 |
dayEnd: DAY_END,
|
| 62 |
dayStart: DAY_START,
|
| 63 |
+
getFocusedRouteId: function () { return focusedRouteId; },
|
| 64 |
getDemoCatalog: function () { return demoData.getCatalog(); },
|
| 65 |
getSelectedDemoId: function () { return demoData.getSelectedId(); },
|
| 66 |
+
onFocusRoute: focusRoute,
|
| 67 |
+
routeCards: layout.routeCards,
|
| 68 |
routeMap: routeMap,
|
| 69 |
+
summaryContainer: layout.summaryContainer,
|
| 70 |
+
tablesContainer: layout.tablesContainer,
|
| 71 |
+
timelineContainer: layout.timelineContainer,
|
| 72 |
});
|
| 73 |
|
| 74 |
var analysisModal = SF.createModal({ title: 'Score Analysis', width: '760px' });
|
|
|
|
| 78 |
onProgress: function (meta) { syncLifecycleMarkers(meta); },
|
| 79 |
onPauseRequested: function (meta) { syncLifecycleMarkers(meta); },
|
| 80 |
onSolution: function (snapshot, meta) {
|
| 81 |
+
renderSnapshot(snapshot, meta);
|
| 82 |
syncLifecycleMarkers(meta);
|
| 83 |
},
|
| 84 |
onPaused: function (snapshot, meta) {
|
| 85 |
+
renderSnapshot(snapshot, meta);
|
| 86 |
syncLifecycleMarkers(meta);
|
| 87 |
},
|
| 88 |
onResumed: function (meta) { syncLifecycleMarkers(meta); },
|
| 89 |
onCancelled: function (snapshot, meta) {
|
| 90 |
+
renderSnapshot(snapshot, meta);
|
| 91 |
syncLifecycleMarkers(meta);
|
| 92 |
},
|
| 93 |
onComplete: function (snapshot, meta) {
|
| 94 |
+
renderSnapshot(snapshot, meta);
|
| 95 |
syncLifecycleMarkers(meta);
|
| 96 |
},
|
| 97 |
onFailure: function (message, meta, snapshot, analysis) {
|
| 98 |
+
renderSnapshot(snapshot, meta);
|
| 99 |
if (analysis) lastAnalysis = analysis;
|
| 100 |
console.error('Solver job failed:', message);
|
| 101 |
syncLifecycleMarkers(meta);
|
|
|
|
| 110 |
},
|
| 111 |
});
|
| 112 |
|
| 113 |
+
routeGeometry = window.FSR.createRouteGeometryController({
|
| 114 |
+
solver: solver,
|
| 115 |
+
utils: utils,
|
| 116 |
+
onClearFocus: function () { focusedRouteId = null; },
|
| 117 |
+
onRoutesChange: function (routes) {
|
| 118 |
+
currentRoutes = routes;
|
| 119 |
+
renderCurrentPlan();
|
| 120 |
+
},
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
renderer.renderApiGuide();
|
| 124 |
updateSolveActionAvailability();
|
| 125 |
demoData.bootstrap();
|
|
|
|
| 127 |
|
| 128 |
function loadAndSolve() {
|
| 129 |
if (solver.isRunning() || solver.getLifecycleState() === 'PAUSED' || !canSolve()) return;
|
| 130 |
+
routeGeometry.invalidate();
|
| 131 |
cleanupTerminalJob()
|
| 132 |
.then(function () { return demoData.resolvePlan(); })
|
| 133 |
.then(function (data) { return solver.start(data); })
|
|
|
|
| 148 |
}
|
| 149 |
|
| 150 |
function openAnalysis() {
|
| 151 |
+
var jobId = solver.getJobId();
|
| 152 |
+
if (jobId == null || jobId === '') return;
|
| 153 |
solver.analyzeSnapshot()
|
| 154 |
.then(function (analysis) {
|
| 155 |
lastAnalysis = analysis;
|
| 156 |
+
analysisModal.setBody(renderer.buildAnalysisBody(analysis));
|
| 157 |
analysisModal.open();
|
| 158 |
})
|
| 159 |
.catch(function (err) { console.error('Analysis failed:', err); });
|
|
|
|
| 161 |
|
| 162 |
function cleanupTerminalJob() {
|
| 163 |
var state = solver.getLifecycleState();
|
| 164 |
+
var jobId = solver.getJobId();
|
| 165 |
+
if (jobId == null || jobId === '' || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
|
| 166 |
return Promise.resolve(null);
|
| 167 |
}
|
| 168 |
return solver.delete().then(function () {
|
|
|
|
| 171 |
});
|
| 172 |
}
|
| 173 |
|
| 174 |
+
function renderSnapshot(snapshot, meta) {
|
| 175 |
+
var identity = routeGeometry.identityFrom(meta);
|
| 176 |
+
if (snapshot && snapshot.solution) {
|
| 177 |
+
routeGeometry.setPlanIdentity(identity);
|
| 178 |
+
renderPlan(snapshot.solution);
|
| 179 |
+
}
|
| 180 |
+
routeGeometry.load(identity);
|
| 181 |
}
|
| 182 |
|
| 183 |
function handleDemoPlanLoaded(plan) {
|
| 184 |
lastAnalysis = null;
|
| 185 |
+
routeGeometry.invalidate();
|
| 186 |
clearBootstrapError();
|
| 187 |
renderPlan(plan);
|
| 188 |
renderer.renderApiGuide();
|
|
|
|
| 191 |
|
| 192 |
function renderPlan(plan) {
|
| 193 |
currentPlan = utils.clonePlan(plan);
|
| 194 |
+
if (focusedRouteId && !hasRoute(currentPlan, focusedRouteId)) focusedRouteId = null;
|
| 195 |
+
renderCurrentPlan();
|
| 196 |
}
|
| 197 |
|
| 198 |
+
function renderCurrentPlan() {
|
| 199 |
+
if (renderer && currentPlan) renderer.renderAll(currentPlan, currentRoutes);
|
| 200 |
}
|
| 201 |
|
| 202 |
+
function focusRoute(routeId) {
|
| 203 |
+
focusedRouteId = focusedRouteId === routeId ? null : routeId;
|
| 204 |
+
renderCurrentPlan();
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function hasRoute(plan, routeId) {
|
| 208 |
+
return (plan.technician_routes || []).some(function (route, idx) {
|
| 209 |
+
return routeKey(route, idx) === routeId;
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function routeKey(route, idx) {
|
| 214 |
+
return String(route.id || route.technician_name || ('route-' + idx));
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function canSolve() {
|
| 218 |
+
return !bootstrapError && !!currentPlan && !demoData.isLoading();
|
| 219 |
}
|
| 220 |
|
| 221 |
function reportBootstrapError(err) {
|
| 222 |
bootstrapError = err && err.message ? err.message : String(err || 'unknown error');
|
| 223 |
+
layout.bootstrapNotice.textContent = 'Demo data bootstrap failed: ' + bootstrapError;
|
| 224 |
+
layout.bootstrapNotice.style.display = '';
|
| 225 |
app.dataset.bootstrapError = 'true';
|
| 226 |
updateSolveActionAvailability();
|
| 227 |
console.error('Demo data bootstrap failed:', err);
|
|
|
|
| 229 |
|
| 230 |
function clearBootstrapError() {
|
| 231 |
bootstrapError = null;
|
| 232 |
+
layout.bootstrapNotice.textContent = '';
|
| 233 |
+
layout.bootstrapNotice.style.display = 'none';
|
| 234 |
delete app.dataset.bootstrapError;
|
| 235 |
}
|
| 236 |
|
| 237 |
function updateSolveActionAvailability() {
|
| 238 |
+
var solveButton = utils.findHeaderButton(layout.header, 'Solve');
|
| 239 |
if (!solveButton) return;
|
| 240 |
var disabled = !canSolve();
|
| 241 |
solveButton.disabled = disabled;
|
|
|
|
| 243 |
solveButton.title = disabled
|
| 244 |
? (bootstrapError ? 'Bergamo road data could not be loaded.' : 'Loading Bergamo demo data...')
|
| 245 |
: '';
|
|
|
|
| 246 |
}
|
| 247 |
|
| 248 |
function syncLifecycleMarkers(meta) {
|
|
@@ -4,20 +4,25 @@
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>solverforge-fsr — SolverForge</title>
|
| 7 |
-
<link rel="stylesheet" href="/sf/sf.css">
|
| 8 |
<link rel="stylesheet" href="/sf/vendor/fontawesome/css/fontawesome.min.css">
|
| 9 |
<link rel="stylesheet" href="/sf/vendor/fontawesome/css/solid.min.css">
|
| 10 |
<link rel="stylesheet" href="/sf/vendor/leaflet/leaflet.css">
|
| 11 |
<link rel="stylesheet" href="/sf/modules/sf-map.css">
|
|
|
|
| 12 |
<link rel="icon" href="/sf/img/ouroboros.svg" type="image/svg+xml">
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
-
<div id="sf-app"></div>
|
| 16 |
<script src="/sf/vendor/leaflet/leaflet.js"></script>
|
| 17 |
-
<script src="/sf/sf.js"></script>
|
| 18 |
<script src="/sf/modules/sf-map.js"></script>
|
| 19 |
<script src="/app-utils.js"></script>
|
| 20 |
<script src="/app-dataset.js"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
<script src="/app-render.js"></script>
|
| 22 |
<script src="/app.js"></script>
|
| 23 |
</body>
|
|
|
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
<title>solverforge-fsr — SolverForge</title>
|
| 7 |
+
<link rel="stylesheet" href="/sf/sf.css?v=fsr-local-ui">
|
| 8 |
<link rel="stylesheet" href="/sf/vendor/fontawesome/css/fontawesome.min.css">
|
| 9 |
<link rel="stylesheet" href="/sf/vendor/fontawesome/css/solid.min.css">
|
| 10 |
<link rel="stylesheet" href="/sf/vendor/leaflet/leaflet.css">
|
| 11 |
<link rel="stylesheet" href="/sf/modules/sf-map.css">
|
| 12 |
+
<link rel="stylesheet" href="/app.css?v=fsr-standard-only">
|
| 13 |
<link rel="icon" href="/sf/img/ouroboros.svg" type="image/svg+xml">
|
| 14 |
</head>
|
| 15 |
<body>
|
| 16 |
+
<div id="sf-app" class="sf-app fsr-app"></div>
|
| 17 |
<script src="/sf/vendor/leaflet/leaflet.js"></script>
|
| 18 |
+
<script src="/sf/sf.js?v=fsr-local-ui"></script>
|
| 19 |
<script src="/sf/modules/sf-map.js"></script>
|
| 20 |
<script src="/app-utils.js"></script>
|
| 21 |
<script src="/app-dataset.js"></script>
|
| 22 |
+
<script src="/app-layout.js"></script>
|
| 23 |
+
<script src="/app-route-state.js"></script>
|
| 24 |
+
<script src="/app-render-map.js"></script>
|
| 25 |
+
<script src="/app-render-routes.js"></script>
|
| 26 |
<script src="/app-render.js"></script>
|
| 27 |
<script src="/app.js"></script>
|
| 28 |
</body>
|