blackopsrepl commited on
Commit
ae32abe
·
1 Parent(s): 757f418

feat(fsr): add snapshot-scoped route geometry

Browse files

Harden 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 CHANGED
@@ -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
Cargo.lock CHANGED
@@ -1620,9 +1620,9 @@ dependencies = [
1620
 
1621
  [[package]]
1622
  name = "solverforge-maps"
1623
- version = "2.1.3"
1624
  source = "registry+https://github.com/rust-lang/crates.io-index"
1625
- checksum = "939b91fd4706c75795ef82db3a569fa47868d72763c48e24e992c00df242dbe0"
1626
  dependencies = [
1627
  "rayon",
1628
  "reqwest",
@@ -1664,9 +1664,9 @@ dependencies = [
1664
 
1665
  [[package]]
1666
  name = "solverforge-ui"
1667
- version = "0.6.4"
1668
  source = "registry+https://github.com/rust-lang/crates.io-index"
1669
- checksum = "7fa4894a0295ef1b538d0c25cb5a8ca0a93a97936421df4ac169154cd3d2a533"
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",
Cargo.toml CHANGED
@@ -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.4" }
15
- solverforge-maps = { version = "2.1.3" }
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"] }
README.md CHANGED
@@ -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.4`
10
- - SolverForge maps target for this scaffold: `solverforge-maps 2.1.3`
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.4`
13
- - Maps dependency currently wired into `Cargo.toml`: `crates.io: solverforge-maps 2.1.3`
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
 
solverforge.app.toml CHANGED
@@ -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.4"
 
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]
src/api/mod.rs CHANGED
@@ -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
 
src/api/route_dto.rs ADDED
@@ -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
+ }
src/api/route_geometry.rs ADDED
@@ -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
+ }
src/api/routes.rs CHANGED
@@ -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>,
src/constraints/balance_workload.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{balance_workload_score, RouteConstraint};
 
 
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
  }
src/constraints/minimize_travel.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{minimize_travel_score, RouteConstraint};
 
 
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
  }
src/constraints/priority_slack.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{priority_slack_score, RouteConstraint};
 
 
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
  }
src/constraints/reachable_legs.rs CHANGED
@@ -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
  }
src/constraints/required_parts.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{required_parts_score, RouteConstraint};
 
 
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
  }
src/constraints/required_skills.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{required_skills_score, RouteConstraint};
 
 
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
  }
src/constraints/route_constraint.rs CHANGED
@@ -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
- .filter(|route| (self.scorer)(solution, route) != HardSoftScore::ZERO)
50
- .count()
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 {
src/constraints/route_metrics.rs CHANGED
@@ -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
+ }
src/constraints/route_metrics_tests.rs CHANGED
@@ -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
  })
src/constraints/shift_capacity.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{shift_capacity_score, RouteConstraint};
 
 
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
  }
src/constraints/territory_affinity.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{territory_affinity_score, RouteConstraint};
 
 
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
  }
src/constraints/time_windows.rs CHANGED
@@ -1,4 +1,6 @@
1
- use crate::constraints::route_metrics::{time_windows_score, RouteConstraint};
 
 
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
  }
src/data/data_seed.rs CHANGED
@@ -4,7 +4,7 @@ use std::str::FromStr;
4
  use std::time::Duration;
5
 
6
  use solverforge_maps::{
7
- encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
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::Small, DemoData::Standard, DemoData::Large];
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::Small => 2,
84
- Self::Standard => 4,
85
- Self::Large => 6,
86
  }
87
  }
88
 
89
  fn visit_count(self) -> usize {
90
  match self {
91
- Self::Small => 6,
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 coords = locations
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 network_config() -> NetworkConfig {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 build_travel_legs(
202
- network: &RoadNetwork,
203
- matrix: &solverforge_maps::TravelTimeMatrix,
204
- coords: &[Coord],
205
- ) -> Vec<TravelLeg> {
206
- let width = coords.len();
 
 
 
 
 
 
 
 
 
 
 
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, encoded_polyline, reachable) = if from == to {
212
- (0, 0, encode_polyline(&[coords[from]]), true)
213
  } else {
214
  let matrix_duration = matrix.get(from, to).unwrap_or(UNREACHABLE);
215
- if matrix_duration == UNREACHABLE {
216
- (0, 0, String::new(), false)
 
217
  } else {
218
- match network.route(coords[from], coords[to]) {
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
  }
src/data/mod.rs CHANGED
@@ -4,4 +4,7 @@ mod bergamo_profiles;
4
  mod bergamo_technicians;
5
  mod data_seed;
6
 
7
- pub use data_seed::{available_demo_data, default_demo_data, generate, DemoData, DemoDataError};
 
 
 
 
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
+ };
src/domain/travel_leg.rs CHANGED
@@ -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
  }
static/app-dataset.js CHANGED
@@ -1,4 +1,4 @@
1
- /* app-dataset.js - demo dataset picker for the Bergamo FSR demo */
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 catalog = { defaultId: null, availableIds: [] };
12
- var selectedId = null;
13
  var loading = false;
14
- var externallyDisabled = false;
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, 'Loading');
57
- return utils.fetchDemoCatalog()
58
- .then(function (nextCatalog) {
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, selectedId ? displayLabel(selectedId) : 'Unavailable'); });
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 switchTo(nextId) {
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
  })();
static/app-layout.js ADDED
@@ -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
+ })();
static/app-render-map.js ADDED
@@ -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
+ })();
static/app-render-routes.js ADDED
@@ -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
+ })();
static/app-render.js CHANGED
@@ -1,4 +1,4 @@
1
- /* app-render.js rendering layer for the Bergamo FSR demo */
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
- buildAnalysisHtml: buildAnalysisHtml,
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' && column !== 'encoded_polyline'; });
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 OSM-backed demo data', curl: utils.buildCurl('GET', '/demo-data/' + (demoId || 'STANDARD')) },
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 drawLeg(plan, from, to, color) {
262
- var leg = utils.legFor(plan, from, to);
263
- if (!leg || !leg.reachable || !leg.encoded_polyline) return;
264
- options.routeMap.drawEncodedRoute({ encoded: leg.encoded_polyline, color: color || '#2563eb' });
265
- }
 
266
 
267
- function buildAnalysisHtml(analysis) {
268
- if (!analysis || !analysis.constraints) return '<p>No analysis available.</p>';
269
- var html = '<p><strong>Score:</strong> ' + SF.escHtml(analysis.score) + '</p>';
270
- html += '<table class="sf-table"><thead><tr><th>Constraint</th><th>Weight</th><th>Score</th><th>Matches</th></tr></thead><tbody>';
271
- analysis.constraints.forEach(function (constraint) {
272
- html += '<tr><td>' + SF.escHtml(constraint.name) + '</td><td>' + SF.escHtml(constraint.weight) + '</td><td>' + SF.escHtml(constraint.score) + '</td><td>' + String(constraint.matchCount || 0) + '</td></tr>';
273
- });
274
- html += '</tbody></table>';
275
- return html;
 
 
 
 
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() {
static/app-route-state.js ADDED
@@ -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
+ })();
static/app-utils.js CHANGED
@@ -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);
static/app.css ADDED
@@ -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
+ }
static/app.js CHANGED
@@ -1,4 +1,4 @@
1
- /* app.js lifecycle bootstrap for the Bergamo field service routing demo */
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 panels = {
25
- map: SF.el('div', { className: 'sf-content' }),
26
- routes: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
27
- data: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
28
- api: SF.el('div', { className: 'sf-content', style: { display: 'none' } }),
29
- };
30
-
31
- var header = SF.createHeader({
32
- logo: '/sf/img/ouroboros.svg',
33
- title: config.title,
34
- subtitle: config.subtitle,
35
- tabs: [
36
- { id: 'map', label: 'Map', icon: 'fa-map-location-dot', active: true },
37
- { id: 'routes', label: 'Routes', icon: 'fa-list-ol' },
38
- { id: 'data', label: 'Data', icon: 'fa-table' },
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.appendChild(demoData.el);
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
- routeCards: routeCards,
 
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
- if (!solver.getJobId()) return;
 
211
  solver.analyzeSnapshot()
212
  .then(function (analysis) {
213
  lastAnalysis = analysis;
214
- analysisModal.setBody(renderer.buildAnalysisHtml(analysis));
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
- if (!solver.getJobId() || state === 'IDLE' || state === 'PAUSED' || solver.isRunning()) {
 
223
  return Promise.resolve(null);
224
  }
225
  return solver.delete().then(function () {
@@ -228,12 +171,18 @@
228
  });
229
  }
230
 
231
- function renderSnapshot(snapshot) {
232
- if (snapshot && snapshot.solution) renderPlan(snapshot.solution);
 
 
 
 
 
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
- renderer.renderAll(plan);
 
246
  }
247
 
248
- function canSolve() {
249
- return !bootstrapError && !!currentPlan && !demoData.isLoading();
250
  }
251
 
252
- function canSwitchDemoData() {
253
- return !solver.isRunning() && solver.getLifecycleState() !== 'PAUSED';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {
static/index.html CHANGED
@@ -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>