blackopsrepl commited on
Commit
44dceed
·
verified ·
1 Parent(s): 88735a2

Upload 24 files

Browse files
Cargo.lock ADDED
@@ -0,0 +1,2484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "adler2"
7
+ version = "2.0.1"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
+
11
+ [[package]]
12
+ name = "aho-corasick"
13
+ version = "1.1.4"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
16
+ dependencies = [
17
+ "memchr",
18
+ ]
19
+
20
+ [[package]]
21
+ name = "android_system_properties"
22
+ version = "0.1.5"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
25
+ dependencies = [
26
+ "libc",
27
+ ]
28
+
29
+ [[package]]
30
+ name = "arbitrary"
31
+ version = "1.4.2"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
34
+ dependencies = [
35
+ "derive_arbitrary",
36
+ ]
37
+
38
+ [[package]]
39
+ name = "arrayvec"
40
+ version = "0.7.6"
41
+ source = "registry+https://github.com/rust-lang/crates.io-index"
42
+ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
43
+
44
+ [[package]]
45
+ name = "async-stream"
46
+ version = "0.3.6"
47
+ source = "registry+https://github.com/rust-lang/crates.io-index"
48
+ checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
49
+ dependencies = [
50
+ "async-stream-impl",
51
+ "futures-core",
52
+ "pin-project-lite",
53
+ ]
54
+
55
+ [[package]]
56
+ name = "async-stream-impl"
57
+ version = "0.3.6"
58
+ source = "registry+https://github.com/rust-lang/crates.io-index"
59
+ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
60
+ dependencies = [
61
+ "proc-macro2",
62
+ "quote",
63
+ "syn",
64
+ ]
65
+
66
+ [[package]]
67
+ name = "atomic-waker"
68
+ version = "1.1.2"
69
+ source = "registry+https://github.com/rust-lang/crates.io-index"
70
+ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
71
+
72
+ [[package]]
73
+ name = "autocfg"
74
+ version = "1.5.0"
75
+ source = "registry+https://github.com/rust-lang/crates.io-index"
76
+ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
77
+
78
+ [[package]]
79
+ name = "axum"
80
+ version = "0.8.8"
81
+ source = "registry+https://github.com/rust-lang/crates.io-index"
82
+ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
83
+ dependencies = [
84
+ "axum-core",
85
+ "bytes",
86
+ "form_urlencoded",
87
+ "futures-util",
88
+ "http",
89
+ "http-body",
90
+ "http-body-util",
91
+ "hyper",
92
+ "hyper-util",
93
+ "itoa",
94
+ "matchit",
95
+ "memchr",
96
+ "mime",
97
+ "percent-encoding",
98
+ "pin-project-lite",
99
+ "serde_core",
100
+ "serde_json",
101
+ "serde_path_to_error",
102
+ "serde_urlencoded",
103
+ "sync_wrapper",
104
+ "tokio",
105
+ "tower",
106
+ "tower-layer",
107
+ "tower-service",
108
+ "tracing",
109
+ ]
110
+
111
+ [[package]]
112
+ name = "axum-core"
113
+ version = "0.5.6"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
116
+ dependencies = [
117
+ "bytes",
118
+ "futures-core",
119
+ "http",
120
+ "http-body",
121
+ "http-body-util",
122
+ "mime",
123
+ "pin-project-lite",
124
+ "sync_wrapper",
125
+ "tower-layer",
126
+ "tower-service",
127
+ "tracing",
128
+ ]
129
+
130
+ [[package]]
131
+ name = "base64"
132
+ version = "0.22.1"
133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
134
+ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
135
+
136
+ [[package]]
137
+ name = "bitflags"
138
+ version = "2.10.0"
139
+ source = "registry+https://github.com/rust-lang/crates.io-index"
140
+ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
141
+
142
+ [[package]]
143
+ name = "block-buffer"
144
+ version = "0.10.4"
145
+ source = "registry+https://github.com/rust-lang/crates.io-index"
146
+ checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
147
+ dependencies = [
148
+ "generic-array",
149
+ ]
150
+
151
+ [[package]]
152
+ name = "bumpalo"
153
+ version = "3.19.1"
154
+ source = "registry+https://github.com/rust-lang/crates.io-index"
155
+ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
156
+
157
+ [[package]]
158
+ name = "bytes"
159
+ version = "1.11.0"
160
+ source = "registry+https://github.com/rust-lang/crates.io-index"
161
+ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
162
+
163
+ [[package]]
164
+ name = "cc"
165
+ version = "1.2.51"
166
+ source = "registry+https://github.com/rust-lang/crates.io-index"
167
+ checksum = "7a0aeaff4ff1a90589618835a598e545176939b97874f7abc7851caa0618f203"
168
+ dependencies = [
169
+ "find-msvc-tools",
170
+ "shlex",
171
+ ]
172
+
173
+ [[package]]
174
+ name = "cfg-if"
175
+ version = "1.0.4"
176
+ source = "registry+https://github.com/rust-lang/crates.io-index"
177
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
178
+
179
+ [[package]]
180
+ name = "cfg_aliases"
181
+ version = "0.2.1"
182
+ source = "registry+https://github.com/rust-lang/crates.io-index"
183
+ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
184
+
185
+ [[package]]
186
+ name = "chrono"
187
+ version = "0.4.42"
188
+ source = "registry+https://github.com/rust-lang/crates.io-index"
189
+ checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
190
+ dependencies = [
191
+ "iana-time-zone",
192
+ "js-sys",
193
+ "num-traits",
194
+ "serde",
195
+ "wasm-bindgen",
196
+ "windows-link",
197
+ ]
198
+
199
+ [[package]]
200
+ name = "core-foundation-sys"
201
+ version = "0.8.7"
202
+ source = "registry+https://github.com/rust-lang/crates.io-index"
203
+ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
204
+
205
+ [[package]]
206
+ name = "cpufeatures"
207
+ version = "0.2.17"
208
+ source = "registry+https://github.com/rust-lang/crates.io-index"
209
+ checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
210
+ dependencies = [
211
+ "libc",
212
+ ]
213
+
214
+ [[package]]
215
+ name = "crc32fast"
216
+ version = "1.5.0"
217
+ source = "registry+https://github.com/rust-lang/crates.io-index"
218
+ checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
219
+ dependencies = [
220
+ "cfg-if",
221
+ ]
222
+
223
+ [[package]]
224
+ name = "crossbeam-deque"
225
+ version = "0.8.6"
226
+ source = "registry+https://github.com/rust-lang/crates.io-index"
227
+ checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
228
+ dependencies = [
229
+ "crossbeam-epoch",
230
+ "crossbeam-utils",
231
+ ]
232
+
233
+ [[package]]
234
+ name = "crossbeam-epoch"
235
+ version = "0.9.18"
236
+ source = "registry+https://github.com/rust-lang/crates.io-index"
237
+ checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
238
+ dependencies = [
239
+ "crossbeam-utils",
240
+ ]
241
+
242
+ [[package]]
243
+ name = "crossbeam-utils"
244
+ version = "0.8.21"
245
+ source = "registry+https://github.com/rust-lang/crates.io-index"
246
+ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
247
+
248
+ [[package]]
249
+ name = "crypto-common"
250
+ version = "0.1.7"
251
+ source = "registry+https://github.com/rust-lang/crates.io-index"
252
+ checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
253
+ dependencies = [
254
+ "generic-array",
255
+ "typenum",
256
+ ]
257
+
258
+ [[package]]
259
+ name = "derive_arbitrary"
260
+ version = "1.4.2"
261
+ source = "registry+https://github.com/rust-lang/crates.io-index"
262
+ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
263
+ dependencies = [
264
+ "proc-macro2",
265
+ "quote",
266
+ "syn",
267
+ ]
268
+
269
+ [[package]]
270
+ name = "digest"
271
+ version = "0.10.7"
272
+ source = "registry+https://github.com/rust-lang/crates.io-index"
273
+ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
274
+ dependencies = [
275
+ "block-buffer",
276
+ "crypto-common",
277
+ ]
278
+
279
+ [[package]]
280
+ name = "displaydoc"
281
+ version = "0.2.5"
282
+ source = "registry+https://github.com/rust-lang/crates.io-index"
283
+ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
284
+ dependencies = [
285
+ "proc-macro2",
286
+ "quote",
287
+ "syn",
288
+ ]
289
+
290
+ [[package]]
291
+ name = "either"
292
+ version = "1.15.0"
293
+ source = "registry+https://github.com/rust-lang/crates.io-index"
294
+ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
295
+
296
+ [[package]]
297
+ name = "equivalent"
298
+ version = "1.0.2"
299
+ source = "registry+https://github.com/rust-lang/crates.io-index"
300
+ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
301
+
302
+ [[package]]
303
+ name = "errno"
304
+ version = "0.3.14"
305
+ source = "registry+https://github.com/rust-lang/crates.io-index"
306
+ checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
307
+ dependencies = [
308
+ "libc",
309
+ "windows-sys 0.61.2",
310
+ ]
311
+
312
+ [[package]]
313
+ name = "find-msvc-tools"
314
+ version = "0.1.6"
315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
316
+ checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff"
317
+
318
+ [[package]]
319
+ name = "fixedbitset"
320
+ version = "0.4.2"
321
+ source = "registry+https://github.com/rust-lang/crates.io-index"
322
+ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
323
+
324
+ [[package]]
325
+ name = "flate2"
326
+ version = "1.1.5"
327
+ source = "registry+https://github.com/rust-lang/crates.io-index"
328
+ checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
329
+ dependencies = [
330
+ "crc32fast",
331
+ "libz-rs-sys",
332
+ "miniz_oxide",
333
+ ]
334
+
335
+ [[package]]
336
+ name = "form_urlencoded"
337
+ version = "1.2.2"
338
+ source = "registry+https://github.com/rust-lang/crates.io-index"
339
+ checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
340
+ dependencies = [
341
+ "percent-encoding",
342
+ ]
343
+
344
+ [[package]]
345
+ name = "futures-channel"
346
+ version = "0.3.31"
347
+ source = "registry+https://github.com/rust-lang/crates.io-index"
348
+ checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
349
+ dependencies = [
350
+ "futures-core",
351
+ ]
352
+
353
+ [[package]]
354
+ name = "futures-core"
355
+ version = "0.3.31"
356
+ source = "registry+https://github.com/rust-lang/crates.io-index"
357
+ checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
358
+
359
+ [[package]]
360
+ name = "futures-sink"
361
+ version = "0.3.31"
362
+ source = "registry+https://github.com/rust-lang/crates.io-index"
363
+ checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
364
+
365
+ [[package]]
366
+ name = "futures-task"
367
+ version = "0.3.31"
368
+ source = "registry+https://github.com/rust-lang/crates.io-index"
369
+ checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
370
+
371
+ [[package]]
372
+ name = "futures-util"
373
+ version = "0.3.31"
374
+ source = "registry+https://github.com/rust-lang/crates.io-index"
375
+ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
376
+ dependencies = [
377
+ "futures-core",
378
+ "futures-task",
379
+ "pin-project-lite",
380
+ "pin-utils",
381
+ ]
382
+
383
+ [[package]]
384
+ name = "generic-array"
385
+ version = "0.14.7"
386
+ source = "registry+https://github.com/rust-lang/crates.io-index"
387
+ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
388
+ dependencies = [
389
+ "typenum",
390
+ "version_check",
391
+ ]
392
+
393
+ [[package]]
394
+ name = "getrandom"
395
+ version = "0.2.16"
396
+ source = "registry+https://github.com/rust-lang/crates.io-index"
397
+ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
398
+ dependencies = [
399
+ "cfg-if",
400
+ "js-sys",
401
+ "libc",
402
+ "wasi",
403
+ "wasm-bindgen",
404
+ ]
405
+
406
+ [[package]]
407
+ name = "getrandom"
408
+ version = "0.3.4"
409
+ source = "registry+https://github.com/rust-lang/crates.io-index"
410
+ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
411
+ dependencies = [
412
+ "cfg-if",
413
+ "js-sys",
414
+ "libc",
415
+ "r-efi",
416
+ "wasip2",
417
+ "wasm-bindgen",
418
+ ]
419
+
420
+ [[package]]
421
+ name = "hashbrown"
422
+ version = "0.16.1"
423
+ source = "registry+https://github.com/rust-lang/crates.io-index"
424
+ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
425
+
426
+ [[package]]
427
+ name = "http"
428
+ version = "1.4.0"
429
+ source = "registry+https://github.com/rust-lang/crates.io-index"
430
+ checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
431
+ dependencies = [
432
+ "bytes",
433
+ "itoa",
434
+ ]
435
+
436
+ [[package]]
437
+ name = "http-body"
438
+ version = "1.0.1"
439
+ source = "registry+https://github.com/rust-lang/crates.io-index"
440
+ checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
441
+ dependencies = [
442
+ "bytes",
443
+ "http",
444
+ ]
445
+
446
+ [[package]]
447
+ name = "http-body-util"
448
+ version = "0.1.3"
449
+ source = "registry+https://github.com/rust-lang/crates.io-index"
450
+ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
451
+ dependencies = [
452
+ "bytes",
453
+ "futures-core",
454
+ "http",
455
+ "http-body",
456
+ "pin-project-lite",
457
+ ]
458
+
459
+ [[package]]
460
+ name = "http-range-header"
461
+ version = "0.4.2"
462
+ source = "registry+https://github.com/rust-lang/crates.io-index"
463
+ checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
464
+
465
+ [[package]]
466
+ name = "httparse"
467
+ version = "1.10.1"
468
+ source = "registry+https://github.com/rust-lang/crates.io-index"
469
+ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
470
+
471
+ [[package]]
472
+ name = "httpdate"
473
+ version = "1.0.3"
474
+ source = "registry+https://github.com/rust-lang/crates.io-index"
475
+ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
476
+
477
+ [[package]]
478
+ name = "hyper"
479
+ version = "1.8.1"
480
+ source = "registry+https://github.com/rust-lang/crates.io-index"
481
+ checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
482
+ dependencies = [
483
+ "atomic-waker",
484
+ "bytes",
485
+ "futures-channel",
486
+ "futures-core",
487
+ "http",
488
+ "http-body",
489
+ "httparse",
490
+ "httpdate",
491
+ "itoa",
492
+ "pin-project-lite",
493
+ "pin-utils",
494
+ "smallvec",
495
+ "tokio",
496
+ "want",
497
+ ]
498
+
499
+ [[package]]
500
+ name = "hyper-rustls"
501
+ version = "0.27.7"
502
+ source = "registry+https://github.com/rust-lang/crates.io-index"
503
+ checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
504
+ dependencies = [
505
+ "http",
506
+ "hyper",
507
+ "hyper-util",
508
+ "rustls",
509
+ "rustls-pki-types",
510
+ "tokio",
511
+ "tokio-rustls",
512
+ "tower-service",
513
+ "webpki-roots",
514
+ ]
515
+
516
+ [[package]]
517
+ name = "hyper-util"
518
+ version = "0.1.19"
519
+ source = "registry+https://github.com/rust-lang/crates.io-index"
520
+ checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
521
+ dependencies = [
522
+ "base64",
523
+ "bytes",
524
+ "futures-channel",
525
+ "futures-core",
526
+ "futures-util",
527
+ "http",
528
+ "http-body",
529
+ "hyper",
530
+ "ipnet",
531
+ "libc",
532
+ "percent-encoding",
533
+ "pin-project-lite",
534
+ "socket2",
535
+ "tokio",
536
+ "tower-service",
537
+ "tracing",
538
+ ]
539
+
540
+ [[package]]
541
+ name = "iana-time-zone"
542
+ version = "0.1.64"
543
+ source = "registry+https://github.com/rust-lang/crates.io-index"
544
+ checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
545
+ dependencies = [
546
+ "android_system_properties",
547
+ "core-foundation-sys",
548
+ "iana-time-zone-haiku",
549
+ "js-sys",
550
+ "log",
551
+ "wasm-bindgen",
552
+ "windows-core",
553
+ ]
554
+
555
+ [[package]]
556
+ name = "iana-time-zone-haiku"
557
+ version = "0.1.2"
558
+ source = "registry+https://github.com/rust-lang/crates.io-index"
559
+ checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
560
+ dependencies = [
561
+ "cc",
562
+ ]
563
+
564
+ [[package]]
565
+ name = "icu_collections"
566
+ version = "2.1.1"
567
+ source = "registry+https://github.com/rust-lang/crates.io-index"
568
+ checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
569
+ dependencies = [
570
+ "displaydoc",
571
+ "potential_utf",
572
+ "yoke",
573
+ "zerofrom",
574
+ "zerovec",
575
+ ]
576
+
577
+ [[package]]
578
+ name = "icu_locale_core"
579
+ version = "2.1.1"
580
+ source = "registry+https://github.com/rust-lang/crates.io-index"
581
+ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
582
+ dependencies = [
583
+ "displaydoc",
584
+ "litemap",
585
+ "tinystr",
586
+ "writeable",
587
+ "zerovec",
588
+ ]
589
+
590
+ [[package]]
591
+ name = "icu_normalizer"
592
+ version = "2.1.1"
593
+ source = "registry+https://github.com/rust-lang/crates.io-index"
594
+ checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
595
+ dependencies = [
596
+ "icu_collections",
597
+ "icu_normalizer_data",
598
+ "icu_properties",
599
+ "icu_provider",
600
+ "smallvec",
601
+ "zerovec",
602
+ ]
603
+
604
+ [[package]]
605
+ name = "icu_normalizer_data"
606
+ version = "2.1.1"
607
+ source = "registry+https://github.com/rust-lang/crates.io-index"
608
+ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
609
+
610
+ [[package]]
611
+ name = "icu_properties"
612
+ version = "2.1.2"
613
+ source = "registry+https://github.com/rust-lang/crates.io-index"
614
+ checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
615
+ dependencies = [
616
+ "icu_collections",
617
+ "icu_locale_core",
618
+ "icu_properties_data",
619
+ "icu_provider",
620
+ "zerotrie",
621
+ "zerovec",
622
+ ]
623
+
624
+ [[package]]
625
+ name = "icu_properties_data"
626
+ version = "2.1.2"
627
+ source = "registry+https://github.com/rust-lang/crates.io-index"
628
+ checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
629
+
630
+ [[package]]
631
+ name = "icu_provider"
632
+ version = "2.1.1"
633
+ source = "registry+https://github.com/rust-lang/crates.io-index"
634
+ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
635
+ dependencies = [
636
+ "displaydoc",
637
+ "icu_locale_core",
638
+ "writeable",
639
+ "yoke",
640
+ "zerofrom",
641
+ "zerotrie",
642
+ "zerovec",
643
+ ]
644
+
645
+ [[package]]
646
+ name = "idna"
647
+ version = "1.1.0"
648
+ source = "registry+https://github.com/rust-lang/crates.io-index"
649
+ checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
650
+ dependencies = [
651
+ "idna_adapter",
652
+ "smallvec",
653
+ "utf8_iter",
654
+ ]
655
+
656
+ [[package]]
657
+ name = "idna_adapter"
658
+ version = "1.2.1"
659
+ source = "registry+https://github.com/rust-lang/crates.io-index"
660
+ checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
661
+ dependencies = [
662
+ "icu_normalizer",
663
+ "icu_properties",
664
+ ]
665
+
666
+ [[package]]
667
+ name = "indexmap"
668
+ version = "2.12.1"
669
+ source = "registry+https://github.com/rust-lang/crates.io-index"
670
+ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
671
+ dependencies = [
672
+ "equivalent",
673
+ "hashbrown",
674
+ "serde",
675
+ "serde_core",
676
+ ]
677
+
678
+ [[package]]
679
+ name = "ipnet"
680
+ version = "2.11.0"
681
+ source = "registry+https://github.com/rust-lang/crates.io-index"
682
+ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
683
+
684
+ [[package]]
685
+ name = "iri-string"
686
+ version = "0.7.10"
687
+ source = "registry+https://github.com/rust-lang/crates.io-index"
688
+ checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
689
+ dependencies = [
690
+ "memchr",
691
+ "serde",
692
+ ]
693
+
694
+ [[package]]
695
+ name = "itoa"
696
+ version = "1.0.17"
697
+ source = "registry+https://github.com/rust-lang/crates.io-index"
698
+ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
699
+
700
+ [[package]]
701
+ name = "js-sys"
702
+ version = "0.3.83"
703
+ source = "registry+https://github.com/rust-lang/crates.io-index"
704
+ checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
705
+ dependencies = [
706
+ "once_cell",
707
+ "wasm-bindgen",
708
+ ]
709
+
710
+ [[package]]
711
+ name = "lazy_static"
712
+ version = "1.5.0"
713
+ source = "registry+https://github.com/rust-lang/crates.io-index"
714
+ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
715
+
716
+ [[package]]
717
+ name = "libc"
718
+ version = "0.2.179"
719
+ source = "registry+https://github.com/rust-lang/crates.io-index"
720
+ checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f"
721
+
722
+ [[package]]
723
+ name = "libz-rs-sys"
724
+ version = "0.5.5"
725
+ source = "registry+https://github.com/rust-lang/crates.io-index"
726
+ checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
727
+ dependencies = [
728
+ "zlib-rs",
729
+ ]
730
+
731
+ [[package]]
732
+ name = "litemap"
733
+ version = "0.8.1"
734
+ source = "registry+https://github.com/rust-lang/crates.io-index"
735
+ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
736
+
737
+ [[package]]
738
+ name = "lock_api"
739
+ version = "0.4.14"
740
+ source = "registry+https://github.com/rust-lang/crates.io-index"
741
+ checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
742
+ dependencies = [
743
+ "scopeguard",
744
+ ]
745
+
746
+ [[package]]
747
+ name = "log"
748
+ version = "0.4.29"
749
+ source = "registry+https://github.com/rust-lang/crates.io-index"
750
+ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
751
+
752
+ [[package]]
753
+ name = "lru-slab"
754
+ version = "0.1.2"
755
+ source = "registry+https://github.com/rust-lang/crates.io-index"
756
+ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
757
+
758
+ [[package]]
759
+ name = "matchers"
760
+ version = "0.2.0"
761
+ source = "registry+https://github.com/rust-lang/crates.io-index"
762
+ checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
763
+ dependencies = [
764
+ "regex-automata",
765
+ ]
766
+
767
+ [[package]]
768
+ name = "matchit"
769
+ version = "0.8.4"
770
+ source = "registry+https://github.com/rust-lang/crates.io-index"
771
+ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
772
+
773
+ [[package]]
774
+ name = "memchr"
775
+ version = "2.7.6"
776
+ source = "registry+https://github.com/rust-lang/crates.io-index"
777
+ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
778
+
779
+ [[package]]
780
+ name = "mime"
781
+ version = "0.3.17"
782
+ source = "registry+https://github.com/rust-lang/crates.io-index"
783
+ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
784
+
785
+ [[package]]
786
+ name = "mime_guess"
787
+ version = "2.0.5"
788
+ source = "registry+https://github.com/rust-lang/crates.io-index"
789
+ checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
790
+ dependencies = [
791
+ "mime",
792
+ "unicase",
793
+ ]
794
+
795
+ [[package]]
796
+ name = "miniz_oxide"
797
+ version = "0.8.9"
798
+ source = "registry+https://github.com/rust-lang/crates.io-index"
799
+ checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
800
+ dependencies = [
801
+ "adler2",
802
+ "simd-adler32",
803
+ ]
804
+
805
+ [[package]]
806
+ name = "mio"
807
+ version = "1.1.1"
808
+ source = "registry+https://github.com/rust-lang/crates.io-index"
809
+ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
810
+ dependencies = [
811
+ "libc",
812
+ "wasi",
813
+ "windows-sys 0.61.2",
814
+ ]
815
+
816
+ [[package]]
817
+ name = "nu-ansi-term"
818
+ version = "0.50.3"
819
+ source = "registry+https://github.com/rust-lang/crates.io-index"
820
+ checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
821
+ dependencies = [
822
+ "windows-sys 0.61.2",
823
+ ]
824
+
825
+ [[package]]
826
+ name = "num-format"
827
+ version = "0.4.4"
828
+ source = "registry+https://github.com/rust-lang/crates.io-index"
829
+ checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3"
830
+ dependencies = [
831
+ "arrayvec",
832
+ "itoa",
833
+ ]
834
+
835
+ [[package]]
836
+ name = "num-traits"
837
+ version = "0.2.19"
838
+ source = "registry+https://github.com/rust-lang/crates.io-index"
839
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
840
+ dependencies = [
841
+ "autocfg",
842
+ ]
843
+
844
+ [[package]]
845
+ name = "once_cell"
846
+ version = "1.21.3"
847
+ source = "registry+https://github.com/rust-lang/crates.io-index"
848
+ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
849
+
850
+ [[package]]
851
+ name = "ordered-float"
852
+ version = "4.6.0"
853
+ source = "registry+https://github.com/rust-lang/crates.io-index"
854
+ checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
855
+ dependencies = [
856
+ "num-traits",
857
+ ]
858
+
859
+ [[package]]
860
+ name = "owo-colors"
861
+ version = "4.2.3"
862
+ source = "registry+https://github.com/rust-lang/crates.io-index"
863
+ checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
864
+
865
+ [[package]]
866
+ name = "parking_lot"
867
+ version = "0.12.5"
868
+ source = "registry+https://github.com/rust-lang/crates.io-index"
869
+ checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
870
+ dependencies = [
871
+ "lock_api",
872
+ "parking_lot_core",
873
+ ]
874
+
875
+ [[package]]
876
+ name = "parking_lot_core"
877
+ version = "0.9.12"
878
+ source = "registry+https://github.com/rust-lang/crates.io-index"
879
+ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
880
+ dependencies = [
881
+ "cfg-if",
882
+ "libc",
883
+ "redox_syscall",
884
+ "smallvec",
885
+ "windows-link",
886
+ ]
887
+
888
+ [[package]]
889
+ name = "percent-encoding"
890
+ version = "2.3.2"
891
+ source = "registry+https://github.com/rust-lang/crates.io-index"
892
+ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
893
+
894
+ [[package]]
895
+ name = "petgraph"
896
+ version = "0.6.5"
897
+ source = "registry+https://github.com/rust-lang/crates.io-index"
898
+ checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
899
+ dependencies = [
900
+ "fixedbitset",
901
+ "indexmap",
902
+ ]
903
+
904
+ [[package]]
905
+ name = "pin-project-lite"
906
+ version = "0.2.16"
907
+ source = "registry+https://github.com/rust-lang/crates.io-index"
908
+ checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
909
+
910
+ [[package]]
911
+ name = "pin-utils"
912
+ version = "0.1.0"
913
+ source = "registry+https://github.com/rust-lang/crates.io-index"
914
+ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
915
+
916
+ [[package]]
917
+ name = "potential_utf"
918
+ version = "0.1.4"
919
+ source = "registry+https://github.com/rust-lang/crates.io-index"
920
+ checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
921
+ dependencies = [
922
+ "zerovec",
923
+ ]
924
+
925
+ [[package]]
926
+ name = "ppv-lite86"
927
+ version = "0.2.21"
928
+ source = "registry+https://github.com/rust-lang/crates.io-index"
929
+ checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
930
+ dependencies = [
931
+ "zerocopy",
932
+ ]
933
+
934
+ [[package]]
935
+ name = "proc-macro2"
936
+ version = "1.0.104"
937
+ source = "registry+https://github.com/rust-lang/crates.io-index"
938
+ checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
939
+ dependencies = [
940
+ "unicode-ident",
941
+ ]
942
+
943
+ [[package]]
944
+ name = "quinn"
945
+ version = "0.11.9"
946
+ source = "registry+https://github.com/rust-lang/crates.io-index"
947
+ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
948
+ dependencies = [
949
+ "bytes",
950
+ "cfg_aliases",
951
+ "pin-project-lite",
952
+ "quinn-proto",
953
+ "quinn-udp",
954
+ "rustc-hash",
955
+ "rustls",
956
+ "socket2",
957
+ "thiserror",
958
+ "tokio",
959
+ "tracing",
960
+ "web-time",
961
+ ]
962
+
963
+ [[package]]
964
+ name = "quinn-proto"
965
+ version = "0.11.13"
966
+ source = "registry+https://github.com/rust-lang/crates.io-index"
967
+ checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
968
+ dependencies = [
969
+ "bytes",
970
+ "getrandom 0.3.4",
971
+ "lru-slab",
972
+ "rand 0.9.2",
973
+ "ring",
974
+ "rustc-hash",
975
+ "rustls",
976
+ "rustls-pki-types",
977
+ "slab",
978
+ "thiserror",
979
+ "tinyvec",
980
+ "tracing",
981
+ "web-time",
982
+ ]
983
+
984
+ [[package]]
985
+ name = "quinn-udp"
986
+ version = "0.5.14"
987
+ source = "registry+https://github.com/rust-lang/crates.io-index"
988
+ checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
989
+ dependencies = [
990
+ "cfg_aliases",
991
+ "libc",
992
+ "once_cell",
993
+ "socket2",
994
+ "tracing",
995
+ "windows-sys 0.60.2",
996
+ ]
997
+
998
+ [[package]]
999
+ name = "quote"
1000
+ version = "1.0.42"
1001
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1002
+ checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
1003
+ dependencies = [
1004
+ "proc-macro2",
1005
+ ]
1006
+
1007
+ [[package]]
1008
+ name = "r-efi"
1009
+ version = "5.3.0"
1010
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1011
+ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1012
+
1013
+ [[package]]
1014
+ name = "rand"
1015
+ version = "0.8.5"
1016
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1017
+ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1018
+ dependencies = [
1019
+ "libc",
1020
+ "rand_chacha 0.3.1",
1021
+ "rand_core 0.6.4",
1022
+ ]
1023
+
1024
+ [[package]]
1025
+ name = "rand"
1026
+ version = "0.9.2"
1027
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1028
+ checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
1029
+ dependencies = [
1030
+ "rand_chacha 0.9.0",
1031
+ "rand_core 0.9.3",
1032
+ ]
1033
+
1034
+ [[package]]
1035
+ name = "rand_chacha"
1036
+ version = "0.3.1"
1037
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1038
+ checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1039
+ dependencies = [
1040
+ "ppv-lite86",
1041
+ "rand_core 0.6.4",
1042
+ ]
1043
+
1044
+ [[package]]
1045
+ name = "rand_chacha"
1046
+ version = "0.9.0"
1047
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1048
+ checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1049
+ dependencies = [
1050
+ "ppv-lite86",
1051
+ "rand_core 0.9.3",
1052
+ ]
1053
+
1054
+ [[package]]
1055
+ name = "rand_core"
1056
+ version = "0.6.4"
1057
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1058
+ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1059
+ dependencies = [
1060
+ "getrandom 0.2.16",
1061
+ ]
1062
+
1063
+ [[package]]
1064
+ name = "rand_core"
1065
+ version = "0.9.3"
1066
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1067
+ checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
1068
+ dependencies = [
1069
+ "getrandom 0.3.4",
1070
+ ]
1071
+
1072
+ [[package]]
1073
+ name = "rayon"
1074
+ version = "1.11.0"
1075
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1076
+ checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
1077
+ dependencies = [
1078
+ "either",
1079
+ "rayon-core",
1080
+ ]
1081
+
1082
+ [[package]]
1083
+ name = "rayon-core"
1084
+ version = "1.13.0"
1085
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1086
+ checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
1087
+ dependencies = [
1088
+ "crossbeam-deque",
1089
+ "crossbeam-utils",
1090
+ ]
1091
+
1092
+ [[package]]
1093
+ name = "redox_syscall"
1094
+ version = "0.5.18"
1095
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1096
+ checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1097
+ dependencies = [
1098
+ "bitflags",
1099
+ ]
1100
+
1101
+ [[package]]
1102
+ name = "regex"
1103
+ version = "1.12.2"
1104
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1105
+ checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
1106
+ dependencies = [
1107
+ "aho-corasick",
1108
+ "memchr",
1109
+ "regex-automata",
1110
+ "regex-syntax",
1111
+ ]
1112
+
1113
+ [[package]]
1114
+ name = "regex-automata"
1115
+ version = "0.4.13"
1116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1117
+ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
1118
+ dependencies = [
1119
+ "aho-corasick",
1120
+ "memchr",
1121
+ "regex-syntax",
1122
+ ]
1123
+
1124
+ [[package]]
1125
+ name = "regex-syntax"
1126
+ version = "0.8.8"
1127
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1128
+ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1129
+
1130
+ [[package]]
1131
+ name = "reqwest"
1132
+ version = "0.12.28"
1133
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1134
+ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1135
+ dependencies = [
1136
+ "base64",
1137
+ "bytes",
1138
+ "futures-core",
1139
+ "http",
1140
+ "http-body",
1141
+ "http-body-util",
1142
+ "hyper",
1143
+ "hyper-rustls",
1144
+ "hyper-util",
1145
+ "js-sys",
1146
+ "log",
1147
+ "percent-encoding",
1148
+ "pin-project-lite",
1149
+ "quinn",
1150
+ "rustls",
1151
+ "rustls-pki-types",
1152
+ "serde",
1153
+ "serde_json",
1154
+ "serde_urlencoded",
1155
+ "sync_wrapper",
1156
+ "tokio",
1157
+ "tokio-rustls",
1158
+ "tower",
1159
+ "tower-http",
1160
+ "tower-service",
1161
+ "url",
1162
+ "wasm-bindgen",
1163
+ "wasm-bindgen-futures",
1164
+ "web-sys",
1165
+ "webpki-roots",
1166
+ ]
1167
+
1168
+ [[package]]
1169
+ name = "ring"
1170
+ version = "0.17.14"
1171
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1172
+ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1173
+ dependencies = [
1174
+ "cc",
1175
+ "cfg-if",
1176
+ "getrandom 0.2.16",
1177
+ "libc",
1178
+ "untrusted",
1179
+ "windows-sys 0.52.0",
1180
+ ]
1181
+
1182
+ [[package]]
1183
+ name = "rust-embed"
1184
+ version = "8.9.0"
1185
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1186
+ checksum = "947d7f3fad52b283d261c4c99a084937e2fe492248cb9a68a8435a861b8798ca"
1187
+ dependencies = [
1188
+ "rust-embed-impl",
1189
+ "rust-embed-utils",
1190
+ "walkdir",
1191
+ ]
1192
+
1193
+ [[package]]
1194
+ name = "rust-embed-impl"
1195
+ version = "8.9.0"
1196
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1197
+ checksum = "5fa2c8c9e8711e10f9c4fd2d64317ef13feaab820a4c51541f1a8c8e2e851ab2"
1198
+ dependencies = [
1199
+ "proc-macro2",
1200
+ "quote",
1201
+ "rust-embed-utils",
1202
+ "syn",
1203
+ "walkdir",
1204
+ ]
1205
+
1206
+ [[package]]
1207
+ name = "rust-embed-utils"
1208
+ version = "8.9.0"
1209
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1210
+ checksum = "60b161f275cb337fe0a44d924a5f4df0ed69c2c39519858f931ce61c779d3475"
1211
+ dependencies = [
1212
+ "sha2",
1213
+ "walkdir",
1214
+ ]
1215
+
1216
+ [[package]]
1217
+ name = "rustc-hash"
1218
+ version = "2.1.1"
1219
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1220
+ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
1221
+
1222
+ [[package]]
1223
+ name = "rustls"
1224
+ version = "0.23.35"
1225
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1226
+ checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
1227
+ dependencies = [
1228
+ "once_cell",
1229
+ "ring",
1230
+ "rustls-pki-types",
1231
+ "rustls-webpki",
1232
+ "subtle",
1233
+ "zeroize",
1234
+ ]
1235
+
1236
+ [[package]]
1237
+ name = "rustls-pki-types"
1238
+ version = "1.13.2"
1239
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1240
+ checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
1241
+ dependencies = [
1242
+ "web-time",
1243
+ "zeroize",
1244
+ ]
1245
+
1246
+ [[package]]
1247
+ name = "rustls-webpki"
1248
+ version = "0.103.8"
1249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1250
+ checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
1251
+ dependencies = [
1252
+ "ring",
1253
+ "rustls-pki-types",
1254
+ "untrusted",
1255
+ ]
1256
+
1257
+ [[package]]
1258
+ name = "rustversion"
1259
+ version = "1.0.22"
1260
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1261
+ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1262
+
1263
+ [[package]]
1264
+ name = "ryu"
1265
+ version = "1.0.22"
1266
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1267
+ checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
1268
+
1269
+ [[package]]
1270
+ name = "same-file"
1271
+ version = "1.0.6"
1272
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1273
+ checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1274
+ dependencies = [
1275
+ "winapi-util",
1276
+ ]
1277
+
1278
+ [[package]]
1279
+ name = "scopeguard"
1280
+ version = "1.2.0"
1281
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1282
+ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1283
+
1284
+ [[package]]
1285
+ name = "serde"
1286
+ version = "1.0.228"
1287
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1288
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1289
+ dependencies = [
1290
+ "serde_core",
1291
+ "serde_derive",
1292
+ ]
1293
+
1294
+ [[package]]
1295
+ name = "serde_core"
1296
+ version = "1.0.228"
1297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1298
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1299
+ dependencies = [
1300
+ "serde_derive",
1301
+ ]
1302
+
1303
+ [[package]]
1304
+ name = "serde_derive"
1305
+ version = "1.0.228"
1306
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1307
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1308
+ dependencies = [
1309
+ "proc-macro2",
1310
+ "quote",
1311
+ "syn",
1312
+ ]
1313
+
1314
+ [[package]]
1315
+ name = "serde_json"
1316
+ version = "1.0.148"
1317
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1318
+ checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
1319
+ dependencies = [
1320
+ "itoa",
1321
+ "memchr",
1322
+ "serde",
1323
+ "serde_core",
1324
+ "zmij",
1325
+ ]
1326
+
1327
+ [[package]]
1328
+ name = "serde_path_to_error"
1329
+ version = "0.1.20"
1330
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1331
+ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
1332
+ dependencies = [
1333
+ "itoa",
1334
+ "serde",
1335
+ "serde_core",
1336
+ ]
1337
+
1338
+ [[package]]
1339
+ name = "serde_spanned"
1340
+ version = "0.6.9"
1341
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1342
+ checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
1343
+ dependencies = [
1344
+ "serde",
1345
+ ]
1346
+
1347
+ [[package]]
1348
+ name = "serde_urlencoded"
1349
+ version = "0.7.1"
1350
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1351
+ checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1352
+ dependencies = [
1353
+ "form_urlencoded",
1354
+ "itoa",
1355
+ "ryu",
1356
+ "serde",
1357
+ ]
1358
+
1359
+ [[package]]
1360
+ name = "serde_yaml"
1361
+ version = "0.9.34+deprecated"
1362
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1363
+ checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
1364
+ dependencies = [
1365
+ "indexmap",
1366
+ "itoa",
1367
+ "ryu",
1368
+ "serde",
1369
+ "unsafe-libyaml",
1370
+ ]
1371
+
1372
+ [[package]]
1373
+ name = "sha2"
1374
+ version = "0.10.9"
1375
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1376
+ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1377
+ dependencies = [
1378
+ "cfg-if",
1379
+ "cpufeatures",
1380
+ "digest",
1381
+ ]
1382
+
1383
+ [[package]]
1384
+ name = "sharded-slab"
1385
+ version = "0.1.7"
1386
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1387
+ checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1388
+ dependencies = [
1389
+ "lazy_static",
1390
+ ]
1391
+
1392
+ [[package]]
1393
+ name = "shlex"
1394
+ version = "1.3.0"
1395
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1396
+ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1397
+
1398
+ [[package]]
1399
+ name = "signal-hook-registry"
1400
+ version = "1.4.8"
1401
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1402
+ checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1403
+ dependencies = [
1404
+ "errno",
1405
+ "libc",
1406
+ ]
1407
+
1408
+ [[package]]
1409
+ name = "simd-adler32"
1410
+ version = "0.3.8"
1411
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1412
+ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
1413
+
1414
+ [[package]]
1415
+ name = "slab"
1416
+ version = "0.4.11"
1417
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
1419
+
1420
+ [[package]]
1421
+ name = "smallvec"
1422
+ version = "1.15.1"
1423
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1424
+ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1425
+
1426
+ [[package]]
1427
+ name = "socket2"
1428
+ version = "0.6.1"
1429
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1430
+ checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
1431
+ dependencies = [
1432
+ "libc",
1433
+ "windows-sys 0.60.2",
1434
+ ]
1435
+
1436
+ [[package]]
1437
+ name = "solverforge"
1438
+ version = "0.5.1"
1439
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1440
+ checksum = "ebe455ae330096af2f36cdd131051b1c14bd995f258078c9555adcfe20678a8c"
1441
+ dependencies = [
1442
+ "solverforge-config",
1443
+ "solverforge-core",
1444
+ "solverforge-macros",
1445
+ "solverforge-scoring",
1446
+ "solverforge-solver",
1447
+ ]
1448
+
1449
+ [[package]]
1450
+ name = "solverforge-config"
1451
+ version = "0.5.1"
1452
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1453
+ checksum = "9e4d590ad6e66553eb1f4013979f0def038555383f88b5b649fcbeb2da51363d"
1454
+ dependencies = [
1455
+ "serde",
1456
+ "serde_yaml",
1457
+ "solverforge-core",
1458
+ "thiserror",
1459
+ "toml",
1460
+ ]
1461
+
1462
+ [[package]]
1463
+ name = "solverforge-core"
1464
+ version = "0.5.1"
1465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1466
+ checksum = "9d58655c54f8b22ff3ddc5a51f5858187794756400de2702601364ca63f1bb98"
1467
+ dependencies = [
1468
+ "num-traits",
1469
+ "serde",
1470
+ "thiserror",
1471
+ ]
1472
+
1473
+ [[package]]
1474
+ name = "solverforge-macros"
1475
+ version = "0.5.1"
1476
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1477
+ checksum = "01b0dc41ba7d1a84ee0c5d2d898700710bff6d959f9be37738b523fd65d12284"
1478
+ dependencies = [
1479
+ "proc-macro2",
1480
+ "quote",
1481
+ "syn",
1482
+ ]
1483
+
1484
+ [[package]]
1485
+ name = "solverforge-scoring"
1486
+ version = "0.5.1"
1487
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1488
+ checksum = "0c559e0bfd0668b53e67ce525136468ae934ef5e07ef1e52cc734a3c36375a09"
1489
+ dependencies = [
1490
+ "solverforge-core",
1491
+ "thiserror",
1492
+ ]
1493
+
1494
+ [[package]]
1495
+ name = "solverforge-solver"
1496
+ version = "0.5.1"
1497
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1498
+ checksum = "a7c2b34de23f929a63721203118bc5856248e768a959f87697ab6585eb5ee20a"
1499
+ dependencies = [
1500
+ "rand 0.9.2",
1501
+ "rand_chacha 0.9.0",
1502
+ "rayon",
1503
+ "serde",
1504
+ "smallvec",
1505
+ "solverforge-config",
1506
+ "solverforge-core",
1507
+ "solverforge-scoring",
1508
+ "thiserror",
1509
+ "tokio",
1510
+ "tracing",
1511
+ ]
1512
+
1513
+ [[package]]
1514
+ name = "stable_deref_trait"
1515
+ version = "1.2.1"
1516
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1517
+ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1518
+
1519
+ [[package]]
1520
+ name = "subtle"
1521
+ version = "2.6.1"
1522
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1523
+ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1524
+
1525
+ [[package]]
1526
+ name = "syn"
1527
+ version = "2.0.113"
1528
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1529
+ checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4"
1530
+ dependencies = [
1531
+ "proc-macro2",
1532
+ "quote",
1533
+ "unicode-ident",
1534
+ ]
1535
+
1536
+ [[package]]
1537
+ name = "sync_wrapper"
1538
+ version = "1.0.2"
1539
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1540
+ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1541
+ dependencies = [
1542
+ "futures-core",
1543
+ ]
1544
+
1545
+ [[package]]
1546
+ name = "synstructure"
1547
+ version = "0.13.2"
1548
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1549
+ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1550
+ dependencies = [
1551
+ "proc-macro2",
1552
+ "quote",
1553
+ "syn",
1554
+ ]
1555
+
1556
+ [[package]]
1557
+ name = "thiserror"
1558
+ version = "2.0.17"
1559
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1560
+ checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
1561
+ dependencies = [
1562
+ "thiserror-impl",
1563
+ ]
1564
+
1565
+ [[package]]
1566
+ name = "thiserror-impl"
1567
+ version = "2.0.17"
1568
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1569
+ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
1570
+ dependencies = [
1571
+ "proc-macro2",
1572
+ "quote",
1573
+ "syn",
1574
+ ]
1575
+
1576
+ [[package]]
1577
+ name = "thread_local"
1578
+ version = "1.1.9"
1579
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1580
+ checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
1581
+ dependencies = [
1582
+ "cfg-if",
1583
+ ]
1584
+
1585
+ [[package]]
1586
+ name = "tinystr"
1587
+ version = "0.8.2"
1588
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1589
+ checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
1590
+ dependencies = [
1591
+ "displaydoc",
1592
+ "zerovec",
1593
+ ]
1594
+
1595
+ [[package]]
1596
+ name = "tinyvec"
1597
+ version = "1.10.0"
1598
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1599
+ checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
1600
+ dependencies = [
1601
+ "tinyvec_macros",
1602
+ ]
1603
+
1604
+ [[package]]
1605
+ name = "tinyvec_macros"
1606
+ version = "0.1.1"
1607
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1608
+ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1609
+
1610
+ [[package]]
1611
+ name = "tokio"
1612
+ version = "1.49.0"
1613
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1614
+ checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
1615
+ dependencies = [
1616
+ "bytes",
1617
+ "libc",
1618
+ "mio",
1619
+ "parking_lot",
1620
+ "pin-project-lite",
1621
+ "signal-hook-registry",
1622
+ "socket2",
1623
+ "tokio-macros",
1624
+ "windows-sys 0.61.2",
1625
+ ]
1626
+
1627
+ [[package]]
1628
+ name = "tokio-macros"
1629
+ version = "2.6.0"
1630
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1631
+ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
1632
+ dependencies = [
1633
+ "proc-macro2",
1634
+ "quote",
1635
+ "syn",
1636
+ ]
1637
+
1638
+ [[package]]
1639
+ name = "tokio-rustls"
1640
+ version = "0.26.4"
1641
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1642
+ checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
1643
+ dependencies = [
1644
+ "rustls",
1645
+ "tokio",
1646
+ ]
1647
+
1648
+ [[package]]
1649
+ name = "tokio-stream"
1650
+ version = "0.1.18"
1651
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1652
+ checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
1653
+ dependencies = [
1654
+ "futures-core",
1655
+ "pin-project-lite",
1656
+ "tokio",
1657
+ ]
1658
+
1659
+ [[package]]
1660
+ name = "tokio-util"
1661
+ version = "0.7.18"
1662
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1663
+ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
1664
+ dependencies = [
1665
+ "bytes",
1666
+ "futures-core",
1667
+ "futures-sink",
1668
+ "pin-project-lite",
1669
+ "tokio",
1670
+ ]
1671
+
1672
+ [[package]]
1673
+ name = "toml"
1674
+ version = "0.8.23"
1675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1676
+ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
1677
+ dependencies = [
1678
+ "serde",
1679
+ "serde_spanned",
1680
+ "toml_datetime",
1681
+ "toml_edit",
1682
+ ]
1683
+
1684
+ [[package]]
1685
+ name = "toml_datetime"
1686
+ version = "0.6.11"
1687
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1688
+ checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
1689
+ dependencies = [
1690
+ "serde",
1691
+ ]
1692
+
1693
+ [[package]]
1694
+ name = "toml_edit"
1695
+ version = "0.22.27"
1696
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1697
+ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
1698
+ dependencies = [
1699
+ "indexmap",
1700
+ "serde",
1701
+ "serde_spanned",
1702
+ "toml_datetime",
1703
+ "toml_write",
1704
+ "winnow",
1705
+ ]
1706
+
1707
+ [[package]]
1708
+ name = "toml_write"
1709
+ version = "0.1.2"
1710
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1711
+ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
1712
+
1713
+ [[package]]
1714
+ name = "tower"
1715
+ version = "0.5.2"
1716
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1717
+ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
1718
+ dependencies = [
1719
+ "futures-core",
1720
+ "futures-util",
1721
+ "pin-project-lite",
1722
+ "sync_wrapper",
1723
+ "tokio",
1724
+ "tower-layer",
1725
+ "tower-service",
1726
+ "tracing",
1727
+ ]
1728
+
1729
+ [[package]]
1730
+ name = "tower-http"
1731
+ version = "0.6.8"
1732
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1733
+ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
1734
+ dependencies = [
1735
+ "bitflags",
1736
+ "bytes",
1737
+ "futures-core",
1738
+ "futures-util",
1739
+ "http",
1740
+ "http-body",
1741
+ "http-body-util",
1742
+ "http-range-header",
1743
+ "httpdate",
1744
+ "iri-string",
1745
+ "mime",
1746
+ "mime_guess",
1747
+ "percent-encoding",
1748
+ "pin-project-lite",
1749
+ "tokio",
1750
+ "tokio-util",
1751
+ "tower",
1752
+ "tower-layer",
1753
+ "tower-service",
1754
+ "tracing",
1755
+ ]
1756
+
1757
+ [[package]]
1758
+ name = "tower-layer"
1759
+ version = "0.3.3"
1760
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1761
+ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
1762
+
1763
+ [[package]]
1764
+ name = "tower-service"
1765
+ version = "0.3.3"
1766
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1767
+ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
1768
+
1769
+ [[package]]
1770
+ name = "tracing"
1771
+ version = "0.1.44"
1772
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1773
+ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
1774
+ dependencies = [
1775
+ "log",
1776
+ "pin-project-lite",
1777
+ "tracing-attributes",
1778
+ "tracing-core",
1779
+ ]
1780
+
1781
+ [[package]]
1782
+ name = "tracing-attributes"
1783
+ version = "0.1.31"
1784
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1785
+ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
1786
+ dependencies = [
1787
+ "proc-macro2",
1788
+ "quote",
1789
+ "syn",
1790
+ ]
1791
+
1792
+ [[package]]
1793
+ name = "tracing-core"
1794
+ version = "0.1.36"
1795
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1796
+ checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
1797
+ dependencies = [
1798
+ "once_cell",
1799
+ "valuable",
1800
+ ]
1801
+
1802
+ [[package]]
1803
+ name = "tracing-log"
1804
+ version = "0.2.0"
1805
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1806
+ checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
1807
+ dependencies = [
1808
+ "log",
1809
+ "once_cell",
1810
+ "tracing-core",
1811
+ ]
1812
+
1813
+ [[package]]
1814
+ name = "tracing-subscriber"
1815
+ version = "0.3.22"
1816
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1817
+ checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
1818
+ dependencies = [
1819
+ "matchers",
1820
+ "nu-ansi-term",
1821
+ "once_cell",
1822
+ "regex-automata",
1823
+ "sharded-slab",
1824
+ "smallvec",
1825
+ "thread_local",
1826
+ "tracing",
1827
+ "tracing-core",
1828
+ "tracing-log",
1829
+ ]
1830
+
1831
+ [[package]]
1832
+ name = "try-lock"
1833
+ version = "0.2.5"
1834
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1835
+ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
1836
+
1837
+ [[package]]
1838
+ name = "typenum"
1839
+ version = "1.19.0"
1840
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1841
+ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
1842
+
1843
+ [[package]]
1844
+ name = "unicase"
1845
+ version = "2.8.1"
1846
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1847
+ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
1848
+
1849
+ [[package]]
1850
+ name = "unicode-ident"
1851
+ version = "1.0.22"
1852
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1853
+ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
1854
+
1855
+ [[package]]
1856
+ name = "unsafe-libyaml"
1857
+ version = "0.2.11"
1858
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1859
+ checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
1860
+
1861
+ [[package]]
1862
+ name = "untrusted"
1863
+ version = "0.9.0"
1864
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1865
+ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
1866
+
1867
+ [[package]]
1868
+ name = "url"
1869
+ version = "2.5.7"
1870
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1871
+ checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
1872
+ dependencies = [
1873
+ "form_urlencoded",
1874
+ "idna",
1875
+ "percent-encoding",
1876
+ "serde",
1877
+ ]
1878
+
1879
+ [[package]]
1880
+ name = "utf8_iter"
1881
+ version = "1.0.4"
1882
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1883
+ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
1884
+
1885
+ [[package]]
1886
+ name = "utoipa"
1887
+ version = "5.4.0"
1888
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1889
+ checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
1890
+ dependencies = [
1891
+ "indexmap",
1892
+ "serde",
1893
+ "serde_json",
1894
+ "utoipa-gen",
1895
+ ]
1896
+
1897
+ [[package]]
1898
+ name = "utoipa-gen"
1899
+ version = "5.4.0"
1900
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1901
+ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
1902
+ dependencies = [
1903
+ "proc-macro2",
1904
+ "quote",
1905
+ "regex",
1906
+ "syn",
1907
+ ]
1908
+
1909
+ [[package]]
1910
+ name = "utoipa-swagger-ui"
1911
+ version = "9.0.2"
1912
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1913
+ checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55"
1914
+ dependencies = [
1915
+ "axum",
1916
+ "base64",
1917
+ "mime_guess",
1918
+ "regex",
1919
+ "rust-embed",
1920
+ "serde",
1921
+ "serde_json",
1922
+ "url",
1923
+ "utoipa",
1924
+ "zip",
1925
+ ]
1926
+
1927
+ [[package]]
1928
+ name = "uuid"
1929
+ version = "1.19.0"
1930
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1931
+ checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
1932
+ dependencies = [
1933
+ "getrandom 0.3.4",
1934
+ "js-sys",
1935
+ "serde_core",
1936
+ "wasm-bindgen",
1937
+ ]
1938
+
1939
+ [[package]]
1940
+ name = "valuable"
1941
+ version = "0.1.1"
1942
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1943
+ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
1944
+
1945
+ [[package]]
1946
+ name = "vehicle-routing"
1947
+ version = "0.4.1"
1948
+ dependencies = [
1949
+ "async-stream",
1950
+ "axum",
1951
+ "chrono",
1952
+ "num-format",
1953
+ "ordered-float",
1954
+ "owo-colors",
1955
+ "parking_lot",
1956
+ "petgraph",
1957
+ "rand 0.8.5",
1958
+ "reqwest",
1959
+ "serde",
1960
+ "serde_json",
1961
+ "solverforge",
1962
+ "tokio",
1963
+ "tokio-stream",
1964
+ "tower",
1965
+ "tower-http",
1966
+ "tracing",
1967
+ "tracing-subscriber",
1968
+ "utoipa",
1969
+ "utoipa-swagger-ui",
1970
+ "uuid",
1971
+ ]
1972
+
1973
+ [[package]]
1974
+ name = "version_check"
1975
+ version = "0.9.5"
1976
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1977
+ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
1978
+
1979
+ [[package]]
1980
+ name = "walkdir"
1981
+ version = "2.5.0"
1982
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1983
+ checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
1984
+ dependencies = [
1985
+ "same-file",
1986
+ "winapi-util",
1987
+ ]
1988
+
1989
+ [[package]]
1990
+ name = "want"
1991
+ version = "0.3.1"
1992
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1993
+ checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
1994
+ dependencies = [
1995
+ "try-lock",
1996
+ ]
1997
+
1998
+ [[package]]
1999
+ name = "wasi"
2000
+ version = "0.11.1+wasi-snapshot-preview1"
2001
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2002
+ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2003
+
2004
+ [[package]]
2005
+ name = "wasip2"
2006
+ version = "1.0.1+wasi-0.2.4"
2007
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2008
+ checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
2009
+ dependencies = [
2010
+ "wit-bindgen",
2011
+ ]
2012
+
2013
+ [[package]]
2014
+ name = "wasm-bindgen"
2015
+ version = "0.2.106"
2016
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2017
+ checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
2018
+ dependencies = [
2019
+ "cfg-if",
2020
+ "once_cell",
2021
+ "rustversion",
2022
+ "wasm-bindgen-macro",
2023
+ "wasm-bindgen-shared",
2024
+ ]
2025
+
2026
+ [[package]]
2027
+ name = "wasm-bindgen-futures"
2028
+ version = "0.4.56"
2029
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2030
+ checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
2031
+ dependencies = [
2032
+ "cfg-if",
2033
+ "js-sys",
2034
+ "once_cell",
2035
+ "wasm-bindgen",
2036
+ "web-sys",
2037
+ ]
2038
+
2039
+ [[package]]
2040
+ name = "wasm-bindgen-macro"
2041
+ version = "0.2.106"
2042
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2043
+ checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
2044
+ dependencies = [
2045
+ "quote",
2046
+ "wasm-bindgen-macro-support",
2047
+ ]
2048
+
2049
+ [[package]]
2050
+ name = "wasm-bindgen-macro-support"
2051
+ version = "0.2.106"
2052
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2053
+ checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
2054
+ dependencies = [
2055
+ "bumpalo",
2056
+ "proc-macro2",
2057
+ "quote",
2058
+ "syn",
2059
+ "wasm-bindgen-shared",
2060
+ ]
2061
+
2062
+ [[package]]
2063
+ name = "wasm-bindgen-shared"
2064
+ version = "0.2.106"
2065
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2066
+ checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
2067
+ dependencies = [
2068
+ "unicode-ident",
2069
+ ]
2070
+
2071
+ [[package]]
2072
+ name = "web-sys"
2073
+ version = "0.3.83"
2074
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2075
+ checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
2076
+ dependencies = [
2077
+ "js-sys",
2078
+ "wasm-bindgen",
2079
+ ]
2080
+
2081
+ [[package]]
2082
+ name = "web-time"
2083
+ version = "1.1.0"
2084
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2085
+ checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
2086
+ dependencies = [
2087
+ "js-sys",
2088
+ "wasm-bindgen",
2089
+ ]
2090
+
2091
+ [[package]]
2092
+ name = "webpki-roots"
2093
+ version = "1.0.5"
2094
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2095
+ checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
2096
+ dependencies = [
2097
+ "rustls-pki-types",
2098
+ ]
2099
+
2100
+ [[package]]
2101
+ name = "winapi-util"
2102
+ version = "0.1.11"
2103
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2104
+ checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2105
+ dependencies = [
2106
+ "windows-sys 0.61.2",
2107
+ ]
2108
+
2109
+ [[package]]
2110
+ name = "windows-core"
2111
+ version = "0.62.2"
2112
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2113
+ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2114
+ dependencies = [
2115
+ "windows-implement",
2116
+ "windows-interface",
2117
+ "windows-link",
2118
+ "windows-result",
2119
+ "windows-strings",
2120
+ ]
2121
+
2122
+ [[package]]
2123
+ name = "windows-implement"
2124
+ version = "0.60.2"
2125
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2126
+ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2127
+ dependencies = [
2128
+ "proc-macro2",
2129
+ "quote",
2130
+ "syn",
2131
+ ]
2132
+
2133
+ [[package]]
2134
+ name = "windows-interface"
2135
+ version = "0.59.3"
2136
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2137
+ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2138
+ dependencies = [
2139
+ "proc-macro2",
2140
+ "quote",
2141
+ "syn",
2142
+ ]
2143
+
2144
+ [[package]]
2145
+ name = "windows-link"
2146
+ version = "0.2.1"
2147
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2148
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2149
+
2150
+ [[package]]
2151
+ name = "windows-result"
2152
+ version = "0.4.1"
2153
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2154
+ checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2155
+ dependencies = [
2156
+ "windows-link",
2157
+ ]
2158
+
2159
+ [[package]]
2160
+ name = "windows-strings"
2161
+ version = "0.5.1"
2162
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2163
+ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2164
+ dependencies = [
2165
+ "windows-link",
2166
+ ]
2167
+
2168
+ [[package]]
2169
+ name = "windows-sys"
2170
+ version = "0.52.0"
2171
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2172
+ checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2173
+ dependencies = [
2174
+ "windows-targets 0.52.6",
2175
+ ]
2176
+
2177
+ [[package]]
2178
+ name = "windows-sys"
2179
+ version = "0.60.2"
2180
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2181
+ checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
2182
+ dependencies = [
2183
+ "windows-targets 0.53.5",
2184
+ ]
2185
+
2186
+ [[package]]
2187
+ name = "windows-sys"
2188
+ version = "0.61.2"
2189
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2190
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2191
+ dependencies = [
2192
+ "windows-link",
2193
+ ]
2194
+
2195
+ [[package]]
2196
+ name = "windows-targets"
2197
+ version = "0.52.6"
2198
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2199
+ checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2200
+ dependencies = [
2201
+ "windows_aarch64_gnullvm 0.52.6",
2202
+ "windows_aarch64_msvc 0.52.6",
2203
+ "windows_i686_gnu 0.52.6",
2204
+ "windows_i686_gnullvm 0.52.6",
2205
+ "windows_i686_msvc 0.52.6",
2206
+ "windows_x86_64_gnu 0.52.6",
2207
+ "windows_x86_64_gnullvm 0.52.6",
2208
+ "windows_x86_64_msvc 0.52.6",
2209
+ ]
2210
+
2211
+ [[package]]
2212
+ name = "windows-targets"
2213
+ version = "0.53.5"
2214
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2215
+ checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
2216
+ dependencies = [
2217
+ "windows-link",
2218
+ "windows_aarch64_gnullvm 0.53.1",
2219
+ "windows_aarch64_msvc 0.53.1",
2220
+ "windows_i686_gnu 0.53.1",
2221
+ "windows_i686_gnullvm 0.53.1",
2222
+ "windows_i686_msvc 0.53.1",
2223
+ "windows_x86_64_gnu 0.53.1",
2224
+ "windows_x86_64_gnullvm 0.53.1",
2225
+ "windows_x86_64_msvc 0.53.1",
2226
+ ]
2227
+
2228
+ [[package]]
2229
+ name = "windows_aarch64_gnullvm"
2230
+ version = "0.52.6"
2231
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2232
+ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2233
+
2234
+ [[package]]
2235
+ name = "windows_aarch64_gnullvm"
2236
+ version = "0.53.1"
2237
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2238
+ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
2239
+
2240
+ [[package]]
2241
+ name = "windows_aarch64_msvc"
2242
+ version = "0.52.6"
2243
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2244
+ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2245
+
2246
+ [[package]]
2247
+ name = "windows_aarch64_msvc"
2248
+ version = "0.53.1"
2249
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2250
+ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2251
+
2252
+ [[package]]
2253
+ name = "windows_i686_gnu"
2254
+ version = "0.52.6"
2255
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2256
+ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2257
+
2258
+ [[package]]
2259
+ name = "windows_i686_gnu"
2260
+ version = "0.53.1"
2261
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2262
+ checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2263
+
2264
+ [[package]]
2265
+ name = "windows_i686_gnullvm"
2266
+ version = "0.52.6"
2267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2268
+ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2269
+
2270
+ [[package]]
2271
+ name = "windows_i686_gnullvm"
2272
+ version = "0.53.1"
2273
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2274
+ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
2275
+
2276
+ [[package]]
2277
+ name = "windows_i686_msvc"
2278
+ version = "0.52.6"
2279
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2280
+ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2281
+
2282
+ [[package]]
2283
+ name = "windows_i686_msvc"
2284
+ version = "0.53.1"
2285
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2286
+ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2287
+
2288
+ [[package]]
2289
+ name = "windows_x86_64_gnu"
2290
+ version = "0.52.6"
2291
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2292
+ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2293
+
2294
+ [[package]]
2295
+ name = "windows_x86_64_gnu"
2296
+ version = "0.53.1"
2297
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2298
+ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2299
+
2300
+ [[package]]
2301
+ name = "windows_x86_64_gnullvm"
2302
+ version = "0.52.6"
2303
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2304
+ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2305
+
2306
+ [[package]]
2307
+ name = "windows_x86_64_gnullvm"
2308
+ version = "0.53.1"
2309
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2310
+ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2311
+
2312
+ [[package]]
2313
+ name = "windows_x86_64_msvc"
2314
+ version = "0.52.6"
2315
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2316
+ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2317
+
2318
+ [[package]]
2319
+ name = "windows_x86_64_msvc"
2320
+ version = "0.53.1"
2321
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2322
+ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2323
+
2324
+ [[package]]
2325
+ name = "winnow"
2326
+ version = "0.7.14"
2327
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2328
+ checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
2329
+ dependencies = [
2330
+ "memchr",
2331
+ ]
2332
+
2333
+ [[package]]
2334
+ name = "wit-bindgen"
2335
+ version = "0.46.0"
2336
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2337
+ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
2338
+
2339
+ [[package]]
2340
+ name = "writeable"
2341
+ version = "0.6.2"
2342
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2343
+ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
2344
+
2345
+ [[package]]
2346
+ name = "yoke"
2347
+ version = "0.8.1"
2348
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2349
+ checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
2350
+ dependencies = [
2351
+ "stable_deref_trait",
2352
+ "yoke-derive",
2353
+ "zerofrom",
2354
+ ]
2355
+
2356
+ [[package]]
2357
+ name = "yoke-derive"
2358
+ version = "0.8.1"
2359
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2360
+ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
2361
+ dependencies = [
2362
+ "proc-macro2",
2363
+ "quote",
2364
+ "syn",
2365
+ "synstructure",
2366
+ ]
2367
+
2368
+ [[package]]
2369
+ name = "zerocopy"
2370
+ version = "0.8.31"
2371
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2372
+ checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
2373
+ dependencies = [
2374
+ "zerocopy-derive",
2375
+ ]
2376
+
2377
+ [[package]]
2378
+ name = "zerocopy-derive"
2379
+ version = "0.8.31"
2380
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2381
+ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
2382
+ dependencies = [
2383
+ "proc-macro2",
2384
+ "quote",
2385
+ "syn",
2386
+ ]
2387
+
2388
+ [[package]]
2389
+ name = "zerofrom"
2390
+ version = "0.1.6"
2391
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2392
+ checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
2393
+ dependencies = [
2394
+ "zerofrom-derive",
2395
+ ]
2396
+
2397
+ [[package]]
2398
+ name = "zerofrom-derive"
2399
+ version = "0.1.6"
2400
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2401
+ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
2402
+ dependencies = [
2403
+ "proc-macro2",
2404
+ "quote",
2405
+ "syn",
2406
+ "synstructure",
2407
+ ]
2408
+
2409
+ [[package]]
2410
+ name = "zeroize"
2411
+ version = "1.8.2"
2412
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2413
+ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2414
+
2415
+ [[package]]
2416
+ name = "zerotrie"
2417
+ version = "0.2.3"
2418
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2419
+ checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
2420
+ dependencies = [
2421
+ "displaydoc",
2422
+ "yoke",
2423
+ "zerofrom",
2424
+ ]
2425
+
2426
+ [[package]]
2427
+ name = "zerovec"
2428
+ version = "0.11.5"
2429
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2430
+ checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
2431
+ dependencies = [
2432
+ "yoke",
2433
+ "zerofrom",
2434
+ "zerovec-derive",
2435
+ ]
2436
+
2437
+ [[package]]
2438
+ name = "zerovec-derive"
2439
+ version = "0.11.2"
2440
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2441
+ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
2442
+ dependencies = [
2443
+ "proc-macro2",
2444
+ "quote",
2445
+ "syn",
2446
+ ]
2447
+
2448
+ [[package]]
2449
+ name = "zip"
2450
+ version = "3.0.0"
2451
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2452
+ checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
2453
+ dependencies = [
2454
+ "arbitrary",
2455
+ "crc32fast",
2456
+ "flate2",
2457
+ "indexmap",
2458
+ "memchr",
2459
+ "zopfli",
2460
+ ]
2461
+
2462
+ [[package]]
2463
+ name = "zlib-rs"
2464
+ version = "0.5.5"
2465
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2466
+ checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
2467
+
2468
+ [[package]]
2469
+ name = "zmij"
2470
+ version = "1.0.10"
2471
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2472
+ checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
2473
+
2474
+ [[package]]
2475
+ name = "zopfli"
2476
+ version = "0.8.3"
2477
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2478
+ checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
2479
+ dependencies = [
2480
+ "bumpalo",
2481
+ "crc32fast",
2482
+ "log",
2483
+ "simd-adler32",
2484
+ ]
Cargo.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [package]
2
+ name = "vehicle-routing"
3
+ version = "0.4.1"
4
+ edition = "2021"
5
+ description = "Vehicle routing quickstart for SolverForge"
6
+ publish = false
7
+
8
+ [dependencies]
9
+ solverforge = { version = "0.5.1", features = ["serde"] }
10
+ rand = "0.8"
11
+
12
+ axum = "0.8"
13
+ tokio = { version = "1", features = ["full"] }
14
+ tower-http = { version = "0.6", features = ["fs", "cors"] }
15
+ tower = "0.5"
16
+ serde = { version = "1", features = ["derive"] }
17
+ serde_json = "1"
18
+ uuid = { version = "1", features = ["v4", "serde"] }
19
+ parking_lot = "0.12"
20
+ tracing = "0.1"
21
+ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
22
+ owo-colors = "4.2"
23
+ num-format = "0.4.4"
24
+ chrono = { version = "0.4", features = ["serde"] }
25
+ utoipa = { version = "5", features = ["axum_extras"] }
26
+ utoipa-swagger-ui = { version = "9", features = ["axum"] }
27
+ petgraph = "0.6"
28
+ reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
29
+ ordered-float = "4"
30
+ async-stream = "0.3"
31
+ tokio-stream = "0.1"
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-stage build for SolverForge Employee Scheduling (Rust)
2
+ #
3
+ # Build context: rust/employee-scheduling/
4
+ # Uses published solverforge crate from crates.io
5
+
6
+ FROM rust:1.83-alpine AS builder
7
+
8
+ # Install build dependencies
9
+ RUN apk add --no-cache musl-dev
10
+
11
+ WORKDIR /build
12
+
13
+ # Copy workspace files
14
+ COPY Cargo.toml Cargo.lock ./
15
+ COPY src/ ./src/
16
+ COPY static/ ./static/
17
+
18
+ # Build release binary with musl target for static linking
19
+ RUN cargo build --release --target x86_64-unknown-linux-musl
20
+
21
+ # Runtime stage - minimal Alpine image
22
+ FROM alpine:latest
23
+
24
+ RUN apk add --no-cache ca-certificates
25
+
26
+ WORKDIR /app
27
+
28
+ # Copy binary from builder (musl static binary)
29
+ COPY --from=builder /build/target/x86_64-unknown-linux-musl/release/employee-scheduling ./employee-scheduling
30
+
31
+ # Copy static files
32
+ COPY --from=builder /build/static/ ./static/
33
+
34
+ # Copy solver config
35
+ COPY --from=builder /build/solver.toml ./solver.toml
36
+
37
+ # Expose port 7860 (HF Spaces default)
38
+ EXPOSE 7860
39
+
40
+ # Run the application
41
+ CMD ["./vehicle-routing1"]
README.md CHANGED
@@ -1,12 +1,14 @@
1
  ---
2
- title: Vehicle Routing Rust Pre
3
- emoji:
4
- colorFrom: red
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
- short_description: Pre-Release SolverForge Quickstart for VRP
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
+ title: Vehicle Routing (Rust Pre-Release)
3
+ emoji: 🛻
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: apache-2.0
10
+ short_description: SolverForge Quickstart for Vehicle Routing in Rust
11
  ---
12
 
13
+
14
+ Visit [solverforge.org](https://www.solverforge.org).
src/api.rs ADDED
@@ -0,0 +1,1252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! REST API for Vehicle Routing Problem.
2
+ //!
3
+ //! Provides endpoints for:
4
+ //! - Demo data retrieval
5
+ //! - Route plan management (create, get, stop)
6
+ //! - Route geometry for map visualization
7
+ //! - Swagger UI at /q/swagger-ui
8
+
9
+ use axum::{
10
+ body::Body,
11
+ extract::{Path, State},
12
+ http::{header, StatusCode},
13
+ response::{IntoResponse, Response},
14
+ routing::{delete, get, post, put},
15
+ Json, Router,
16
+ };
17
+ use chrono::{NaiveDateTime, NaiveTime};
18
+ use serde::{Deserialize, Serialize};
19
+ use std::collections::HashMap;
20
+ use std::sync::Arc;
21
+ use tower_http::cors::{Any, CorsLayer};
22
+ use utoipa::{OpenApi, ToSchema};
23
+ use utoipa_swagger_ui::SwaggerUi;
24
+ use uuid::Uuid;
25
+
26
+ use crate::demo_data::{available_datasets, generate_by_name};
27
+ use crate::domain::{Vehicle, VehicleRoutePlan, Visit};
28
+ use crate::geometry::{encode_routes, EncodedSegment};
29
+ use crate::solver::{SolverConfig, SolverService, SolverStatus};
30
+ use solverforge::prelude::HardSoftScore;
31
+ use std::time::Duration;
32
+
33
+ // ============================================================================
34
+ // Date/Time Utilities
35
+ // ============================================================================
36
+
37
+ /// Reference date for time calculations (matches Python frontend).
38
+ const BASE_DATE: &str = "2025-01-05";
39
+
40
+ /// Converts seconds from midnight to ISO datetime string.
41
+ ///
42
+ /// # Examples
43
+ ///
44
+ /// ```
45
+ /// use vehicle_routing::api::seconds_to_iso;
46
+ ///
47
+ /// assert_eq!(seconds_to_iso(0), "2025-01-05T00:00:00");
48
+ /// assert_eq!(seconds_to_iso(8 * 3600), "2025-01-05T08:00:00");
49
+ /// assert_eq!(seconds_to_iso(8 * 3600 + 30 * 60 + 45), "2025-01-05T08:30:45");
50
+ /// ```
51
+ pub fn seconds_to_iso(seconds: i64) -> String {
52
+ let hours = (seconds / 3600) % 24;
53
+ let mins = (seconds % 3600) / 60;
54
+ let secs = seconds % 60;
55
+ format!("{}T{:02}:{:02}:{:02}", BASE_DATE, hours, mins, secs)
56
+ }
57
+
58
+ /// Parses ISO datetime string to seconds from midnight.
59
+ ///
60
+ /// # Examples
61
+ ///
62
+ /// ```
63
+ /// use vehicle_routing::api::iso_to_seconds;
64
+ ///
65
+ /// assert_eq!(iso_to_seconds("2025-01-05T08:00:00"), 8 * 3600);
66
+ /// assert_eq!(iso_to_seconds("2025-01-05T08:30:45"), 8 * 3600 + 30 * 60 + 45);
67
+ /// ```
68
+ pub fn iso_to_seconds(iso: &str) -> i64 {
69
+ if let Ok(dt) = NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%S") {
70
+ let midnight = NaiveDateTime::new(dt.date(), NaiveTime::from_hms_opt(0, 0, 0).unwrap());
71
+ (dt - midnight).num_seconds()
72
+ } else {
73
+ 0
74
+ }
75
+ }
76
+
77
+ /// Application state shared across handlers.
78
+ pub struct AppState {
79
+ pub solver: SolverService,
80
+ }
81
+
82
+ impl AppState {
83
+ pub fn new() -> Self {
84
+ Self {
85
+ solver: SolverService::new(),
86
+ }
87
+ }
88
+ }
89
+
90
+ impl Default for AppState {
91
+ fn default() -> Self {
92
+ Self::new()
93
+ }
94
+ }
95
+
96
+ /// Creates the API router with CORS and Swagger UI enabled.
97
+ pub fn create_router() -> Router {
98
+ let state = Arc::new(AppState::new());
99
+
100
+ let cors = CorsLayer::new()
101
+ .allow_origin(Any)
102
+ .allow_methods(Any)
103
+ .allow_headers(Any);
104
+
105
+ Router::new()
106
+ // Health & Info
107
+ .route("/health", get(health))
108
+ .route("/info", get(info))
109
+ // Demo data
110
+ .route("/demo-data", get(list_demo_data))
111
+ .route("/demo-data/{name}", get(get_demo_data))
112
+ .route("/demo-data/{name}/stream", get(get_demo_data_stream))
113
+ // Route plans
114
+ .route("/route-plans", post(create_route_plan))
115
+ .route("/route-plans", get(list_route_plans))
116
+ .route("/route-plans/{id}", get(get_route_plan))
117
+ .route("/route-plans/{id}/status", get(get_route_plan_status))
118
+ .route("/route-plans/{id}", delete(stop_solving))
119
+ .route("/route-plans/{id}/geometry", get(get_route_geometry))
120
+ // Analysis and recommendations
121
+ .route("/route-plans/analyze", put(analyze_route_plan))
122
+ .route("/route-plans/recommendation", post(recommend_assignment))
123
+ .route("/route-plans/recommendation/apply", post(apply_recommendation))
124
+ // Swagger UI at /q/swagger-ui (Quarkus-style path)
125
+ .merge(SwaggerUi::new("/q/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()))
126
+ .layer(cors)
127
+ .with_state(state)
128
+ }
129
+
130
+ // ============================================================================
131
+ // Health & Info
132
+ // ============================================================================
133
+
134
+ /// Health check response.
135
+ #[derive(Debug, Serialize, ToSchema)]
136
+ pub struct HealthResponse {
137
+ /// Status indicator ("UP" when healthy).
138
+ pub status: &'static str,
139
+ }
140
+
141
+ /// GET /health - Health check endpoint.
142
+ #[utoipa::path(
143
+ get,
144
+ path = "/health",
145
+ responses((status = 200, description = "Service is healthy", body = HealthResponse))
146
+ )]
147
+ async fn health() -> Json<HealthResponse> {
148
+ Json(HealthResponse { status: "UP" })
149
+ }
150
+
151
+ /// Application info response.
152
+ #[derive(Debug, Serialize, ToSchema)]
153
+ #[serde(rename_all = "camelCase")]
154
+ pub struct InfoResponse {
155
+ /// Application name.
156
+ pub name: &'static str,
157
+ /// Application version.
158
+ pub version: &'static str,
159
+ /// Solver engine name.
160
+ pub solver_engine: &'static str,
161
+ }
162
+
163
+ /// GET /info - Application info endpoint.
164
+ #[utoipa::path(
165
+ get,
166
+ path = "/info",
167
+ responses((status = 200, description = "Application info", body = InfoResponse))
168
+ )]
169
+ async fn info() -> Json<InfoResponse> {
170
+ Json(InfoResponse {
171
+ name: "Vehicle Routing",
172
+ version: env!("CARGO_PKG_VERSION"),
173
+ solver_engine: "SolverForge-RS",
174
+ })
175
+ }
176
+
177
+ // ============================================================================
178
+ // Demo Data
179
+ // ============================================================================
180
+
181
+ /// GET /demo-data - List available demo datasets.
182
+ #[utoipa::path(
183
+ get,
184
+ path = "/demo-data",
185
+ responses((status = 200, description = "List of demo dataset names", body = Vec<String>))
186
+ )]
187
+ async fn list_demo_data() -> Json<Vec<&'static str>> {
188
+ Json(available_datasets().to_vec())
189
+ }
190
+
191
+ /// GET /demo-data/{name} - Get a specific demo dataset.
192
+ #[utoipa::path(
193
+ get,
194
+ path = "/demo-data/{name}",
195
+ params(("name" = String, Path, description = "Demo dataset name")),
196
+ responses(
197
+ (status = 200, description = "Demo data retrieved", body = RoutePlanDto),
198
+ (status = 404, description = "Dataset not found")
199
+ )
200
+ )]
201
+ async fn get_demo_data(Path(name): Path<String>) -> Result<Json<RoutePlanDto>, StatusCode> {
202
+ match generate_by_name(&name) {
203
+ Some(plan) => Ok(Json(RoutePlanDto::from_plan(&plan, None))),
204
+ None => Err(StatusCode::NOT_FOUND),
205
+ }
206
+ }
207
+
208
+ /// GET /demo-data/{name}/stream - Get demo data with SSE progress updates.
209
+ ///
210
+ /// Returns Server-Sent Events (SSE) stream with progress and final solution.
211
+ /// Downloads OSM road network and computes real driving times.
212
+ /// Compatible with frontend's EventSource API.
213
+ ///
214
+ /// Progress phases:
215
+ /// - `network` (0-15%): Loading road network from cache or downloading
216
+ /// - `matrix` (15-75%): Computing travel time matrix (Dijkstra per location)
217
+ /// - `geometry` (75-95%): Computing route geometries for visualization
218
+ /// - `complete` (100%): Ready
219
+ async fn get_demo_data_stream(Path(name): Path<String>) -> impl IntoResponse {
220
+ use crate::routing::{BoundingBox, RoadNetwork};
221
+
222
+ // Generate the demo data
223
+ let mut plan = match generate_by_name(&name) {
224
+ Some(p) => p,
225
+ None => {
226
+ let error = r#"data: {"event":"error","message":"Demo data not found"}"#;
227
+ return Response::builder()
228
+ .status(StatusCode::OK)
229
+ .header(header::CONTENT_TYPE, "text/event-stream")
230
+ .header(header::CACHE_CONTROL, "no-cache")
231
+ .body(Body::from(format!("{}\n\n", error)))
232
+ .unwrap();
233
+ }
234
+ };
235
+
236
+ // Build bounding box from plan
237
+ let bbox = BoundingBox::new(
238
+ plan.south_west_corner[0],
239
+ plan.south_west_corner[1],
240
+ plan.north_east_corner[0],
241
+ plan.north_east_corner[1],
242
+ )
243
+ .expand(0.05);
244
+
245
+ // Extract coordinates for routing
246
+ let coords: Vec<(f64, f64)> = plan
247
+ .locations
248
+ .iter()
249
+ .map(|l| (l.latitude, l.longitude))
250
+ .collect();
251
+ let n = coords.len();
252
+
253
+ // Build SSE stream with granular progress
254
+ let stream = async_stream::stream! {
255
+ // Phase 1: Network loading (0-15%)
256
+ yield Ok::<_, std::convert::Infallible>(
257
+ format!("data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Loading road network...\",\"percent\":5,\"detail\":\"{} locations\"}}\n\n", n)
258
+ );
259
+
260
+ let network = match RoadNetwork::load_or_fetch(&bbox).await {
261
+ Ok(net) => {
262
+ yield Ok(format!(
263
+ "data: {{\"event\":\"progress\",\"phase\":\"network\",\"message\":\"Road network ready\",\"percent\":15,\"detail\":\"{} nodes, {} edges\"}}\n\n",
264
+ net.node_count(), net.edge_count()
265
+ ));
266
+ net
267
+ }
268
+ Err(e) => {
269
+ tracing::warn!("Road routing failed, using haversine: {}", e);
270
+ plan.finalize();
271
+ yield Ok("data: {\"event\":\"progress\",\"phase\":\"fallback\",\"message\":\"Using straight-line distances\",\"percent\":95}\n\n".to_string());
272
+
273
+ // Build response DTO and complete
274
+ let dto = RoutePlanDto::from_plan(&plan, None);
275
+ let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
276
+ yield Ok(format!(
277
+ "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\
278
+ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
279
+ solution_json
280
+ ));
281
+ return;
282
+ }
283
+ };
284
+
285
+ // Phase 2: Matrix computation (15-75%) via channel for real-time progress
286
+ let (matrix_tx, mut matrix_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>();
287
+ let network_for_matrix = std::sync::Arc::clone(&network);
288
+ let coords_for_matrix = coords.clone();
289
+
290
+ let matrix_handle = tokio::task::spawn_blocking(move || {
291
+ network_for_matrix.compute_matrix_with_progress(&coords_for_matrix, |row, total| {
292
+ let _ = matrix_tx.send((row, total));
293
+ })
294
+ });
295
+
296
+ // Stream matrix progress
297
+ while let Some((row, total)) = matrix_rx.recv().await {
298
+ // Progress from 15% to 75% (60% range)
299
+ let pct = 15 + (row + 1) * 60 / total;
300
+ yield Ok(format!(
301
+ "data: {{\"event\":\"progress\",\"phase\":\"matrix\",\"message\":\"Computing routes\",\"percent\":{},\"detail\":\"{}/{} locations\"}}\n\n",
302
+ pct, row + 1, total
303
+ ));
304
+ }
305
+
306
+ // Get matrix result
307
+ let matrix = match matrix_handle.await {
308
+ Ok(m) => m,
309
+ Err(e) => {
310
+ tracing::error!("Matrix computation failed: {}", e);
311
+ plan.finalize();
312
+ let dto = RoutePlanDto::from_plan(&plan, None);
313
+ let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
314
+ yield Ok(format!(
315
+ "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready (fallback)\",\"percent\":100}}\n\n\
316
+ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
317
+ solution_json
318
+ ));
319
+ return;
320
+ }
321
+ };
322
+ plan.travel_time_matrix = matrix;
323
+
324
+ // Phase 3: Geometry computation (75-95%) via channel
325
+ let (geo_tx, mut geo_rx) = tokio::sync::mpsc::unbounded_channel::<(usize, usize)>();
326
+ let network_for_geo = std::sync::Arc::clone(&network);
327
+ let coords_for_geo = coords.clone();
328
+
329
+ let geo_handle = tokio::task::spawn_blocking(move || {
330
+ network_for_geo.compute_all_geometries_with_progress(&coords_for_geo, |row, total| {
331
+ let _ = geo_tx.send((row, total));
332
+ })
333
+ });
334
+
335
+ // Stream geometry progress
336
+ while let Some((row, total)) = geo_rx.recv().await {
337
+ // Progress from 75% to 95% (20% range)
338
+ let pct = 75 + (row + 1) * 20 / total;
339
+ yield Ok(format!(
340
+ "data: {{\"event\":\"progress\",\"phase\":\"geometry\",\"message\":\"Generating routes\",\"percent\":{},\"detail\":\"{}/{} paths\"}}\n\n",
341
+ pct, row + 1, total
342
+ ));
343
+ }
344
+
345
+ // Get geometry result
346
+ let geometries = match geo_handle.await {
347
+ Ok(g) => g,
348
+ Err(e) => {
349
+ tracing::error!("Geometry computation failed: {}", e);
350
+ std::collections::HashMap::new()
351
+ }
352
+ };
353
+ plan.route_geometries = geometries;
354
+
355
+ // Build response DTO
356
+ let dto = RoutePlanDto::from_plan(&plan, None);
357
+ let solution_json = serde_json::to_string(&dto).unwrap_or_else(|_| "{}".to_string());
358
+
359
+ // Complete (100%)
360
+ yield Ok(format!(
361
+ "data: {{\"event\":\"progress\",\"phase\":\"complete\",\"message\":\"Ready!\",\"percent\":100}}\n\n\
362
+ data: {{\"event\":\"complete\",\"solution\":{}}}\n\n",
363
+ solution_json
364
+ ));
365
+ };
366
+
367
+ let body = Body::from_stream(stream);
368
+
369
+ Response::builder()
370
+ .status(StatusCode::OK)
371
+ .header(header::CONTENT_TYPE, "text/event-stream")
372
+ .header(header::CACHE_CONTROL, "no-cache")
373
+ .header(header::CONNECTION, "keep-alive")
374
+ .body(body)
375
+ .unwrap()
376
+ }
377
+
378
+ // ============================================================================
379
+ // DTOs (Python API Compatible)
380
+ // ============================================================================
381
+
382
+ /// Visit DTO matching Python API structure.
383
+ ///
384
+ /// All times are ISO datetime strings (e.g., "2025-01-05T08:30:00").
385
+ /// Location is `[latitude, longitude]` array.
386
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
387
+ #[serde(rename_all = "camelCase")]
388
+ pub struct VisitDto {
389
+ /// Unique visit identifier.
390
+ pub id: String,
391
+ /// Customer name.
392
+ pub name: String,
393
+ /// Location as `[latitude, longitude]`.
394
+ pub location: [f64; 2],
395
+ /// Quantity demanded.
396
+ pub demand: i32,
397
+ /// Earliest service start time (ISO datetime).
398
+ pub min_start_time: String,
399
+ /// Latest service end time (ISO datetime).
400
+ pub max_end_time: String,
401
+ /// Service duration in seconds.
402
+ pub service_duration: i32,
403
+ /// Assigned vehicle ID (null if unassigned).
404
+ #[serde(skip_serializing_if = "Option::is_none")]
405
+ pub vehicle: Option<String>,
406
+ /// Previous visit in route (null if first or unassigned).
407
+ #[serde(skip_serializing_if = "Option::is_none")]
408
+ pub previous_visit: Option<String>,
409
+ /// Next visit in route (null if last or unassigned).
410
+ #[serde(skip_serializing_if = "Option::is_none")]
411
+ pub next_visit: Option<String>,
412
+ /// Arrival time at visit (ISO datetime).
413
+ #[serde(skip_serializing_if = "Option::is_none")]
414
+ pub arrival_time: Option<String>,
415
+ /// Service start time (ISO datetime).
416
+ #[serde(skip_serializing_if = "Option::is_none")]
417
+ pub start_service_time: Option<String>,
418
+ /// Departure time from visit (ISO datetime).
419
+ #[serde(skip_serializing_if = "Option::is_none")]
420
+ pub departure_time: Option<String>,
421
+ /// Driving time from previous stop in seconds.
422
+ #[serde(skip_serializing_if = "Option::is_none")]
423
+ pub driving_time_seconds_from_previous_standstill: Option<i32>,
424
+ }
425
+
426
+ /// Vehicle DTO matching Python API structure.
427
+ ///
428
+ /// Visits are referenced by ID only; full visit data is in the plan's `visits` array.
429
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
430
+ #[serde(rename_all = "camelCase")]
431
+ pub struct VehicleDto {
432
+ /// Unique vehicle identifier.
433
+ pub id: String,
434
+ /// Vehicle name for display.
435
+ pub name: String,
436
+ /// Maximum capacity.
437
+ pub capacity: i32,
438
+ /// Home depot location as `[latitude, longitude]`.
439
+ pub home_location: [f64; 2],
440
+ /// Departure time from depot (ISO datetime).
441
+ pub departure_time: String,
442
+ /// Visit IDs in route order.
443
+ pub visits: Vec<String>,
444
+ /// Total demand of assigned visits.
445
+ pub total_demand: i32,
446
+ /// Total driving time in seconds.
447
+ pub total_driving_time_seconds: i32,
448
+ /// Arrival time back at depot (ISO datetime).
449
+ pub arrival_time: String,
450
+ }
451
+
452
+ /// Termination configuration for the solver.
453
+ ///
454
+ /// Supports multiple termination conditions that combine with OR logic.
455
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
456
+ #[serde(rename_all = "camelCase")]
457
+ pub struct TerminationConfigDto {
458
+ /// Stop after this many seconds.
459
+ #[serde(skip_serializing_if = "Option::is_none")]
460
+ pub seconds_spent_limit: Option<u64>,
461
+ /// Stop after this many seconds without improvement.
462
+ #[serde(skip_serializing_if = "Option::is_none")]
463
+ pub unimproved_seconds_spent_limit: Option<u64>,
464
+ /// Stop after this many steps.
465
+ #[serde(skip_serializing_if = "Option::is_none")]
466
+ pub step_count_limit: Option<u64>,
467
+ /// Stop after this many steps without improvement.
468
+ #[serde(skip_serializing_if = "Option::is_none")]
469
+ pub unimproved_step_count_limit: Option<u64>,
470
+ }
471
+
472
+ /// Full route plan DTO matching Python API structure.
473
+ ///
474
+ /// Contains ALL visits in a flat list; assignment is indicated by `vehicle` field.
475
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
476
+ #[serde(rename_all = "camelCase")]
477
+ pub struct RoutePlanDto {
478
+ /// Problem name.
479
+ pub name: String,
480
+ /// South-west corner of bounding box as `[latitude, longitude]`.
481
+ pub south_west_corner: [f64; 2],
482
+ /// North-east corner of bounding box as `[latitude, longitude]`.
483
+ pub north_east_corner: [f64; 2],
484
+ /// Earliest vehicle departure time (ISO datetime).
485
+ #[serde(skip_serializing_if = "Option::is_none")]
486
+ pub start_date_time: Option<String>,
487
+ /// Latest vehicle arrival time (ISO datetime).
488
+ #[serde(skip_serializing_if = "Option::is_none")]
489
+ pub end_date_time: Option<String>,
490
+ /// Total driving time across all vehicles in seconds.
491
+ pub total_driving_time_seconds: i32,
492
+ /// All vehicles.
493
+ pub vehicles: Vec<VehicleDto>,
494
+ /// All visits (assigned and unassigned).
495
+ pub visits: Vec<VisitDto>,
496
+ /// Current score (e.g., "0hard/-14400soft").
497
+ #[serde(skip_serializing_if = "Option::is_none")]
498
+ pub score: Option<String>,
499
+ /// Solver status ("NOT_SOLVING", "SOLVING_ACTIVE", etc.).
500
+ #[serde(skip_serializing_if = "Option::is_none")]
501
+ pub solver_status: Option<String>,
502
+ /// Termination configuration.
503
+ #[serde(skip_serializing_if = "Option::is_none")]
504
+ pub termination: Option<TerminationConfigDto>,
505
+ /// Precomputed travel time matrix (optional, from real roads).
506
+ /// Row/column order: depot locations first, then visit locations.
507
+ #[serde(skip_serializing_if = "Option::is_none")]
508
+ pub travel_time_matrix: Option<Vec<Vec<i64>>>,
509
+ }
510
+
511
+ impl RoutePlanDto {
512
+ /// Converts domain model to DTO for API responses.
513
+ ///
514
+ /// Builds flat visit list with vehicle assignments and timing info.
515
+ pub fn from_plan(plan: &VehicleRoutePlan, status: Option<SolverStatus>) -> Self {
516
+ // Build vehicle ID lookup: visit_idx -> (vehicle_id, position in route)
517
+ let mut visit_vehicle: HashMap<usize, (String, usize)> = HashMap::new();
518
+ for v in &plan.vehicles {
519
+ for (pos, &visit_idx) in v.visits.iter().enumerate() {
520
+ visit_vehicle.insert(visit_idx, (v.id.to_string(), pos));
521
+ }
522
+ }
523
+
524
+ // Build visit ID lookup for next/previous references
525
+ let visit_id = |idx: usize| -> String { format!("v{}", idx) };
526
+
527
+ // Calculate timing for all vehicles
528
+ let mut visit_timings: HashMap<usize, (i64, i64, i64, i32)> = HashMap::new(); // (arrival, service_start, departure, driving_time)
529
+ for v in &plan.vehicles {
530
+ let timings = plan.calculate_route_times(v);
531
+ let mut prev_loc = v.home_location.index;
532
+
533
+ for timing in timings.iter() {
534
+ let driving_time = plan.travel_time(prev_loc, plan.visits[timing.visit_idx].location.index);
535
+ let service_start = timing.arrival.max(plan.visits[timing.visit_idx].min_start_time);
536
+ visit_timings.insert(
537
+ timing.visit_idx,
538
+ (timing.arrival, service_start, timing.departure, driving_time as i32),
539
+ );
540
+ prev_loc = plan.visits[timing.visit_idx].location.index;
541
+ }
542
+ }
543
+
544
+ // Build ALL visits with assignment info
545
+ let visits: Vec<VisitDto> = plan
546
+ .visits
547
+ .iter()
548
+ .filter_map(|visit| {
549
+ let loc = plan.locations.get(visit.location.index)?;
550
+ let (vehicle_id, vehicle_pos) = visit_vehicle.get(&visit.index).cloned().unzip();
551
+ let vehicle_for_visit = vehicle_id.as_ref().and_then(|vid| {
552
+ plan.vehicles.iter().find(|v| v.id.to_string() == *vid)
553
+ });
554
+
555
+ // Get previous/next visit IDs
556
+ let (prev_visit, next_visit) = if let (Some(v), Some(pos)) = (vehicle_for_visit, vehicle_pos) {
557
+ let prev = if pos > 0 { Some(visit_id(v.visits[pos - 1])) } else { None };
558
+ let next = if pos + 1 < v.visits.len() { Some(visit_id(v.visits[pos + 1])) } else { None };
559
+ (prev, next)
560
+ } else {
561
+ (None, None)
562
+ };
563
+
564
+ let timing = visit_timings.get(&visit.index);
565
+
566
+ Some(VisitDto {
567
+ id: visit_id(visit.index),
568
+ name: visit.name.clone(),
569
+ location: [loc.latitude, loc.longitude],
570
+ demand: visit.demand,
571
+ min_start_time: seconds_to_iso(visit.min_start_time),
572
+ max_end_time: seconds_to_iso(visit.max_end_time),
573
+ service_duration: visit.service_duration as i32,
574
+ vehicle: vehicle_id,
575
+ previous_visit: prev_visit,
576
+ next_visit,
577
+ arrival_time: timing.map(|t| seconds_to_iso(t.0)),
578
+ start_service_time: timing.map(|t| seconds_to_iso(t.1)),
579
+ departure_time: timing.map(|t| seconds_to_iso(t.2)),
580
+ driving_time_seconds_from_previous_standstill: timing.map(|t| t.3),
581
+ })
582
+ })
583
+ .collect();
584
+
585
+ // Build vehicles with visit ID references
586
+ let vehicles: Vec<VehicleDto> = plan
587
+ .vehicles
588
+ .iter()
589
+ .map(|v| {
590
+ let home_loc = plan
591
+ .locations
592
+ .get(v.home_location.index)
593
+ .map(|l| [l.latitude, l.longitude])
594
+ .unwrap_or([0.0, 0.0]);
595
+
596
+ let total_driving = plan.total_driving_time(v);
597
+ let route_times = plan.calculate_route_times(v);
598
+
599
+ // Calculate arrival time back at depot
600
+ let arrival = if v.visits.is_empty() {
601
+ v.departure_time
602
+ } else if let Some(last_timing) = route_times.last() {
603
+ let last_visit = &plan.visits[last_timing.visit_idx];
604
+ let return_travel = plan.travel_time(last_visit.location.index, v.home_location.index);
605
+ last_timing.departure + return_travel
606
+ } else {
607
+ v.departure_time
608
+ };
609
+
610
+ // Compute total demand by summing visit demands
611
+ let total_demand: i32 = v
612
+ .visits
613
+ .iter()
614
+ .filter_map(|&idx| plan.visits.get(idx))
615
+ .map(|visit| visit.demand)
616
+ .sum();
617
+
618
+ VehicleDto {
619
+ id: v.id.to_string(),
620
+ name: v.name.clone(),
621
+ capacity: v.capacity,
622
+ home_location: home_loc,
623
+ departure_time: seconds_to_iso(v.departure_time),
624
+ visits: v.visits.iter().map(|&idx| visit_id(idx)).collect(),
625
+ total_demand,
626
+ total_driving_time_seconds: total_driving as i32,
627
+ arrival_time: seconds_to_iso(arrival),
628
+ }
629
+ })
630
+ .collect();
631
+
632
+ // Calculate plan-level times
633
+ let start_dt = plan.vehicles.iter().map(|v| v.departure_time).min();
634
+ let end_dt = vehicles.iter().map(|v| iso_to_seconds(&v.arrival_time)).max();
635
+
636
+ Self {
637
+ name: plan.name.clone(),
638
+ south_west_corner: plan.south_west_corner,
639
+ north_east_corner: plan.north_east_corner,
640
+ start_date_time: start_dt.map(seconds_to_iso),
641
+ end_date_time: end_dt.map(seconds_to_iso),
642
+ total_driving_time_seconds: plan.total_driving_time_all() as i32,
643
+ vehicles,
644
+ visits,
645
+ score: plan.score.map(|s| format!("{}", s)),
646
+ solver_status: status.map(|s| s.as_str().to_string()),
647
+ termination: None,
648
+ travel_time_matrix: if plan.travel_time_matrix.is_empty() {
649
+ None
650
+ } else {
651
+ Some(plan.travel_time_matrix.clone())
652
+ },
653
+ }
654
+ }
655
+
656
+ /// Converts DTO to domain model for solving.
657
+ pub fn to_domain(&self) -> VehicleRoutePlan {
658
+ use crate::domain::Location;
659
+
660
+ // Build locations (depots first, then visit locations)
661
+ let mut locations = Vec::new();
662
+ let mut depot_indices: HashMap<(i64, i64), usize> = HashMap::new();
663
+
664
+ // Add unique depot locations
665
+ for vdto in &self.vehicles {
666
+ let key = (
667
+ (vdto.home_location[0] * 1e6) as i64,
668
+ (vdto.home_location[1] * 1e6) as i64,
669
+ );
670
+ depot_indices.entry(key).or_insert_with(|| {
671
+ let idx = locations.len();
672
+ locations.push(Location::new(idx, vdto.home_location[0], vdto.home_location[1]));
673
+ idx
674
+ });
675
+ }
676
+
677
+ // Build visit ID to index mapping
678
+ let visit_id_to_idx: HashMap<&str, usize> = self
679
+ .visits
680
+ .iter()
681
+ .enumerate()
682
+ .map(|(i, v)| (v.id.as_str(), i))
683
+ .collect();
684
+
685
+ // Add visit locations
686
+ let visit_start_idx = locations.len();
687
+ for (i, vdto) in self.visits.iter().enumerate() {
688
+ locations.push(Location::new(
689
+ visit_start_idx + i,
690
+ vdto.location[0],
691
+ vdto.location[1],
692
+ ));
693
+ }
694
+
695
+ // Build visits - now needs Location object, not index
696
+ let visits: Vec<Visit> = self
697
+ .visits
698
+ .iter()
699
+ .enumerate()
700
+ .map(|(i, vdto)| {
701
+ let loc = locations[visit_start_idx + i].clone();
702
+ Visit::new(i, &vdto.name, loc)
703
+ .with_demand(vdto.demand)
704
+ .with_time_window(
705
+ iso_to_seconds(&vdto.min_start_time),
706
+ iso_to_seconds(&vdto.max_end_time),
707
+ )
708
+ .with_service_duration(vdto.service_duration as i64)
709
+ })
710
+ .collect();
711
+
712
+ // Build vehicles - now needs Location object, not index
713
+ let vehicles: Vec<Vehicle> = self
714
+ .vehicles
715
+ .iter()
716
+ .enumerate()
717
+ .map(|(i, vdto)| {
718
+ let key = (
719
+ (vdto.home_location[0] * 1e6) as i64,
720
+ (vdto.home_location[1] * 1e6) as i64,
721
+ );
722
+ let home_idx = depot_indices[&key];
723
+ let home_loc = locations[home_idx].clone();
724
+
725
+ // Map visit IDs to indices
726
+ let visit_indices: Vec<usize> = vdto
727
+ .visits
728
+ .iter()
729
+ .filter_map(|vid| visit_id_to_idx.get(vid.as_str()).copied())
730
+ .collect();
731
+
732
+ let mut v = Vehicle::new(i, &vdto.name, vdto.capacity, home_loc);
733
+ v.departure_time = iso_to_seconds(&vdto.departure_time);
734
+ v.visits = visit_indices;
735
+ v
736
+ })
737
+ .collect();
738
+
739
+ let mut plan = VehicleRoutePlan::new(&self.name, locations, visits, vehicles);
740
+ plan.south_west_corner = self.south_west_corner;
741
+ plan.north_east_corner = self.north_east_corner;
742
+
743
+ // Use provided matrix (from real roads) if available, otherwise compute haversine
744
+ if let Some(matrix) = &self.travel_time_matrix {
745
+ plan.travel_time_matrix = matrix.clone();
746
+ } else {
747
+ plan.finalize();
748
+ }
749
+ plan
750
+ }
751
+ }
752
+
753
+ // ============================================================================
754
+ // Route Plan Handlers
755
+ // ============================================================================
756
+
757
+ /// POST /route-plans - Create and start solving a route plan.
758
+ #[utoipa::path(
759
+ post,
760
+ path = "/route-plans",
761
+ request_body = RoutePlanDto,
762
+ responses((status = 200, description = "Job ID", body = String))
763
+ )]
764
+ async fn create_route_plan(
765
+ State(state): State<Arc<AppState>>,
766
+ Json(dto): Json<RoutePlanDto>,
767
+ ) -> Result<String, StatusCode> {
768
+ let id = Uuid::new_v4().to_string();
769
+ let mut plan = dto.to_domain();
770
+
771
+ // Initialize road routing (uses cached network - instant after first download)
772
+ if let Err(e) = plan.init_routing().await {
773
+ tracing::error!("Road routing initialization failed: {}", e);
774
+ return Err(StatusCode::SERVICE_UNAVAILABLE);
775
+ }
776
+
777
+ // Convert termination config from DTO
778
+ let config = if let Some(term) = &dto.termination {
779
+ SolverConfig {
780
+ time_limit: term.seconds_spent_limit.map(Duration::from_secs),
781
+ unimproved_time_limit: term.unimproved_seconds_spent_limit.map(Duration::from_secs),
782
+ step_limit: term.step_count_limit,
783
+ unimproved_step_limit: term.unimproved_step_count_limit,
784
+ }
785
+ } else {
786
+ SolverConfig::default_config()
787
+ };
788
+
789
+ let job = state.solver.create_job_with_config(id.clone(), plan, config);
790
+ state.solver.start_solving(job);
791
+ Ok(id)
792
+ }
793
+
794
+ /// GET /route-plans - List all route plan IDs.
795
+ #[utoipa::path(
796
+ get,
797
+ path = "/route-plans",
798
+ responses((status = 200, description = "List of job IDs", body = Vec<String>))
799
+ )]
800
+ async fn list_route_plans(State(state): State<Arc<AppState>>) -> Json<Vec<String>> {
801
+ Json(state.solver.list_jobs())
802
+ }
803
+
804
+ /// GET /route-plans/{id} - Get current route plan state.
805
+ #[utoipa::path(
806
+ get,
807
+ path = "/route-plans/{id}",
808
+ params(("id" = String, Path, description = "Route plan ID")),
809
+ responses(
810
+ (status = 200, description = "Route plan retrieved", body = RoutePlanDto),
811
+ (status = 404, description = "Not found")
812
+ )
813
+ )]
814
+ async fn get_route_plan(
815
+ State(state): State<Arc<AppState>>,
816
+ Path(id): Path<String>,
817
+ ) -> Result<Json<RoutePlanDto>, StatusCode> {
818
+ match state.solver.get_job(&id) {
819
+ Some(job) => {
820
+ let guard = job.read();
821
+ Ok(Json(RoutePlanDto::from_plan(
822
+ &guard.plan,
823
+ Some(guard.status),
824
+ )))
825
+ }
826
+ None => Err(StatusCode::NOT_FOUND),
827
+ }
828
+ }
829
+
830
+ /// Status response.
831
+ #[derive(Debug, Serialize, ToSchema)]
832
+ #[serde(rename_all = "camelCase")]
833
+ pub struct StatusResponse {
834
+ /// Current score.
835
+ pub score: Option<String>,
836
+ /// Solver status.
837
+ pub solver_status: String,
838
+ }
839
+
840
+ /// GET /route-plans/{id}/status - Get route plan status only.
841
+ #[utoipa::path(
842
+ get,
843
+ path = "/route-plans/{id}/status",
844
+ params(("id" = String, Path, description = "Route plan ID")),
845
+ responses(
846
+ (status = 200, description = "Status retrieved", body = StatusResponse),
847
+ (status = 404, description = "Not found")
848
+ )
849
+ )]
850
+ async fn get_route_plan_status(
851
+ State(state): State<Arc<AppState>>,
852
+ Path(id): Path<String>,
853
+ ) -> Result<Json<StatusResponse>, StatusCode> {
854
+ match state.solver.get_job(&id) {
855
+ Some(job) => {
856
+ let guard = job.read();
857
+ Ok(Json(StatusResponse {
858
+ score: guard.plan.score.map(|s| format!("{}", s)),
859
+ solver_status: guard.status.as_str().to_string(),
860
+ }))
861
+ }
862
+ None => Err(StatusCode::NOT_FOUND),
863
+ }
864
+ }
865
+
866
+ /// DELETE /route-plans/{id} - Stop solving and get final solution.
867
+ #[utoipa::path(
868
+ delete,
869
+ path = "/route-plans/{id}",
870
+ params(("id" = String, Path, description = "Route plan ID")),
871
+ responses(
872
+ (status = 200, description = "Solving stopped", body = RoutePlanDto),
873
+ (status = 404, description = "Not found")
874
+ )
875
+ )]
876
+ async fn stop_solving(
877
+ State(state): State<Arc<AppState>>,
878
+ Path(id): Path<String>,
879
+ ) -> Result<Json<RoutePlanDto>, StatusCode> {
880
+ state.solver.stop_solving(&id);
881
+ match state.solver.remove_job(&id) {
882
+ Some(job) => {
883
+ let guard = job.read();
884
+ Ok(Json(RoutePlanDto::from_plan(
885
+ &guard.plan,
886
+ Some(SolverStatus::NotSolving),
887
+ )))
888
+ }
889
+ None => Err(StatusCode::NOT_FOUND),
890
+ }
891
+ }
892
+
893
+ /// Geometry response with encoded polylines for map rendering.
894
+ #[derive(Debug, Serialize, ToSchema)]
895
+ #[serde(rename_all = "camelCase")]
896
+ pub struct GeometryResponse {
897
+ /// Encoded route segments per vehicle.
898
+ pub segments: Vec<EncodedSegment>,
899
+ }
900
+
901
+ /// GET /route-plans/{id}/geometry - Get encoded polylines for routes.
902
+ #[utoipa::path(
903
+ get,
904
+ path = "/route-plans/{id}/geometry",
905
+ params(("id" = String, Path, description = "Route plan ID")),
906
+ responses(
907
+ (status = 200, description = "Geometry retrieved", body = GeometryResponse),
908
+ (status = 404, description = "Not found")
909
+ )
910
+ )]
911
+ async fn get_route_geometry(
912
+ State(state): State<Arc<AppState>>,
913
+ Path(id): Path<String>,
914
+ ) -> Result<Json<GeometryResponse>, StatusCode> {
915
+ match state.solver.get_job(&id) {
916
+ Some(job) => {
917
+ let guard = job.read();
918
+ let segments = encode_routes(&guard.plan);
919
+ Ok(Json(GeometryResponse { segments }))
920
+ }
921
+ None => Err(StatusCode::NOT_FOUND),
922
+ }
923
+ }
924
+
925
+ // ============================================================================
926
+ // Score Analysis
927
+ // ============================================================================
928
+
929
+ /// Match analysis for a constraint violation.
930
+ #[derive(Debug, Clone, Serialize, ToSchema)]
931
+ pub struct MatchAnalysisDto {
932
+ /// Constraint name.
933
+ pub name: String,
934
+ /// Score impact of this match.
935
+ pub score: String,
936
+ /// Description of the match.
937
+ pub justification: String,
938
+ }
939
+
940
+ /// Constraint analysis showing all matches.
941
+ #[derive(Debug, Clone, Serialize, ToSchema)]
942
+ pub struct ConstraintAnalysisDto {
943
+ /// Constraint name.
944
+ pub name: String,
945
+ /// Constraint weight (score per violation).
946
+ pub weight: String,
947
+ /// Total score from this constraint.
948
+ pub score: String,
949
+ /// Individual matches.
950
+ pub matches: Vec<MatchAnalysisDto>,
951
+ }
952
+
953
+ /// Response from score analysis endpoint.
954
+ #[derive(Debug, Serialize, ToSchema)]
955
+ pub struct AnalyzeResponse {
956
+ /// Per-constraint breakdown.
957
+ pub constraints: Vec<ConstraintAnalysisDto>,
958
+ }
959
+
960
+ /// PUT /route-plans/analyze - Analyze constraint violations.
961
+ #[utoipa::path(
962
+ put,
963
+ path = "/route-plans/analyze",
964
+ request_body = RoutePlanDto,
965
+ responses((status = 200, description = "Constraint analysis", body = AnalyzeResponse))
966
+ )]
967
+ async fn analyze_route_plan(Json(dto): Json<RoutePlanDto>) -> Json<AnalyzeResponse> {
968
+ use crate::constraints::{calculate_late_minutes, calculate_excess_capacity};
969
+
970
+ let plan = dto.to_domain();
971
+
972
+ // Calculate constraint scores
973
+ let cap_total: i64 = plan.vehicles.iter()
974
+ .map(|v| calculate_excess_capacity(&plan, v) as i64)
975
+ .sum();
976
+
977
+ let tw_total: i64 = plan.vehicles.iter()
978
+ .map(|v| calculate_late_minutes(&plan, v))
979
+ .sum();
980
+
981
+ let travel_total: i64 = plan.vehicles.iter()
982
+ .map(|v| plan.total_driving_time(v))
983
+ .sum();
984
+
985
+ let cap_score = HardSoftScore::of_hard(-cap_total);
986
+ let tw_score = HardSoftScore::of_hard(-tw_total);
987
+ let travel_score = HardSoftScore::of_soft(-travel_total);
988
+
989
+ // Helper to compute total demand
990
+ let total_demand = |v: &Vehicle| -> i32 {
991
+ v.visits.iter()
992
+ .filter_map(|&idx| plan.visits.get(idx))
993
+ .map(|visit| visit.demand)
994
+ .sum()
995
+ };
996
+
997
+ // Build detailed matches for capacity constraint
998
+ let cap_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter()
999
+ .filter(|v| total_demand(v) > v.capacity)
1000
+ .map(|v| {
1001
+ let demand = total_demand(v);
1002
+ let excess = demand - v.capacity;
1003
+ MatchAnalysisDto {
1004
+ name: "Vehicle capacity".to_string(),
1005
+ score: format!("{}hard/0soft", -excess),
1006
+ justification: format!("{} is over capacity by {} (demand {} > capacity {})",
1007
+ v.name, excess, demand, v.capacity),
1008
+ }
1009
+ })
1010
+ .collect();
1011
+
1012
+ // Build detailed matches for time window constraint
1013
+ let mut tw_matches: Vec<MatchAnalysisDto> = Vec::new();
1014
+ for vehicle in &plan.vehicles {
1015
+ let timings = plan.calculate_route_times(vehicle);
1016
+ for timing in &timings {
1017
+ if let Some(visit) = plan.get_visit(timing.visit_idx) {
1018
+ if timing.departure > visit.max_end_time {
1019
+ let late_secs = timing.departure - visit.max_end_time;
1020
+ let late_mins = (late_secs + 59) / 60;
1021
+ tw_matches.push(MatchAnalysisDto {
1022
+ name: "Service finished after max end time".to_string(),
1023
+ score: format!("{}hard/0soft", -late_mins),
1024
+ justification: format!("{} finishes {} mins late (ends at {}, max {})",
1025
+ visit.name, late_mins,
1026
+ seconds_to_iso(timing.departure),
1027
+ seconds_to_iso(visit.max_end_time)),
1028
+ });
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+
1034
+ // Build matches for travel time
1035
+ let travel_matches: Vec<MatchAnalysisDto> = plan.vehicles.iter()
1036
+ .filter(|v| !v.visits.is_empty())
1037
+ .map(|v| {
1038
+ let time = plan.total_driving_time(v);
1039
+ MatchAnalysisDto {
1040
+ name: "Minimize travel time".to_string(),
1041
+ score: format!("0hard/{}soft", -time),
1042
+ justification: format!("{} drives {} seconds", v.name, time),
1043
+ }
1044
+ })
1045
+ .collect();
1046
+
1047
+ let constraints = vec![
1048
+ ConstraintAnalysisDto {
1049
+ name: "Vehicle capacity".to_string(),
1050
+ weight: "1hard/0soft".to_string(),
1051
+ score: format!("{}", cap_score),
1052
+ matches: cap_matches,
1053
+ },
1054
+ ConstraintAnalysisDto {
1055
+ name: "Service finished after max end time".to_string(),
1056
+ weight: "1hard/0soft".to_string(),
1057
+ score: format!("{}", tw_score),
1058
+ matches: tw_matches,
1059
+ },
1060
+ ConstraintAnalysisDto {
1061
+ name: "Minimize travel time".to_string(),
1062
+ weight: "0hard/1soft".to_string(),
1063
+ score: format!("{}", travel_score),
1064
+ matches: travel_matches,
1065
+ },
1066
+ ];
1067
+
1068
+ Json(AnalyzeResponse { constraints })
1069
+ }
1070
+
1071
+ // ============================================================================
1072
+ // Recommendation
1073
+ // ============================================================================
1074
+
1075
+ /// Recommended assignment for a visit.
1076
+ #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1077
+ #[serde(rename_all = "camelCase")]
1078
+ pub struct VehicleRecommendation {
1079
+ /// Vehicle ID to assign to.
1080
+ pub vehicle_id: String,
1081
+ /// Position in vehicle's route.
1082
+ pub index: usize,
1083
+ }
1084
+
1085
+ /// Recommendation response with score impact.
1086
+ #[derive(Debug, Clone, Serialize, ToSchema)]
1087
+ #[serde(rename_all = "camelCase")]
1088
+ pub struct RecommendedAssignment {
1089
+ /// The recommendation.
1090
+ pub proposition: VehicleRecommendation,
1091
+ /// Score difference if applied.
1092
+ pub score_diff: String,
1093
+ }
1094
+
1095
+ /// Request for visit recommendations.
1096
+ #[derive(Debug, Deserialize, ToSchema)]
1097
+ #[serde(rename_all = "camelCase")]
1098
+ pub struct RecommendationRequest {
1099
+ /// Current solution.
1100
+ pub solution: RoutePlanDto,
1101
+ /// Visit ID to find recommendations for.
1102
+ pub visit_id: String,
1103
+ }
1104
+
1105
+ /// Request to apply a recommendation.
1106
+ #[derive(Debug, Deserialize, ToSchema)]
1107
+ #[serde(rename_all = "camelCase")]
1108
+ pub struct ApplyRecommendationRequest {
1109
+ /// Current solution.
1110
+ pub solution: RoutePlanDto,
1111
+ /// Visit ID to assign.
1112
+ pub visit_id: String,
1113
+ /// Vehicle ID to assign to.
1114
+ pub vehicle_id: String,
1115
+ /// Position in vehicle's route.
1116
+ pub index: usize,
1117
+ }
1118
+
1119
+ /// POST /route-plans/recommendation - Get recommendations for assigning a visit.
1120
+ #[utoipa::path(
1121
+ post,
1122
+ path = "/route-plans/recommendation",
1123
+ request_body = RecommendationRequest,
1124
+ responses((status = 200, description = "Recommendations", body = Vec<RecommendedAssignment>))
1125
+ )]
1126
+ async fn recommend_assignment(Json(request): Json<RecommendationRequest>) -> Json<Vec<RecommendedAssignment>> {
1127
+ use crate::constraints::calculate_score;
1128
+
1129
+ let mut plan = request.solution.to_domain();
1130
+
1131
+ // Find the visit index by ID
1132
+ let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX);
1133
+ if visit_id_num >= plan.visits.len() {
1134
+ return Json(vec![]);
1135
+ }
1136
+
1137
+ // Remove visit from any current assignment
1138
+ for vehicle in &mut plan.vehicles {
1139
+ vehicle.visits.retain(|&v| v != visit_id_num);
1140
+ }
1141
+ plan.finalize();
1142
+
1143
+ // Get baseline score
1144
+ let baseline = calculate_score(&plan);
1145
+
1146
+ // Try inserting at each position in each vehicle
1147
+ let mut recommendations: Vec<(RecommendedAssignment, HardSoftScore)> = Vec::new();
1148
+
1149
+ for (v_idx, vehicle) in plan.vehicles.iter().enumerate() {
1150
+ for insert_pos in 0..=vehicle.visits.len() {
1151
+ // Clone and insert
1152
+ let mut test_plan = plan.clone();
1153
+ test_plan.vehicles[v_idx].visits.insert(insert_pos, visit_id_num);
1154
+ test_plan.finalize();
1155
+
1156
+ let new_score = calculate_score(&test_plan);
1157
+ let diff = new_score - baseline;
1158
+
1159
+ recommendations.push((
1160
+ RecommendedAssignment {
1161
+ proposition: VehicleRecommendation {
1162
+ vehicle_id: vehicle.id.to_string(),
1163
+ index: insert_pos,
1164
+ },
1165
+ score_diff: format!("{}", diff),
1166
+ },
1167
+ diff,
1168
+ ));
1169
+ }
1170
+ }
1171
+
1172
+ // Sort by score (best first) and take top 5
1173
+ recommendations.sort_by(|a, b| b.1.cmp(&a.1));
1174
+ let top5: Vec<RecommendedAssignment> = recommendations.into_iter().take(5).map(|(r, _)| r).collect();
1175
+
1176
+ Json(top5)
1177
+ }
1178
+
1179
+ /// POST /route-plans/recommendation/apply - Apply a recommendation.
1180
+ #[utoipa::path(
1181
+ post,
1182
+ path = "/route-plans/recommendation/apply",
1183
+ request_body = ApplyRecommendationRequest,
1184
+ responses((status = 200, description = "Updated solution", body = RoutePlanDto))
1185
+ )]
1186
+ async fn apply_recommendation(Json(request): Json<ApplyRecommendationRequest>) -> Json<RoutePlanDto> {
1187
+ let mut plan = request.solution.to_domain();
1188
+
1189
+ // Find the visit index by ID
1190
+ let visit_id_num: usize = request.visit_id.trim_start_matches('v').parse().unwrap_or(usize::MAX);
1191
+ let vehicle_id_num: usize = request.vehicle_id.parse().unwrap_or(usize::MAX);
1192
+
1193
+ // Remove visit from any current assignment
1194
+ for vehicle in &mut plan.vehicles {
1195
+ vehicle.visits.retain(|&v| v != visit_id_num);
1196
+ }
1197
+
1198
+ // Insert at specified position
1199
+ if let Some(vehicle) = plan.vehicles.iter_mut().find(|v| v.id == vehicle_id_num) {
1200
+ let insert_idx = request.index.min(vehicle.visits.len());
1201
+ vehicle.visits.insert(insert_idx, visit_id_num);
1202
+ }
1203
+
1204
+ plan.finalize();
1205
+
1206
+ // Recalculate score
1207
+ use crate::constraints::calculate_score;
1208
+ plan.score = Some(calculate_score(&plan));
1209
+
1210
+ Json(RoutePlanDto::from_plan(&plan, None))
1211
+ }
1212
+
1213
+ // ============================================================================
1214
+ // OpenAPI Documentation
1215
+ // ============================================================================
1216
+
1217
+ #[derive(OpenApi)]
1218
+ #[openapi(
1219
+ paths(
1220
+ health,
1221
+ info,
1222
+ list_demo_data,
1223
+ get_demo_data,
1224
+ create_route_plan,
1225
+ list_route_plans,
1226
+ get_route_plan,
1227
+ get_route_plan_status,
1228
+ stop_solving,
1229
+ get_route_geometry,
1230
+ analyze_route_plan,
1231
+ recommend_assignment,
1232
+ apply_recommendation,
1233
+ ),
1234
+ components(schemas(
1235
+ HealthResponse,
1236
+ InfoResponse,
1237
+ VisitDto,
1238
+ VehicleDto,
1239
+ RoutePlanDto,
1240
+ TerminationConfigDto,
1241
+ StatusResponse,
1242
+ GeometryResponse,
1243
+ MatchAnalysisDto,
1244
+ ConstraintAnalysisDto,
1245
+ AnalyzeResponse,
1246
+ VehicleRecommendation,
1247
+ RecommendedAssignment,
1248
+ RecommendationRequest,
1249
+ ApplyRecommendationRequest,
1250
+ ))
1251
+ )]
1252
+ struct ApiDoc;
src/console.rs ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Colorful console output for solver metrics.
2
+
3
+ use num_format::{Locale, ToFormattedString};
4
+ use owo_colors::OwoColorize;
5
+ use std::time::{Duration, Instant};
6
+
7
+ /// ASCII art banner for solver startup.
8
+ pub fn print_banner() {
9
+ let banner = r#"
10
+ ____ _ _____
11
+ / ___| ___ | |_ _____ _ __| ___|__ _ __ __ _ ___
12
+ \___ \ / _ \| \ \ / / _ \ '__| |_ / _ \| '__/ _` |/ _ \
13
+ ___) | (_) | |\ V / __/ | | _| (_) | | | (_| | __/
14
+ |____/ \___/|_| \_/ \___|_| |_| \___/|_| \__, |\___|
15
+ |___/
16
+ "#;
17
+ println!("{}", banner.cyan().bold());
18
+ println!(
19
+ " {} {}\n",
20
+ format!("v{}", env!("CARGO_PKG_VERSION")).bright_black(),
21
+ "Vehicle Routing".bright_cyan()
22
+ );
23
+ }
24
+
25
+ /// Prints "Solving started" message.
26
+ pub fn print_solving_started(
27
+ time_spent_ms: u64,
28
+ best_score: &str,
29
+ entity_count: usize,
30
+ variable_count: usize,
31
+ value_count: usize,
32
+ ) {
33
+ println!(
34
+ "{} {} {} time spent ({}), best score ({}), random ({})",
35
+ timestamp().bright_black(),
36
+ "INFO".bright_green(),
37
+ "[Solver]".bright_cyan(),
38
+ format!("{}ms", time_spent_ms).yellow(),
39
+ format_score(best_score),
40
+ "StdRng".white()
41
+ );
42
+
43
+ // Problem scale
44
+ let scale = calculate_problem_scale(entity_count, value_count);
45
+ println!(
46
+ "{} {} {} entity count ({}), variable count ({}), value count ({}), problem scale ({})",
47
+ timestamp().bright_black(),
48
+ "INFO".bright_green(),
49
+ "[Solver]".bright_cyan(),
50
+ entity_count.to_formatted_string(&Locale::en).bright_yellow(),
51
+ variable_count.to_formatted_string(&Locale::en).bright_yellow(),
52
+ value_count.to_formatted_string(&Locale::en).bright_yellow(),
53
+ scale.bright_magenta()
54
+ );
55
+ }
56
+
57
+ /// Prints a phase start message.
58
+ pub fn print_phase_start(phase_name: &str, phase_index: usize) {
59
+ println!(
60
+ "{} {} {} {} phase ({}) started",
61
+ timestamp().bright_black(),
62
+ "INFO".bright_green(),
63
+ format!("[{}]", phase_name).bright_cyan(),
64
+ phase_name.white().bold(),
65
+ phase_index.to_string().yellow()
66
+ );
67
+ }
68
+
69
+ /// Prints a phase end message with metrics.
70
+ pub fn print_phase_end(
71
+ phase_name: &str,
72
+ phase_index: usize,
73
+ duration: Duration,
74
+ steps_accepted: u64,
75
+ moves_evaluated: u64,
76
+ best_score: &str,
77
+ ) {
78
+ let moves_per_sec = if duration.as_secs_f64() > 0.0 {
79
+ (moves_evaluated as f64 / duration.as_secs_f64()) as u64
80
+ } else {
81
+ 0
82
+ };
83
+ let acceptance_rate = if moves_evaluated > 0 {
84
+ (steps_accepted as f64 / moves_evaluated as f64) * 100.0
85
+ } else {
86
+ 0.0
87
+ };
88
+
89
+ println!(
90
+ "{} {} {} {} phase ({}) ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), step total ({}, {:.1}% accepted)",
91
+ timestamp().bright_black(),
92
+ "INFO".bright_green(),
93
+ format!("[{}]", phase_name).bright_cyan(),
94
+ phase_name.white().bold(),
95
+ phase_index.to_string().yellow(),
96
+ format_duration(duration).yellow(),
97
+ format_score(best_score),
98
+ moves_per_sec.to_formatted_string(&Locale::en).bright_magenta().bold(),
99
+ steps_accepted.to_formatted_string(&Locale::en).white(),
100
+ acceptance_rate
101
+ );
102
+ }
103
+
104
+ /// Prints a step progress update with moves/sec prominently displayed.
105
+ pub fn print_step_progress(
106
+ step: u64,
107
+ elapsed: Duration,
108
+ moves_evaluated: u64,
109
+ score: &str,
110
+ ) {
111
+ let moves_per_sec = if elapsed.as_secs_f64() > 0.0 {
112
+ (moves_evaluated as f64 / elapsed.as_secs_f64()) as u64
113
+ } else {
114
+ 0
115
+ };
116
+
117
+ println!(
118
+ " {} Step {:>7} │ {} │ {}/sec │ {}",
119
+ "→".bright_blue(),
120
+ step.to_formatted_string(&Locale::en).white(),
121
+ format!("{:>6}", format_duration(elapsed)).bright_black(),
122
+ format!("{:>8}", moves_per_sec.to_formatted_string(&Locale::en)).bright_magenta().bold(),
123
+ format_score(score)
124
+ );
125
+ }
126
+
127
+ /// Prints solver completion summary.
128
+ pub fn print_solving_ended(
129
+ total_duration: Duration,
130
+ total_moves: u64,
131
+ phase_count: usize,
132
+ final_score: &str,
133
+ is_feasible: bool,
134
+ ) {
135
+ let moves_per_sec = if total_duration.as_secs_f64() > 0.0 {
136
+ (total_moves as f64 / total_duration.as_secs_f64()) as u64
137
+ } else {
138
+ 0
139
+ };
140
+
141
+ println!(
142
+ "{} {} {} Solving ended: time spent ({}), best score ({}), move evaluation speed ({}/sec), phase total ({})",
143
+ timestamp().bright_black(),
144
+ "INFO".bright_green(),
145
+ "[Solver]".bright_cyan(),
146
+ format_duration(total_duration).yellow(),
147
+ format_score(final_score),
148
+ moves_per_sec.to_formatted_string(&Locale::en).bright_magenta().bold(),
149
+ phase_count.to_string().white()
150
+ );
151
+
152
+ // Pretty summary box (60 chars wide, 56 char content area)
153
+ println!();
154
+ println!("{}", "╔═════════════════════��════════════════════════════════════╗".bright_cyan());
155
+
156
+ let status_text = if is_feasible {
157
+ "✓ FEASIBLE SOLUTION FOUND"
158
+ } else {
159
+ "✗ INFEASIBLE (hard constraints violated)"
160
+ };
161
+ let status_colored = if is_feasible {
162
+ status_text.bright_green().bold().to_string()
163
+ } else {
164
+ status_text.bright_red().bold().to_string()
165
+ };
166
+ let status_padding = 56 - status_text.chars().count();
167
+ let left_pad = status_padding / 2;
168
+ let right_pad = status_padding - left_pad;
169
+ println!(
170
+ "{}{}{}{}{}",
171
+ "║".bright_cyan(),
172
+ " ".repeat(left_pad),
173
+ status_colored,
174
+ " ".repeat(right_pad),
175
+ "║".bright_cyan()
176
+ );
177
+
178
+ println!("{}", "╠══════════════════════════════════════════════════════════╣".bright_cyan());
179
+
180
+ let score_str = final_score;
181
+ println!(
182
+ "{} {:<18}{:>36} {}",
183
+ "║".bright_cyan(),
184
+ "Final Score:",
185
+ score_str,
186
+ "║".bright_cyan()
187
+ );
188
+
189
+ let time_str = format!("{:.2}s", total_duration.as_secs_f64());
190
+ println!(
191
+ "{} {:<18}{:>36} {}",
192
+ "║".bright_cyan(),
193
+ "Solving Time:",
194
+ time_str,
195
+ "║".bright_cyan()
196
+ );
197
+
198
+ let speed_str = format!("{}/sec", moves_per_sec.to_formatted_string(&Locale::en));
199
+ println!(
200
+ "{} {:<18}{:>36} {}",
201
+ "║".bright_cyan(),
202
+ "Move Speed:",
203
+ speed_str,
204
+ "║".bright_cyan()
205
+ );
206
+
207
+ println!("{}", "╚══════════════════════════════════════════════════════════╝".bright_cyan());
208
+ println!();
209
+ }
210
+
211
+ /// Prints VRP-specific configuration.
212
+ pub fn print_config(vehicles: usize, visits: usize, locations: usize) {
213
+ println!(
214
+ "{} {} {} Problem: vehicles ({}), visits ({}), locations ({})",
215
+ timestamp().bright_black(),
216
+ "INFO".bright_green(),
217
+ "[Solver]".bright_cyan(),
218
+ vehicles.to_formatted_string(&Locale::en).bright_yellow(),
219
+ visits.to_formatted_string(&Locale::en).bright_yellow(),
220
+ locations.to_formatted_string(&Locale::en).bright_yellow()
221
+ );
222
+ }
223
+
224
+ /// Formats a duration nicely.
225
+ fn format_duration(d: Duration) -> String {
226
+ let total_ms = d.as_millis();
227
+ if total_ms < 1000 {
228
+ format!("{}ms", total_ms)
229
+ } else if total_ms < 60_000 {
230
+ format!("{:.2}s", d.as_secs_f64())
231
+ } else {
232
+ let mins = total_ms / 60_000;
233
+ let secs = (total_ms % 60_000) / 1000;
234
+ format!("{}m {}s", mins, secs)
235
+ }
236
+ }
237
+
238
+ /// Formats a score with colors based on feasibility.
239
+ fn format_score(score: &str) -> String {
240
+ // Parse HardSoftScore format like "-2hard/5soft" or "0hard/10soft"
241
+ if score.contains("hard") {
242
+ let parts: Vec<&str> = score.split('/').collect();
243
+ if parts.len() == 2 {
244
+ let hard = parts[0].trim_end_matches("hard");
245
+ let soft = parts[1].trim_end_matches("soft");
246
+
247
+ let hard_num: f64 = hard.parse().unwrap_or(0.0);
248
+ let soft_num: f64 = soft.parse().unwrap_or(0.0);
249
+
250
+ let hard_str = if hard_num < 0.0 {
251
+ format!("{}hard", hard).bright_red().to_string()
252
+ } else {
253
+ format!("{}hard", hard).bright_green().to_string()
254
+ };
255
+
256
+ let soft_str = if soft_num < 0.0 {
257
+ format!("{}soft", soft).yellow().to_string()
258
+ } else if soft_num > 0.0 {
259
+ format!("{}soft", soft).bright_green().to_string()
260
+ } else {
261
+ format!("{}soft", soft).white().to_string()
262
+ };
263
+
264
+ return format!("{}/{}", hard_str, soft_str);
265
+ }
266
+ }
267
+
268
+ // Simple score
269
+ if let Ok(n) = score.parse::<i32>() {
270
+ if n < 0 {
271
+ return score.bright_red().to_string();
272
+ } else if n > 0 {
273
+ return score.bright_green().to_string();
274
+ }
275
+ }
276
+
277
+ score.white().to_string()
278
+ }
279
+
280
+ /// Returns a timestamp string.
281
+ fn timestamp() -> String {
282
+ std::time::SystemTime::now()
283
+ .duration_since(std::time::UNIX_EPOCH)
284
+ .map(|d| {
285
+ let secs = d.as_secs();
286
+ let millis = d.subsec_millis();
287
+ format!("{}.{:03}", secs, millis)
288
+ })
289
+ .unwrap_or_else(|_| "0.000".to_string())
290
+ }
291
+
292
+ /// Calculates an approximate problem scale.
293
+ fn calculate_problem_scale(entity_count: usize, value_count: usize) -> String {
294
+ if entity_count == 0 || value_count == 0 {
295
+ return "0".to_string();
296
+ }
297
+
298
+ // value_count ^ entity_count
299
+ let log_scale = (entity_count as f64) * (value_count as f64).log10();
300
+ let exponent = log_scale.floor() as i32;
301
+ let mantissa = 10f64.powf(log_scale - exponent as f64);
302
+
303
+ format!("{:.3} × 10^{}", mantissa, exponent)
304
+ }
305
+
306
+ /// A timer for tracking phase/step durations.
307
+ pub struct PhaseTimer {
308
+ start: Instant,
309
+ phase_name: String,
310
+ phase_index: usize,
311
+ steps_accepted: u64,
312
+ moves_evaluated: u64,
313
+ last_score: String,
314
+ }
315
+
316
+ impl PhaseTimer {
317
+ pub fn start(phase_name: impl Into<String>, phase_index: usize) -> Self {
318
+ let name = phase_name.into();
319
+ print_phase_start(&name, phase_index);
320
+ Self {
321
+ start: Instant::now(),
322
+ phase_name: name,
323
+ phase_index,
324
+ steps_accepted: 0,
325
+ moves_evaluated: 0,
326
+ last_score: String::new(),
327
+ }
328
+ }
329
+
330
+ pub fn record_accepted(&mut self, score: &str) {
331
+ self.steps_accepted += 1;
332
+ self.last_score = score.to_string();
333
+ }
334
+
335
+ pub fn record_move(&mut self) {
336
+ self.moves_evaluated += 1;
337
+ }
338
+
339
+ pub fn elapsed(&self) -> Duration {
340
+ self.start.elapsed()
341
+ }
342
+
343
+ pub fn moves_evaluated(&self) -> u64 {
344
+ self.moves_evaluated
345
+ }
346
+
347
+ pub fn finish(self) {
348
+ print_phase_end(
349
+ &self.phase_name,
350
+ self.phase_index,
351
+ self.start.elapsed(),
352
+ self.steps_accepted,
353
+ self.moves_evaluated,
354
+ &self.last_score,
355
+ );
356
+ }
357
+
358
+ pub fn steps_accepted(&self) -> u64 {
359
+ self.steps_accepted
360
+ }
361
+ }
src/constraints.rs ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Score calculator for Vehicle Routing Problem.
2
+ //!
3
+ //! # Constraints
4
+ //!
5
+ //! - **Vehicle capacity** (hard): Total demand must not exceed vehicle capacity
6
+ //! - **Time windows** (hard): Service must complete before max end time
7
+ //! - **Minimize travel time** (soft): Reduce total driving time
8
+ //!
9
+ //! # Design
10
+ //!
11
+ //! Uses a simple score calculator function with full solution access.
12
+ //! No global state or RwLock overhead - direct array indexing into the plan's
13
+ //! travel time matrix and visits.
14
+
15
+ use solverforge::prelude::*;
16
+
17
+ use crate::domain::{Vehicle, VehicleRoutePlan};
18
+
19
+ /// Calculates the score for a vehicle routing solution.
20
+ ///
21
+ /// # Hard constraints
22
+ /// - Vehicle capacity: penalize excess demand
23
+ /// - Time windows: penalize late arrivals
24
+ ///
25
+ /// # Soft constraints
26
+ /// - Minimize total travel time (in minutes)
27
+ ///
28
+ /// # Examples
29
+ ///
30
+ /// ```
31
+ /// use vehicle_routing::constraints::calculate_score;
32
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
33
+ /// use solverforge::prelude::Score; // For is_feasible()
34
+ ///
35
+ /// let depot = Location::new(0, 0.0, 0.0);
36
+ /// let locations = vec![depot.clone()];
37
+ /// let visits = vec![Visit::new(0, "A", depot.clone()).with_demand(5)];
38
+ /// let mut vehicle = Vehicle::new(0, "V1", 10, depot);
39
+ /// vehicle.visits = vec![0];
40
+ ///
41
+ /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
42
+ /// plan.finalize();
43
+ ///
44
+ /// let score = calculate_score(&plan);
45
+ /// assert!(score.is_feasible()); // Demand 5 <= capacity 10
46
+ /// ```
47
+ pub fn calculate_score(plan: &VehicleRoutePlan) -> HardSoftScore {
48
+ let mut hard = 0i64;
49
+ let mut soft = 0i64;
50
+
51
+ for vehicle in &plan.vehicles {
52
+ // =====================================================================
53
+ // HARD: Vehicle Capacity
54
+ // =====================================================================
55
+ let total_demand: i32 = vehicle
56
+ .visits
57
+ .iter()
58
+ .filter_map(|&idx| plan.visits.get(idx))
59
+ .map(|v| v.demand)
60
+ .sum();
61
+
62
+ if total_demand > vehicle.capacity {
63
+ hard -= (total_demand - vehicle.capacity) as i64;
64
+ }
65
+
66
+ // =====================================================================
67
+ // HARD: Time Windows
68
+ // =====================================================================
69
+ let late_minutes = calculate_late_minutes_for_vehicle(plan, vehicle);
70
+ if late_minutes > 0 {
71
+ hard -= late_minutes;
72
+ }
73
+
74
+ // =====================================================================
75
+ // SOFT: Minimize Travel Time
76
+ // =====================================================================
77
+ let driving_seconds = plan.total_driving_time(vehicle);
78
+ soft -= driving_seconds / 60; // Convert to minutes
79
+ }
80
+
81
+ HardSoftScore::of(hard, soft)
82
+ }
83
+
84
+ /// Calculates total late minutes for a vehicle's route.
85
+ ///
86
+ /// A visit is late if service finishes after `max_end_time`.
87
+ fn calculate_late_minutes_for_vehicle(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 {
88
+ if vehicle.visits.is_empty() {
89
+ return 0;
90
+ }
91
+
92
+ let mut total_late = 0i64;
93
+ let mut current_time = vehicle.departure_time;
94
+ let mut current_loc_idx = vehicle.home_location.index;
95
+
96
+ for &visit_idx in &vehicle.visits {
97
+ let Some(visit) = plan.visits.get(visit_idx) else {
98
+ continue;
99
+ };
100
+
101
+ // Travel to this visit
102
+ let travel = plan.travel_time(current_loc_idx, visit.location.index);
103
+ let arrival = current_time + travel;
104
+
105
+ // Service starts at max(arrival, min_start_time)
106
+ let service_start = arrival.max(visit.min_start_time);
107
+ let service_end = service_start + visit.service_duration;
108
+
109
+ // Check if late (service finishes after max_end_time)
110
+ if service_end > visit.max_end_time {
111
+ let late_seconds = service_end - visit.max_end_time;
112
+ // Round up to minutes
113
+ total_late += (late_seconds + 59) / 60;
114
+ }
115
+
116
+ current_time = service_end;
117
+ current_loc_idx = visit.location.index;
118
+ }
119
+
120
+ total_late
121
+ }
122
+
123
+ // ============================================================================
124
+ // Helper functions (for analyze endpoint)
125
+ // ============================================================================
126
+
127
+ /// Calculates total late minutes for a vehicle's route (public API).
128
+ ///
129
+ /// # Examples
130
+ ///
131
+ /// ```
132
+ /// use vehicle_routing::constraints::calculate_late_minutes;
133
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
134
+ ///
135
+ /// let depot = Location::new(0, 0.0, 0.0);
136
+ /// let customer = Location::new(1, 0.0, 1.0); // ~111 km away, ~2.2 hours at 50 km/h
137
+ ///
138
+ /// let locations = vec![depot.clone(), customer.clone()];
139
+ /// let visits = vec![
140
+ /// Visit::new(0, "A", customer)
141
+ /// .with_time_window(0, 8 * 3600 + 30 * 60) // Must finish by 8:30am
142
+ /// .with_service_duration(300), // 5 min service
143
+ /// ];
144
+ /// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
145
+ /// vehicle.departure_time = 8 * 3600; // Depart at 8am
146
+ /// vehicle.visits = vec![0];
147
+ ///
148
+ /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]);
149
+ /// plan.finalize();
150
+ ///
151
+ /// // Vehicle departs 8am, travels ~2.2 hours, arrives ~10:13am
152
+ /// // Service ends ~10:18am, but max_end is 8:30am
153
+ /// // Late by ~108 minutes
154
+ /// let late = calculate_late_minutes(&plan, &vehicle);
155
+ /// assert!(late > 100);
156
+ /// ```
157
+ #[inline]
158
+ pub fn calculate_late_minutes(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i64 {
159
+ calculate_late_minutes_for_vehicle(plan, vehicle)
160
+ }
161
+
162
+ /// Calculates excess demand for a vehicle (0 if under capacity).
163
+ ///
164
+ /// # Examples
165
+ ///
166
+ /// ```
167
+ /// use vehicle_routing::constraints::calculate_excess_capacity;
168
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
169
+ ///
170
+ /// let depot = Location::new(0, 0.0, 0.0);
171
+ /// let locations = vec![depot.clone()];
172
+ /// let visits = vec![
173
+ /// Visit::new(0, "A", depot.clone()).with_demand(60),
174
+ /// Visit::new(1, "B", depot.clone()).with_demand(50),
175
+ /// ];
176
+ /// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
177
+ /// vehicle.visits = vec![0, 1]; // Total demand = 110
178
+ ///
179
+ /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle.clone()]);
180
+ /// plan.finalize();
181
+ ///
182
+ /// // Excess = 110 - 100 = 10
183
+ /// assert_eq!(calculate_excess_capacity(&plan, &vehicle), 10);
184
+ /// ```
185
+ #[inline]
186
+ pub fn calculate_excess_capacity(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> i32 {
187
+ let total_demand: i32 = vehicle
188
+ .visits
189
+ .iter()
190
+ .filter_map(|&idx| plan.visits.get(idx))
191
+ .map(|v| v.demand)
192
+ .sum();
193
+
194
+ (total_demand - vehicle.capacity).max(0)
195
+ }
src/demo_data.rs ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Demo data generators for Vehicle Routing Problem.
2
+ //!
3
+ //! Provides realistic demo datasets for three cities:
4
+ //! - Philadelphia (49 visits, 6 vehicles)
5
+ //! - Hartford (30 visits, 6 vehicles)
6
+ //! - Firenze (48 visits, 6 vehicles)
7
+ //!
8
+ //! Uses real street addresses and weighted customer types:
9
+ //! - Residential (50%): 17:00-20:00, demand 1-2
10
+ //! - Business (30%): 09:00-17:00, demand 3-6
11
+ //! - Restaurant (20%): 06:00-10:00, demand 5-10
12
+
13
+ use rand::rngs::StdRng;
14
+ use rand::{Rng, SeedableRng};
15
+
16
+ use crate::domain::{Location, Vehicle, VehicleRoutePlan, Visit};
17
+
18
+ /// Vehicle names using phonetic alphabet.
19
+ const VEHICLE_NAMES: [&str; 10] = [
20
+ "Alpha", "Bravo", "Charlie", "Delta", "Echo",
21
+ "Foxtrot", "Golf", "Hotel", "India", "Juliet",
22
+ ];
23
+
24
+ /// Customer type with time window and demand characteristics.
25
+ #[derive(Clone, Copy)]
26
+ enum CustomerType {
27
+ /// Evening deliveries (17:00-20:00), small orders
28
+ Residential,
29
+ /// Business hours (09:00-17:00), medium orders
30
+ Business,
31
+ /// Early morning (06:00-10:00), large orders
32
+ Restaurant,
33
+ }
34
+
35
+ impl CustomerType {
36
+ fn time_window(&self) -> (i64, i64) {
37
+ match self {
38
+ CustomerType::Residential => (17 * 3600, 20 * 3600),
39
+ CustomerType::Business => (9 * 3600, 17 * 3600),
40
+ CustomerType::Restaurant => (6 * 3600, 10 * 3600),
41
+ }
42
+ }
43
+
44
+ fn demand_range(&self) -> (i32, i32) {
45
+ match self {
46
+ CustomerType::Residential => (1, 2),
47
+ CustomerType::Business => (3, 6),
48
+ CustomerType::Restaurant => (5, 10),
49
+ }
50
+ }
51
+
52
+ fn service_duration_range(&self) -> (i64, i64) {
53
+ match self {
54
+ CustomerType::Residential => (5 * 60, 10 * 60),
55
+ CustomerType::Business => (15 * 60, 30 * 60),
56
+ CustomerType::Restaurant => (20 * 60, 40 * 60),
57
+ }
58
+ }
59
+
60
+ /// Weighted random selection: 50% residential, 30% business, 20% restaurant.
61
+ fn random(rng: &mut StdRng) -> Self {
62
+ let r: u32 = rng.gen_range(1..=100);
63
+ if r <= 50 {
64
+ CustomerType::Residential
65
+ } else if r <= 80 {
66
+ CustomerType::Business
67
+ } else {
68
+ CustomerType::Restaurant
69
+ }
70
+ }
71
+ }
72
+
73
+ /// Location data with name, coordinates, and optional type.
74
+ struct LocationData {
75
+ name: &'static str,
76
+ lat: f64,
77
+ lng: f64,
78
+ customer_type: Option<CustomerType>,
79
+ }
80
+
81
+ /// Demo dataset configuration.
82
+ struct DemoConfig {
83
+ seed: u64,
84
+ visit_count: usize,
85
+ vehicle_count: usize,
86
+ vehicle_start_time: i64,
87
+ min_capacity: i32,
88
+ max_capacity: i32,
89
+ }
90
+
91
+ // ============================================================================
92
+ // Philadelphia Data
93
+ // ============================================================================
94
+
95
+ const PHILADELPHIA_DEPOTS: &[LocationData] = &[
96
+ LocationData { name: "Central Depot - City Hall", lat: 39.9526, lng: -75.1652, customer_type: None },
97
+ LocationData { name: "South Philly Depot", lat: 39.9256, lng: -75.1697, customer_type: None },
98
+ LocationData { name: "University City Depot", lat: 39.9522, lng: -75.1932, customer_type: None },
99
+ LocationData { name: "North Philly Depot", lat: 39.9907, lng: -75.1556, customer_type: None },
100
+ LocationData { name: "Fishtown Depot", lat: 39.9712, lng: -75.1340, customer_type: None },
101
+ LocationData { name: "West Philly Depot", lat: 39.9601, lng: -75.2175, customer_type: None },
102
+ ];
103
+
104
+ const PHILADELPHIA_VISITS: &[LocationData] = &[
105
+ // Restaurants
106
+ LocationData { name: "Reading Terminal Market", lat: 39.9535, lng: -75.1589, customer_type: Some(CustomerType::Restaurant) },
107
+ LocationData { name: "Parc Restaurant", lat: 39.9493, lng: -75.1727, customer_type: Some(CustomerType::Restaurant) },
108
+ LocationData { name: "Zahav", lat: 39.9430, lng: -75.1474, customer_type: Some(CustomerType::Restaurant) },
109
+ LocationData { name: "Vetri Cucina", lat: 39.9499, lng: -75.1659, customer_type: Some(CustomerType::Restaurant) },
110
+ LocationData { name: "Talula's Garden", lat: 39.9470, lng: -75.1709, customer_type: Some(CustomerType::Restaurant) },
111
+ LocationData { name: "Fork", lat: 39.9493, lng: -75.1539, customer_type: Some(CustomerType::Restaurant) },
112
+ LocationData { name: "Morimoto", lat: 39.9488, lng: -75.1559, customer_type: Some(CustomerType::Restaurant) },
113
+ LocationData { name: "Vernick Food & Drink", lat: 39.9508, lng: -75.1718, customer_type: Some(CustomerType::Restaurant) },
114
+ LocationData { name: "Friday Saturday Sunday", lat: 39.9492, lng: -75.1715, customer_type: Some(CustomerType::Restaurant) },
115
+ LocationData { name: "Royal Izakaya", lat: 39.9410, lng: -75.1509, customer_type: Some(CustomerType::Restaurant) },
116
+ LocationData { name: "Laurel", lat: 39.9392, lng: -75.1538, customer_type: Some(CustomerType::Restaurant) },
117
+ LocationData { name: "Marigold Kitchen", lat: 39.9533, lng: -75.1920, customer_type: Some(CustomerType::Restaurant) },
118
+ // Businesses
119
+ LocationData { name: "Comcast Center", lat: 39.9543, lng: -75.1690, customer_type: Some(CustomerType::Business) },
120
+ LocationData { name: "Liberty Place", lat: 39.9520, lng: -75.1685, customer_type: Some(CustomerType::Business) },
121
+ LocationData { name: "BNY Mellon Center", lat: 39.9505, lng: -75.1660, customer_type: Some(CustomerType::Business) },
122
+ LocationData { name: "One Liberty Place", lat: 39.9520, lng: -75.1685, customer_type: Some(CustomerType::Business) },
123
+ LocationData { name: "Aramark Tower", lat: 39.9550, lng: -75.1705, customer_type: Some(CustomerType::Business) },
124
+ LocationData { name: "PSFS Building", lat: 39.9510, lng: -75.1618, customer_type: Some(CustomerType::Business) },
125
+ LocationData { name: "Three Logan Square", lat: 39.9567, lng: -75.1720, customer_type: Some(CustomerType::Business) },
126
+ LocationData { name: "Two Commerce Square", lat: 39.9551, lng: -75.1675, customer_type: Some(CustomerType::Business) },
127
+ LocationData { name: "Penn Medicine", lat: 39.9495, lng: -75.1935, customer_type: Some(CustomerType::Business) },
128
+ LocationData { name: "Children's Hospital", lat: 39.9482, lng: -75.1950, customer_type: Some(CustomerType::Business) },
129
+ LocationData { name: "Drexel University", lat: 39.9566, lng: -75.1899, customer_type: Some(CustomerType::Business) },
130
+ LocationData { name: "Temple University", lat: 39.9812, lng: -75.1554, customer_type: Some(CustomerType::Business) },
131
+ LocationData { name: "Jefferson Hospital", lat: 39.9487, lng: -75.1577, customer_type: Some(CustomerType::Business) },
132
+ LocationData { name: "Pennsylvania Hospital", lat: 39.9445, lng: -75.1545, customer_type: Some(CustomerType::Business) },
133
+ LocationData { name: "FMC Tower", lat: 39.9499, lng: -75.1780, customer_type: Some(CustomerType::Business) },
134
+ LocationData { name: "Cira Centre", lat: 39.9560, lng: -75.1822, customer_type: Some(CustomerType::Business) },
135
+ // Residential
136
+ LocationData { name: "Rittenhouse Square", lat: 39.9496, lng: -75.1718, customer_type: Some(CustomerType::Residential) },
137
+ LocationData { name: "Washington Square West", lat: 39.9468, lng: -75.1545, customer_type: Some(CustomerType::Residential) },
138
+ LocationData { name: "Society Hill", lat: 39.9425, lng: -75.1478, customer_type: Some(CustomerType::Residential) },
139
+ LocationData { name: "Old City", lat: 39.9510, lng: -75.1450, customer_type: Some(CustomerType::Residential) },
140
+ LocationData { name: "Northern Liberties", lat: 39.9650, lng: -75.1420, customer_type: Some(CustomerType::Residential) },
141
+ LocationData { name: "Fishtown", lat: 39.9712, lng: -75.1340, customer_type: Some(CustomerType::Residential) },
142
+ LocationData { name: "Queen Village", lat: 39.9380, lng: -75.1520, customer_type: Some(CustomerType::Residential) },
143
+ LocationData { name: "Bella Vista", lat: 39.9395, lng: -75.1598, customer_type: Some(CustomerType::Residential) },
144
+ LocationData { name: "Graduate Hospital", lat: 39.9425, lng: -75.1768, customer_type: Some(CustomerType::Residential) },
145
+ LocationData { name: "Fairmount", lat: 39.9680, lng: -75.1750, customer_type: Some(CustomerType::Residential) },
146
+ LocationData { name: "Spring Garden", lat: 39.9620, lng: -75.1620, customer_type: Some(CustomerType::Residential) },
147
+ LocationData { name: "Art Museum Area", lat: 39.9656, lng: -75.1810, customer_type: Some(CustomerType::Residential) },
148
+ LocationData { name: "Brewerytown", lat: 39.9750, lng: -75.1850, customer_type: Some(CustomerType::Residential) },
149
+ LocationData { name: "East Passyunk", lat: 39.9310, lng: -75.1605, customer_type: Some(CustomerType::Residential) },
150
+ LocationData { name: "Point Breeze", lat: 39.9285, lng: -75.1780, customer_type: Some(CustomerType::Residential) },
151
+ LocationData { name: "Pennsport", lat: 39.9320, lng: -75.1450, customer_type: Some(CustomerType::Residential) },
152
+ LocationData { name: "Powelton Village", lat: 39.9610, lng: -75.1950, customer_type: Some(CustomerType::Residential) },
153
+ LocationData { name: "Spruce Hill", lat: 39.9530, lng: -75.2100, customer_type: Some(CustomerType::Residential) },
154
+ LocationData { name: "Cedar Park", lat: 39.9490, lng: -75.2200, customer_type: Some(CustomerType::Residential) },
155
+ LocationData { name: "Kensington", lat: 39.9850, lng: -75.1280, customer_type: Some(CustomerType::Residential) },
156
+ LocationData { name: "Port Richmond", lat: 39.9870, lng: -75.1120, customer_type: Some(CustomerType::Residential) },
157
+ ];
158
+
159
+ // ============================================================================
160
+ // Hartford Data
161
+ // ============================================================================
162
+
163
+ const HARTFORD_DEPOTS: &[LocationData] = &[
164
+ LocationData { name: "Downtown Hartford Depot", lat: 41.7658, lng: -72.6734, customer_type: None },
165
+ LocationData { name: "Asylum Hill Depot", lat: 41.7700, lng: -72.6900, customer_type: None },
166
+ LocationData { name: "South End Depot", lat: 41.7400, lng: -72.6750, customer_type: None },
167
+ LocationData { name: "West End Depot", lat: 41.7680, lng: -72.7100, customer_type: None },
168
+ LocationData { name: "Barry Square Depot", lat: 41.7450, lng: -72.6800, customer_type: None },
169
+ LocationData { name: "Clay Arsenal Depot", lat: 41.7750, lng: -72.6850, customer_type: None },
170
+ ];
171
+
172
+ const HARTFORD_VISITS: &[LocationData] = &[
173
+ // Restaurants
174
+ LocationData { name: "Max Downtown", lat: 41.7670, lng: -72.6730, customer_type: Some(CustomerType::Restaurant) },
175
+ LocationData { name: "Trumbull Kitchen", lat: 41.7650, lng: -72.6750, customer_type: Some(CustomerType::Restaurant) },
176
+ LocationData { name: "Salute", lat: 41.7630, lng: -72.6740, customer_type: Some(CustomerType::Restaurant) },
177
+ LocationData { name: "Peppercorns Grill", lat: 41.7690, lng: -72.6680, customer_type: Some(CustomerType::Restaurant) },
178
+ LocationData { name: "Feng Asian Bistro", lat: 41.7640, lng: -72.6725, customer_type: Some(CustomerType::Restaurant) },
179
+ LocationData { name: "On20", lat: 41.7655, lng: -72.6728, customer_type: Some(CustomerType::Restaurant) },
180
+ LocationData { name: "First and Last Tavern", lat: 41.7620, lng: -72.7050, customer_type: Some(CustomerType::Restaurant) },
181
+ LocationData { name: "Agave Grill", lat: 41.7580, lng: -72.6820, customer_type: Some(CustomerType::Restaurant) },
182
+ LocationData { name: "Bear's Smokehouse", lat: 41.7550, lng: -72.6780, customer_type: Some(CustomerType::Restaurant) },
183
+ LocationData { name: "City Steam Brewery", lat: 41.7630, lng: -72.6750, customer_type: Some(CustomerType::Restaurant) },
184
+ // Businesses
185
+ LocationData { name: "Travelers Tower", lat: 41.7658, lng: -72.6734, customer_type: Some(CustomerType::Business) },
186
+ LocationData { name: "Hartford Steam Boiler", lat: 41.7680, lng: -72.6700, customer_type: Some(CustomerType::Business) },
187
+ LocationData { name: "Aetna Building", lat: 41.7700, lng: -72.6900, customer_type: Some(CustomerType::Business) },
188
+ LocationData { name: "Connecticut Convention Center", lat: 41.7615, lng: -72.6820, customer_type: Some(CustomerType::Business) },
189
+ LocationData { name: "Hartford Hospital", lat: 41.7547, lng: -72.6858, customer_type: Some(CustomerType::Business) },
190
+ LocationData { name: "Connecticut Children's", lat: 41.7560, lng: -72.6850, customer_type: Some(CustomerType::Business) },
191
+ LocationData { name: "Trinity College", lat: 41.7474, lng: -72.6909, customer_type: Some(CustomerType::Business) },
192
+ LocationData { name: "Connecticut Science Center", lat: 41.7650, lng: -72.6695, customer_type: Some(CustomerType::Business) },
193
+ // Residential
194
+ LocationData { name: "West End Hartford", lat: 41.7680, lng: -72.7000, customer_type: Some(CustomerType::Residential) },
195
+ LocationData { name: "Asylum Hill", lat: 41.7720, lng: -72.6850, customer_type: Some(CustomerType::Residential) },
196
+ LocationData { name: "Frog Hollow", lat: 41.7580, lng: -72.6900, customer_type: Some(CustomerType::Residential) },
197
+ LocationData { name: "Barry Square", lat: 41.7450, lng: -72.6800, customer_type: Some(CustomerType::Residential) },
198
+ LocationData { name: "South End", lat: 41.7400, lng: -72.6750, customer_type: Some(CustomerType::Residential) },
199
+ LocationData { name: "Blue Hills", lat: 41.7850, lng: -72.7050, customer_type: Some(CustomerType::Residential) },
200
+ LocationData { name: "Parkville", lat: 41.7650, lng: -72.7100, customer_type: Some(CustomerType::Residential) },
201
+ LocationData { name: "Behind the Rocks", lat: 41.7550, lng: -72.7050, customer_type: Some(CustomerType::Residential) },
202
+ LocationData { name: "Charter Oak", lat: 41.7495, lng: -72.6650, customer_type: Some(CustomerType::Residential) },
203
+ LocationData { name: "Sheldon Charter Oak", lat: 41.7510, lng: -72.6700, customer_type: Some(CustomerType::Residential) },
204
+ LocationData { name: "Clay Arsenal", lat: 41.7750, lng: -72.6850, customer_type: Some(CustomerType::Residential) },
205
+ LocationData { name: "Upper Albany", lat: 41.7780, lng: -72.6950, customer_type: Some(CustomerType::Residential) },
206
+ ];
207
+
208
+ // ============================================================================
209
+ // Firenze Data
210
+ // ============================================================================
211
+
212
+ const FIRENZE_DEPOTS: &[LocationData] = &[
213
+ LocationData { name: "Centro Storico Depot", lat: 43.7696, lng: 11.2558, customer_type: None },
214
+ LocationData { name: "Santa Maria Novella Depot", lat: 43.7745, lng: 11.2487, customer_type: None },
215
+ LocationData { name: "Campo di Marte Depot", lat: 43.7820, lng: 11.2820, customer_type: None },
216
+ LocationData { name: "Rifredi Depot", lat: 43.7950, lng: 11.2410, customer_type: None },
217
+ LocationData { name: "Novoli Depot", lat: 43.7880, lng: 11.2220, customer_type: None },
218
+ LocationData { name: "Gavinana Depot", lat: 43.7520, lng: 11.2680, customer_type: None },
219
+ ];
220
+
221
+ const FIRENZE_VISITS: &[LocationData] = &[
222
+ // Restaurants
223
+ LocationData { name: "Trattoria Mario", lat: 43.7750, lng: 11.2530, customer_type: Some(CustomerType::Restaurant) },
224
+ LocationData { name: "Buca Mario", lat: 43.7698, lng: 11.2505, customer_type: Some(CustomerType::Restaurant) },
225
+ LocationData { name: "Il Latini", lat: 43.7705, lng: 11.2495, customer_type: Some(CustomerType::Restaurant) },
226
+ LocationData { name: "Osteria dell'Enoteca", lat: 43.7680, lng: 11.2545, customer_type: Some(CustomerType::Restaurant) },
227
+ LocationData { name: "Trattoria Sostanza", lat: 43.7735, lng: 11.2470, customer_type: Some(CustomerType::Restaurant) },
228
+ LocationData { name: "All'Antico Vinaio", lat: 43.7690, lng: 11.2570, customer_type: Some(CustomerType::Restaurant) },
229
+ LocationData { name: "Mercato Centrale", lat: 43.7762, lng: 11.2540, customer_type: Some(CustomerType::Restaurant) },
230
+ LocationData { name: "Cibreo", lat: 43.7702, lng: 11.2670, customer_type: Some(CustomerType::Restaurant) },
231
+ LocationData { name: "Ora d'Aria", lat: 43.7710, lng: 11.2610, customer_type: Some(CustomerType::Restaurant) },
232
+ LocationData { name: "Buca Lapi", lat: 43.7720, lng: 11.2535, customer_type: Some(CustomerType::Restaurant) },
233
+ LocationData { name: "Il Palagio", lat: 43.7680, lng: 11.2550, customer_type: Some(CustomerType::Restaurant) },
234
+ LocationData { name: "Enoteca Pinchiorri", lat: 43.7695, lng: 11.2620, customer_type: Some(CustomerType::Restaurant) },
235
+ LocationData { name: "La Giostra", lat: 43.7745, lng: 11.2650, customer_type: Some(CustomerType::Restaurant) },
236
+ LocationData { name: "Fishing Lab", lat: 43.7730, lng: 11.2560, customer_type: Some(CustomerType::Restaurant) },
237
+ LocationData { name: "Trattoria Cammillo", lat: 43.7665, lng: 11.2520, customer_type: Some(CustomerType::Restaurant) },
238
+ // Businesses
239
+ LocationData { name: "Palazzo Vecchio", lat: 43.7693, lng: 11.2563, customer_type: Some(CustomerType::Business) },
240
+ LocationData { name: "Uffizi Gallery", lat: 43.7677, lng: 11.2553, customer_type: Some(CustomerType::Business) },
241
+ LocationData { name: "Gucci Garden", lat: 43.7692, lng: 11.2556, customer_type: Some(CustomerType::Business) },
242
+ LocationData { name: "Ferragamo Museum", lat: 43.7700, lng: 11.2530, customer_type: Some(CustomerType::Business) },
243
+ LocationData { name: "Ospedale Santa Maria", lat: 43.7830, lng: 11.2690, customer_type: Some(CustomerType::Business) },
244
+ LocationData { name: "Universita degli Studi", lat: 43.7765, lng: 11.2555, customer_type: Some(CustomerType::Business) },
245
+ LocationData { name: "Palazzo Strozzi", lat: 43.7706, lng: 11.2515, customer_type: Some(CustomerType::Business) },
246
+ LocationData { name: "Biblioteca Nazionale", lat: 43.7660, lng: 11.2650, customer_type: Some(CustomerType::Business) },
247
+ LocationData { name: "Teatro del Maggio", lat: 43.7780, lng: 11.2470, customer_type: Some(CustomerType::Business) },
248
+ LocationData { name: "Palazzo Pitti", lat: 43.7650, lng: 11.2500, customer_type: Some(CustomerType::Business) },
249
+ LocationData { name: "Accademia Gallery", lat: 43.7768, lng: 11.2590, customer_type: Some(CustomerType::Business) },
250
+ LocationData { name: "Ospedale Meyer", lat: 43.7910, lng: 11.2520, customer_type: Some(CustomerType::Business) },
251
+ LocationData { name: "Polo Universitario", lat: 43.7920, lng: 11.2180, customer_type: Some(CustomerType::Business) },
252
+ // Residential
253
+ LocationData { name: "Santo Spirito", lat: 43.7665, lng: 11.2470, customer_type: Some(CustomerType::Residential) },
254
+ LocationData { name: "San Frediano", lat: 43.7680, lng: 11.2420, customer_type: Some(CustomerType::Residential) },
255
+ LocationData { name: "Santa Croce", lat: 43.7688, lng: 11.2620, customer_type: Some(CustomerType::Residential) },
256
+ LocationData { name: "San Lorenzo", lat: 43.7755, lng: 11.2540, customer_type: Some(CustomerType::Residential) },
257
+ LocationData { name: "San Marco", lat: 43.7780, lng: 11.2585, customer_type: Some(CustomerType::Residential) },
258
+ LocationData { name: "Sant'Ambrogio", lat: 43.7705, lng: 11.2680, customer_type: Some(CustomerType::Residential) },
259
+ LocationData { name: "Campo di Marte", lat: 43.7820, lng: 11.2820, customer_type: Some(CustomerType::Residential) },
260
+ LocationData { name: "Novoli", lat: 43.7880, lng: 11.2220, customer_type: Some(CustomerType::Residential) },
261
+ LocationData { name: "Rifredi", lat: 43.7950, lng: 11.2410, customer_type: Some(CustomerType::Residential) },
262
+ LocationData { name: "Le Cure", lat: 43.7890, lng: 11.2580, customer_type: Some(CustomerType::Residential) },
263
+ LocationData { name: "Careggi", lat: 43.8020, lng: 11.2530, customer_type: Some(CustomerType::Residential) },
264
+ LocationData { name: "Peretola", lat: 43.7960, lng: 11.2050, customer_type: Some(CustomerType::Residential) },
265
+ LocationData { name: "Isolotto", lat: 43.7620, lng: 11.2200, customer_type: Some(CustomerType::Residential) },
266
+ LocationData { name: "Gavinana", lat: 43.7520, lng: 11.2680, customer_type: Some(CustomerType::Residential) },
267
+ LocationData { name: "Galluzzo", lat: 43.7400, lng: 11.2480, customer_type: Some(CustomerType::Residential) },
268
+ LocationData { name: "Porta Romana", lat: 43.7610, lng: 11.2560, customer_type: Some(CustomerType::Residential) },
269
+ LocationData { name: "Bellosguardo", lat: 43.7650, lng: 11.2350, customer_type: Some(CustomerType::Residential) },
270
+ LocationData { name: "Arcetri", lat: 43.7500, lng: 11.2530, customer_type: Some(CustomerType::Residential) },
271
+ LocationData { name: "Fiesole", lat: 43.8055, lng: 11.2935, customer_type: Some(CustomerType::Residential) },
272
+ LocationData { name: "Settignano", lat: 43.7850, lng: 11.3100, customer_type: Some(CustomerType::Residential) },
273
+ ];
274
+
275
+ // ============================================================================
276
+ // Generator Functions
277
+ // ============================================================================
278
+
279
+ fn generate_demo_data(
280
+ name: &str,
281
+ config: &DemoConfig,
282
+ depots: &[LocationData],
283
+ visit_data: &[LocationData],
284
+ ) -> VehicleRoutePlan {
285
+ let mut rng = StdRng::seed_from_u64(config.seed);
286
+
287
+ // Build locations: depots first, then visit locations
288
+ let mut locations = Vec::new();
289
+ let mut location_idx = 0;
290
+
291
+ // Add depot locations
292
+ for depot in depots.iter().take(config.vehicle_count) {
293
+ locations.push(Location::new(location_idx, depot.lat, depot.lng));
294
+ location_idx += 1;
295
+ }
296
+
297
+ // Shuffle visit data for variety
298
+ let mut shuffled_visits: Vec<_> = visit_data.iter().collect();
299
+ for i in (1..shuffled_visits.len()).rev() {
300
+ let j = rng.gen_range(0..=i);
301
+ shuffled_visits.swap(i, j);
302
+ }
303
+
304
+ // Add visit locations
305
+ for visit in shuffled_visits.iter().take(config.visit_count) {
306
+ locations.push(Location::new(location_idx, visit.lat, visit.lng));
307
+ location_idx += 1;
308
+ }
309
+
310
+ // Create vehicles - now needs Location object, not index
311
+ let depot_count = config.vehicle_count.min(depots.len());
312
+ let vehicles: Vec<_> = (0..config.vehicle_count)
313
+ .map(|i| {
314
+ let capacity = rng.gen_range(config.min_capacity..=config.max_capacity);
315
+ let home_loc = locations[i].clone(); // Depot locations are first
316
+ Vehicle::new(
317
+ i,
318
+ VEHICLE_NAMES[i % VEHICLE_NAMES.len()],
319
+ capacity,
320
+ home_loc,
321
+ )
322
+ .with_departure_time(config.vehicle_start_time)
323
+ })
324
+ .collect();
325
+
326
+ // Create visits - now needs Location object, not index
327
+ let visits: Vec<_> = shuffled_visits
328
+ .iter()
329
+ .take(config.visit_count)
330
+ .enumerate()
331
+ .map(|(i, loc_data)| {
332
+ let ctype = loc_data.customer_type.unwrap_or_else(|| CustomerType::random(&mut rng));
333
+ let (min_time, max_time) = ctype.time_window();
334
+ let (min_demand, max_demand) = ctype.demand_range();
335
+ let (min_service, max_service) = ctype.service_duration_range();
336
+
337
+ let demand = rng.gen_range(min_demand..=max_demand);
338
+ let service_duration = rng.gen_range(min_service..=max_service);
339
+
340
+ let visit_loc = locations[depot_count + i].clone(); // Visit locations are after depots
341
+ Visit::new(i, loc_data.name, visit_loc)
342
+ .with_demand(demand)
343
+ .with_time_window(min_time, max_time)
344
+ .with_service_duration(service_duration)
345
+ })
346
+ .collect();
347
+
348
+ let mut plan = VehicleRoutePlan::new(name, locations, visits, vehicles);
349
+ plan.finalize();
350
+ plan
351
+ }
352
+
353
+ /// Generates Philadelphia demo data (49 visits, 10 vehicles).
354
+ ///
355
+ /// # Examples
356
+ ///
357
+ /// ```
358
+ /// use vehicle_routing::demo_data::generate_philadelphia;
359
+ ///
360
+ /// let plan = generate_philadelphia();
361
+ /// assert_eq!(plan.name, "Philadelphia");
362
+ /// assert_eq!(plan.visits.len(), 49);
363
+ /// assert_eq!(plan.vehicles.len(), 10);
364
+ /// ```
365
+ pub fn generate_philadelphia() -> VehicleRoutePlan {
366
+ let config = DemoConfig {
367
+ seed: 0,
368
+ visit_count: PHILADELPHIA_VISITS.len(),
369
+ vehicle_count: 10,
370
+ vehicle_start_time: 6 * 3600, // 6am
371
+ min_capacity: 15,
372
+ max_capacity: 30,
373
+ };
374
+ generate_demo_data("Philadelphia", &config, PHILADELPHIA_DEPOTS, PHILADELPHIA_VISITS)
375
+ }
376
+
377
+ /// Generates Hartford demo data (30 visits, 10 vehicles).
378
+ ///
379
+ /// # Examples
380
+ ///
381
+ /// ```
382
+ /// use vehicle_routing::demo_data::generate_hartford;
383
+ ///
384
+ /// let plan = generate_hartford();
385
+ /// assert_eq!(plan.name, "Hartford");
386
+ /// assert_eq!(plan.visits.len(), 30);
387
+ /// assert_eq!(plan.vehicles.len(), 10);
388
+ /// ```
389
+ pub fn generate_hartford() -> VehicleRoutePlan {
390
+ let config = DemoConfig {
391
+ seed: 1,
392
+ visit_count: HARTFORD_VISITS.len(),
393
+ vehicle_count: 10,
394
+ vehicle_start_time: 6 * 3600,
395
+ min_capacity: 20,
396
+ max_capacity: 30,
397
+ };
398
+ generate_demo_data("Hartford", &config, HARTFORD_DEPOTS, HARTFORD_VISITS)
399
+ }
400
+
401
+ /// Generates Firenze demo data (48 visits, 10 vehicles).
402
+ ///
403
+ /// # Examples
404
+ ///
405
+ /// ```
406
+ /// use vehicle_routing::demo_data::generate_firenze;
407
+ ///
408
+ /// let plan = generate_firenze();
409
+ /// assert_eq!(plan.name, "Firenze");
410
+ /// assert_eq!(plan.visits.len(), 48);
411
+ /// assert_eq!(plan.vehicles.len(), 10);
412
+ /// ```
413
+ pub fn generate_firenze() -> VehicleRoutePlan {
414
+ let config = DemoConfig {
415
+ seed: 2,
416
+ visit_count: FIRENZE_VISITS.len(),
417
+ vehicle_count: 10,
418
+ vehicle_start_time: 6 * 3600,
419
+ min_capacity: 20,
420
+ max_capacity: 40,
421
+ };
422
+ generate_demo_data("Firenze", &config, FIRENZE_DEPOTS, FIRENZE_VISITS)
423
+ }
424
+
425
+ /// Returns all available demo dataset names.
426
+ pub fn available_datasets() -> &'static [&'static str] {
427
+ &["PHILADELPHIA", "HARTFORD", "FIRENZE"]
428
+ }
429
+
430
+ /// Generates demo data by name.
431
+ ///
432
+ /// # Examples
433
+ ///
434
+ /// ```
435
+ /// use vehicle_routing::demo_data::generate_by_name;
436
+ ///
437
+ /// let plan = generate_by_name("PHILADELPHIA").unwrap();
438
+ /// assert_eq!(plan.name, "Philadelphia");
439
+ ///
440
+ /// assert!(generate_by_name("UNKNOWN").is_none());
441
+ /// ```
442
+ pub fn generate_by_name(name: &str) -> Option<VehicleRoutePlan> {
443
+ match name.to_uppercase().as_str() {
444
+ "PHILADELPHIA" => Some(generate_philadelphia()),
445
+ "HARTFORD" => Some(generate_hartford()),
446
+ "FIRENZE" => Some(generate_firenze()),
447
+ _ => None,
448
+ }
449
+ }
src/domain.rs ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Domain model for Vehicle Routing Problem.
2
+ //!
3
+ //! # Overview
4
+ //!
5
+ //! Models a vehicle routing problem with:
6
+ //! - Geographic [`Location`]s with haversine distance calculation
7
+ //! - Customer [`Visit`]s with time windows, demand, and service duration
8
+ //! - [`Vehicle`]s with capacity constraints and routes
9
+ //! - [`VehicleRoutePlan`] as the complete planning solution
10
+ //!
11
+ //! # Design
12
+ //!
13
+ //! All scoring uses direct access to the plan's travel time matrix.
14
+ //! No global state or RwLock overhead.
15
+
16
+ use serde::{Deserialize, Serialize};
17
+ use solverforge::prelude::*;
18
+ use std::collections::HashMap;
19
+
20
+ /// Average driving speed in km/h for travel time estimation.
21
+ pub const AVERAGE_SPEED_KMPH: f64 = 50.0;
22
+
23
+ /// Earth radius in meters for haversine calculation.
24
+ const EARTH_RADIUS_M: f64 = 6_371_000.0;
25
+
26
+ /// A geographic location with latitude and longitude.
27
+ ///
28
+ /// Supports haversine distance calculation for travel time estimation.
29
+ ///
30
+ /// # Examples
31
+ ///
32
+ /// ```
33
+ /// use vehicle_routing::domain::Location;
34
+ ///
35
+ /// let philadelphia = Location::new(0, 39.9526, -75.1652);
36
+ /// let new_york = Location::new(1, 40.7128, -74.0060);
37
+ ///
38
+ /// // Distance is approximately 130 km
39
+ /// let distance = philadelphia.distance_meters(&new_york);
40
+ /// assert!(distance > 120_000.0 && distance < 140_000.0);
41
+ ///
42
+ /// // Travel time at 50 km/h is approximately 2.6 hours
43
+ /// let travel_secs = philadelphia.travel_time_seconds(&new_york);
44
+ /// assert!(travel_secs > 8000 && travel_secs < 10000);
45
+ /// ```
46
+ #[derive(Clone, Debug, Serialize, Deserialize)]
47
+ pub struct Location {
48
+ /// Index in `VehicleRoutePlan.locations`.
49
+ pub index: usize,
50
+ /// Latitude in degrees (-90 to 90).
51
+ pub latitude: f64,
52
+ /// Longitude in degrees (-180 to 180).
53
+ pub longitude: f64,
54
+ }
55
+
56
+ impl PartialEq for Location {
57
+ fn eq(&self, other: &Self) -> bool {
58
+ self.index == other.index
59
+ }
60
+ }
61
+
62
+ impl Eq for Location {}
63
+
64
+ impl std::hash::Hash for Location {
65
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
66
+ self.index.hash(state);
67
+ }
68
+ }
69
+
70
+ impl Location {
71
+ /// Creates a new location.
72
+ pub fn new(index: usize, latitude: f64, longitude: f64) -> Self {
73
+ Self {
74
+ index,
75
+ latitude,
76
+ longitude,
77
+ }
78
+ }
79
+
80
+ /// Calculates the great-circle distance in meters using the haversine formula.
81
+ ///
82
+ /// # Examples
83
+ ///
84
+ /// ```
85
+ /// use vehicle_routing::domain::Location;
86
+ ///
87
+ /// let a = Location::new(0, 0.0, 0.0);
88
+ /// let b = Location::new(1, 0.0, 1.0);
89
+ ///
90
+ /// // 1 degree of longitude at equator is about 111 km
91
+ /// let dist = a.distance_meters(&b);
92
+ /// assert!(dist > 110_000.0 && dist < 112_000.0);
93
+ /// ```
94
+ pub fn distance_meters(&self, other: &Location) -> f64 {
95
+ if self.latitude == other.latitude && self.longitude == other.longitude {
96
+ return 0.0;
97
+ }
98
+
99
+ let lat1 = self.latitude.to_radians();
100
+ let lat2 = other.latitude.to_radians();
101
+ let lon1 = self.longitude.to_radians();
102
+ let lon2 = other.longitude.to_radians();
103
+
104
+ // Haversine formula
105
+ let dlat = lat2 - lat1;
106
+ let dlon = lon2 - lon1;
107
+ let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
108
+ let c = 2.0 * a.sqrt().asin();
109
+
110
+ EARTH_RADIUS_M * c
111
+ }
112
+
113
+ /// Calculates travel time in seconds assuming average driving speed.
114
+ ///
115
+ /// Uses [`AVERAGE_SPEED_KMPH`] (50 km/h) for conversion.
116
+ pub fn travel_time_seconds(&self, other: &Location) -> i64 {
117
+ let meters = self.distance_meters(other);
118
+ // seconds = meters / (km/h * 1000 / 3600) = meters * 3.6 / km/h
119
+ (meters * 3.6 / AVERAGE_SPEED_KMPH).round() as i64
120
+ }
121
+
122
+ }
123
+
124
+ /// A customer visit with time window and demand constraints.
125
+ ///
126
+ /// # Time Window
127
+ ///
128
+ /// - `min_start_time`: Earliest time service can begin (vehicle may wait)
129
+ /// - `max_end_time`: Latest time service must finish (hard constraint)
130
+ /// - `service_duration`: Time required to complete the visit
131
+ ///
132
+ /// All times are in seconds from midnight.
133
+ ///
134
+ /// # Examples
135
+ ///
136
+ /// ```
137
+ /// use vehicle_routing::domain::{Visit, Location};
138
+ ///
139
+ /// let location = Location::new(0, 39.95, -75.17);
140
+ ///
141
+ /// // A restaurant delivery: 6am-10am window, 5-minute service
142
+ /// let visit = Visit::new(0, "Restaurant A", location)
143
+ /// .with_demand(8)
144
+ /// .with_time_window(6 * 3600, 10 * 3600)
145
+ /// .with_service_duration(300);
146
+ ///
147
+ /// assert_eq!(visit.demand, 8);
148
+ /// assert_eq!(visit.min_start_time, 21600); // 6 * 3600
149
+ /// ```
150
+ #[problem_fact]
151
+ #[derive(Serialize, Deserialize)]
152
+ pub struct Visit {
153
+ /// Index in `VehicleRoutePlan.visits`.
154
+ pub index: usize,
155
+ /// Customer name.
156
+ pub name: String,
157
+ /// The geographic location of this visit.
158
+ pub location: Location,
159
+ /// Quantity demanded (must fit in vehicle capacity).
160
+ pub demand: i32,
161
+ /// Earliest service start time (seconds from midnight).
162
+ #[serde(rename = "minStartTime")]
163
+ pub min_start_time: i64,
164
+ /// Latest service end time (seconds from midnight).
165
+ #[serde(rename = "maxEndTime")]
166
+ pub max_end_time: i64,
167
+ /// Service duration in seconds.
168
+ #[serde(rename = "serviceDuration")]
169
+ pub service_duration: i64,
170
+ }
171
+
172
+ impl Visit {
173
+ /// Creates a new visit with default time window (all day).
174
+ pub fn new(index: usize, name: impl Into<String>, location: Location) -> Self {
175
+ Self {
176
+ index,
177
+ name: name.into(),
178
+ location,
179
+ demand: 1,
180
+ min_start_time: 0,
181
+ max_end_time: 24 * 3600,
182
+ service_duration: 0,
183
+ }
184
+ }
185
+
186
+ /// Sets the demand.
187
+ pub fn with_demand(mut self, demand: i32) -> Self {
188
+ self.demand = demand;
189
+ self
190
+ }
191
+
192
+ /// Sets the time window (min_start_time, max_end_time) in seconds from midnight.
193
+ pub fn with_time_window(mut self, min_start: i64, max_end: i64) -> Self {
194
+ self.min_start_time = min_start;
195
+ self.max_end_time = max_end;
196
+ self
197
+ }
198
+
199
+ /// Sets the service duration in seconds.
200
+ pub fn with_service_duration(mut self, duration: i64) -> Self {
201
+ self.service_duration = duration;
202
+ self
203
+ }
204
+
205
+ }
206
+
207
+ /// A delivery vehicle with capacity and assigned route.
208
+ ///
209
+ /// The route is stored as a list of visit indices in order.
210
+ ///
211
+ /// # Examples
212
+ ///
213
+ /// ```
214
+ /// use vehicle_routing::domain::{Vehicle, Location};
215
+ ///
216
+ /// let depot = Location::new(0, 39.95, -75.17);
217
+ /// let vehicle = Vehicle::new(0, "Truck 1", 100, depot)
218
+ /// .with_departure_time(8 * 3600); // Departs at 8am
219
+ ///
220
+ /// assert_eq!(vehicle.capacity, 100);
221
+ /// assert!(vehicle.visits.is_empty());
222
+ /// ```
223
+ #[planning_entity]
224
+ #[derive(Serialize, Deserialize)]
225
+ pub struct Vehicle {
226
+ /// Unique vehicle ID.
227
+ #[planning_id]
228
+ pub id: usize,
229
+ /// Vehicle name for display.
230
+ pub name: String,
231
+ /// Maximum capacity (sum of visit demands must not exceed).
232
+ pub capacity: i32,
233
+ /// Home depot location.
234
+ #[serde(rename = "homeLocation")]
235
+ pub home_location: Location,
236
+ /// Departure time from depot (seconds from midnight).
237
+ #[serde(rename = "departureTime")]
238
+ pub departure_time: i64,
239
+ /// Ordered list of visit indices (the route).
240
+ #[serde(default)]
241
+ pub visits: Vec<usize>,
242
+ }
243
+
244
+ impl Vehicle {
245
+ /// Creates a new vehicle with empty route.
246
+ pub fn new(id: usize, name: impl Into<String>, capacity: i32, home_location: Location) -> Self {
247
+ Self {
248
+ id,
249
+ name: name.into(),
250
+ capacity,
251
+ home_location,
252
+ departure_time: 8 * 3600, // Default 8am
253
+ visits: Vec::new(),
254
+ }
255
+ }
256
+
257
+ /// Sets the departure time in seconds from midnight.
258
+ pub fn with_departure_time(mut self, time: i64) -> Self {
259
+ self.departure_time = time;
260
+ self
261
+ }
262
+ }
263
+
264
+ /// Arrival and departure times for a visit in a route.
265
+ #[derive(Debug, Clone, Copy)]
266
+ pub struct VisitTiming {
267
+ /// Visit index.
268
+ pub visit_idx: usize,
269
+ /// Arrival time at the visit (seconds from midnight).
270
+ pub arrival: i64,
271
+ /// Departure time from the visit (seconds from midnight).
272
+ pub departure: i64,
273
+ }
274
+
275
+ /// The complete vehicle routing solution.
276
+ ///
277
+ /// Contains all problem facts (locations, visits) and planning entities (vehicles).
278
+ /// Call `finalize()` after construction to populate the travel time matrix.
279
+ ///
280
+ /// # Examples
281
+ ///
282
+ /// ```
283
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
284
+ ///
285
+ /// let depot = Location::new(0, 39.95, -75.17); // Philadelphia
286
+ /// let customer_loc = Location::new(1, 40.00, -75.10);
287
+ ///
288
+ /// let locations = vec![depot.clone(), customer_loc.clone()];
289
+ /// let visits = vec![
290
+ /// Visit::new(0, "Customer 1", customer_loc).with_demand(5),
291
+ /// ];
292
+ /// let vehicles = vec![
293
+ /// Vehicle::new(0, "Truck 1", 100, depot),
294
+ /// ];
295
+ ///
296
+ /// let mut plan = VehicleRoutePlan::new("Philadelphia", locations, visits, vehicles);
297
+ /// plan.finalize();
298
+ ///
299
+ /// // Travel time matrix is now populated
300
+ /// assert!(plan.travel_time(0, 1) > 0);
301
+ /// ```
302
+ #[planning_solution]
303
+ #[derive(Serialize, Deserialize)]
304
+ pub struct VehicleRoutePlan {
305
+ /// Problem name.
306
+ pub name: String,
307
+ /// South-west corner of bounding box (for map display).
308
+ #[serde(rename = "southWestCorner")]
309
+ pub south_west_corner: [f64; 2],
310
+ /// North-east corner of bounding box (for map display).
311
+ #[serde(rename = "northEastCorner")]
312
+ pub north_east_corner: [f64; 2],
313
+ /// All locations (depot and customer locations).
314
+ #[problem_fact_collection]
315
+ pub locations: Vec<Location>,
316
+ /// All customer visits.
317
+ #[problem_fact_collection]
318
+ pub visits: Vec<Visit>,
319
+ /// All vehicles.
320
+ #[planning_entity_collection]
321
+ pub vehicles: Vec<Vehicle>,
322
+ /// Current score.
323
+ #[planning_score]
324
+ pub score: Option<HardSoftScore>,
325
+ /// Solver status for REST API.
326
+ #[serde(rename = "solverStatus", skip_serializing_if = "Option::is_none")]
327
+ pub solver_status: Option<String>,
328
+ /// Precomputed travel times: `travel_time_matrix[from][to]` in seconds.
329
+ #[serde(skip)]
330
+ pub travel_time_matrix: Vec<Vec<i64>>,
331
+ /// Route geometries: `(from_loc, to_loc)` -> list of (lat, lng) waypoints.
332
+ #[serde(skip)]
333
+ pub route_geometries: HashMap<(usize, usize), Vec<(f64, f64)>>,
334
+ }
335
+
336
+ impl VehicleRoutePlan {
337
+ /// Creates a new vehicle route plan.
338
+ pub fn new(
339
+ name: impl Into<String>,
340
+ locations: Vec<Location>,
341
+ visits: Vec<Visit>,
342
+ vehicles: Vec<Vehicle>,
343
+ ) -> Self {
344
+ // Compute bounding box from locations
345
+ let (sw, ne) = Self::compute_bounds(&locations);
346
+
347
+ Self {
348
+ name: name.into(),
349
+ south_west_corner: sw,
350
+ north_east_corner: ne,
351
+ locations,
352
+ visits,
353
+ vehicles,
354
+ score: None,
355
+ solver_status: None,
356
+ travel_time_matrix: Vec::new(),
357
+ route_geometries: HashMap::new(),
358
+ }
359
+ }
360
+
361
+ /// Computes bounding box from locations.
362
+ fn compute_bounds(locations: &[Location]) -> ([f64; 2], [f64; 2]) {
363
+ if locations.is_empty() {
364
+ return ([0.0, 0.0], [0.0, 0.0]);
365
+ }
366
+
367
+ let mut min_lat = f64::MAX;
368
+ let mut max_lat = f64::MIN;
369
+ let mut min_lon = f64::MAX;
370
+ let mut max_lon = f64::MIN;
371
+
372
+ for loc in locations {
373
+ min_lat = min_lat.min(loc.latitude);
374
+ max_lat = max_lat.max(loc.latitude);
375
+ min_lon = min_lon.min(loc.longitude);
376
+ max_lon = max_lon.max(loc.longitude);
377
+ }
378
+
379
+ // No padding here - init_routing() adds expansion
380
+ ([min_lat, min_lon], [max_lat, max_lon])
381
+ }
382
+
383
+ /// Populates travel time matrix using haversine distances.
384
+ ///
385
+ /// Must be called after construction and before solving.
386
+ /// For real road routing, use `init_routing()` instead.
387
+ pub fn finalize(&mut self) {
388
+ let n = self.locations.len();
389
+ self.travel_time_matrix = vec![vec![0; n]; n];
390
+
391
+ for i in 0..n {
392
+ for j in 0..n {
393
+ if i != j {
394
+ self.travel_time_matrix[i][j] =
395
+ self.locations[i].travel_time_seconds(&self.locations[j]);
396
+ }
397
+ }
398
+ }
399
+ }
400
+
401
+ /// Initializes with real road routing from OSM data.
402
+ ///
403
+ /// Downloads road network via Overpass API (cached), builds graph,
404
+ /// and computes travel times using Dijkstra shortest paths.
405
+ /// Also stores route geometries for visualization.
406
+ pub async fn init_routing(&mut self) -> Result<(), crate::routing::RoutingError> {
407
+ use crate::routing::{BoundingBox, RoadNetwork};
408
+
409
+ // Build bounding box from plan bounds (with expansion)
410
+ let bbox = BoundingBox::new(
411
+ self.south_west_corner[0],
412
+ self.south_west_corner[1],
413
+ self.north_east_corner[0],
414
+ self.north_east_corner[1],
415
+ )
416
+ .expand(0.05); // 5% expansion to catch nearby roads
417
+
418
+ // Load or fetch road network
419
+ let network = RoadNetwork::load_or_fetch(&bbox).await?;
420
+
421
+ // Extract coordinates
422
+ let coords: Vec<(f64, f64)> = self
423
+ .locations
424
+ .iter()
425
+ .map(|l| (l.latitude, l.longitude))
426
+ .collect();
427
+
428
+ // Compute travel time matrix
429
+ self.travel_time_matrix = network.compute_matrix(&coords);
430
+
431
+ // Compute route geometries for visualization
432
+ self.route_geometries = network.compute_all_geometries(&coords);
433
+
434
+ Ok(())
435
+ }
436
+
437
+ /// Returns the bounding box for this plan.
438
+ pub fn bounding_box(&self) -> crate::routing::BoundingBox {
439
+ crate::routing::BoundingBox::new(
440
+ self.south_west_corner[0],
441
+ self.south_west_corner[1],
442
+ self.north_east_corner[0],
443
+ self.north_east_corner[1],
444
+ )
445
+ }
446
+
447
+ /// Gets travel time between two locations in seconds.
448
+ ///
449
+ /// Returns 0 if indices are out of bounds or matrix not initialized.
450
+ #[inline]
451
+ pub fn travel_time(&self, from_idx: usize, to_idx: usize) -> i64 {
452
+ self.travel_time_matrix
453
+ .get(from_idx)
454
+ .and_then(|row| row.get(to_idx))
455
+ .copied()
456
+ .unwrap_or(0)
457
+ }
458
+
459
+ /// Gets route geometry between two locations.
460
+ ///
461
+ /// Returns the waypoints if real road routing was initialized,
462
+ /// or `None` if using haversine fallback.
463
+ #[inline]
464
+ pub fn route_geometry(&self, from_idx: usize, to_idx: usize) -> Option<&[(f64, f64)]> {
465
+ self.route_geometries.get(&(from_idx, to_idx)).map(|v| v.as_slice())
466
+ }
467
+
468
+ /// Gets a location by index.
469
+ #[inline]
470
+ pub fn get_location(&self, idx: usize) -> Option<&Location> {
471
+ self.locations.get(idx)
472
+ }
473
+
474
+ /// Gets a visit by index.
475
+ #[inline]
476
+ pub fn get_visit(&self, idx: usize) -> Option<&Visit> {
477
+ self.visits.get(idx)
478
+ }
479
+
480
+ /// Calculates arrival and departure times for each visit in a vehicle's route.
481
+ ///
482
+ /// Returns a vector of [`VisitTiming`] in route order.
483
+ ///
484
+ /// # Examples
485
+ ///
486
+ /// ```
487
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
488
+ ///
489
+ /// let depot = Location::new(0, 0.0, 0.0);
490
+ /// let customer_loc = Location::new(1, 0.0, 0.01); // ~1.1 km away
491
+ ///
492
+ /// let locations = vec![depot.clone(), customer_loc.clone()];
493
+ /// let visits = vec![
494
+ /// Visit::new(0, "A", customer_loc)
495
+ /// .with_service_duration(300)
496
+ /// .with_time_window(0, 86400),
497
+ /// ];
498
+ /// let mut vehicle = Vehicle::new(0, "V1", 100, depot);
499
+ /// vehicle.departure_time = 8 * 3600; // 8am
500
+ /// vehicle.visits = vec![0];
501
+ ///
502
+ /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
503
+ /// plan.finalize();
504
+ ///
505
+ /// let timings = plan.calculate_route_times(&plan.vehicles[0]);
506
+ /// assert_eq!(timings.len(), 1);
507
+ /// assert!(timings[0].arrival > 8 * 3600); // Arrives after departure
508
+ /// assert_eq!(timings[0].departure, timings[0].arrival + 300); // Service takes 5 min
509
+ /// ```
510
+ pub fn calculate_route_times(&self, vehicle: &Vehicle) -> Vec<VisitTiming> {
511
+ let mut timings = Vec::with_capacity(vehicle.visits.len());
512
+ let mut current_time = vehicle.departure_time;
513
+ let mut current_loc = vehicle.home_location.index;
514
+
515
+ for &visit_idx in &vehicle.visits {
516
+ let Some(visit) = self.visits.get(visit_idx) else {
517
+ continue;
518
+ };
519
+
520
+ // Travel to this visit
521
+ let travel = self.travel_time(current_loc, visit.location.index);
522
+ let arrival = current_time + travel;
523
+
524
+ // Service starts at max(arrival, min_start_time)
525
+ let service_start = arrival.max(visit.min_start_time);
526
+ let departure = service_start + visit.service_duration;
527
+
528
+ timings.push(VisitTiming {
529
+ visit_idx,
530
+ arrival,
531
+ departure,
532
+ });
533
+
534
+ current_time = departure;
535
+ current_loc = visit.location.index;
536
+ }
537
+
538
+ timings
539
+ }
540
+
541
+ /// Calculates total driving time for a vehicle's route in seconds.
542
+ ///
543
+ /// Includes travel from depot, between visits, and back to depot.
544
+ pub fn total_driving_time(&self, vehicle: &Vehicle) -> i64 {
545
+ if vehicle.visits.is_empty() {
546
+ return 0;
547
+ }
548
+
549
+ let mut total = 0i64;
550
+ let mut current_loc = vehicle.home_location.index;
551
+
552
+ for &visit_idx in &vehicle.visits {
553
+ if let Some(visit) = self.visits.get(visit_idx) {
554
+ total += self.travel_time(current_loc, visit.location.index);
555
+ current_loc = visit.location.index;
556
+ }
557
+ }
558
+
559
+ // Return to depot
560
+ total += self.travel_time(current_loc, vehicle.home_location.index);
561
+ total
562
+ }
563
+
564
+ /// Calculates total driving time across all vehicles.
565
+ pub fn total_driving_time_all(&self) -> i64 {
566
+ self.vehicles.iter().map(|v| self.total_driving_time(v)).sum()
567
+ }
568
+ }
src/geometry.rs ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Geometry utilities for route visualization.
2
+ //!
3
+ //! Implements Google Polyline encoding for efficient route transmission.
4
+ //! See: <https://developers.google.com/maps/documentation/utilities/polylinealgorithm>
5
+
6
+ use crate::domain::{Vehicle, VehicleRoutePlan};
7
+ use utoipa::ToSchema;
8
+
9
+ /// Encodes a sequence of coordinates using Google Polyline Algorithm.
10
+ ///
11
+ /// The algorithm encodes latitude/longitude pairs as an ASCII string for
12
+ /// efficient transmission. Each coordinate is encoded as the difference
13
+ /// from the previous point, with 5 decimal places of precision.
14
+ ///
15
+ /// # Examples
16
+ ///
17
+ /// ```
18
+ /// use vehicle_routing::geometry::encode_polyline;
19
+ ///
20
+ /// // Single point encodes to non-empty string
21
+ /// let encoded = encode_polyline(&[(38.5, -120.2)]);
22
+ /// assert!(!encoded.is_empty());
23
+ ///
24
+ /// // Empty input gives empty output
25
+ /// let empty = encode_polyline(&[]);
26
+ /// assert!(empty.is_empty());
27
+ ///
28
+ /// // Two points create a line
29
+ /// let line = encode_polyline(&[(38.5, -120.2), (40.7, -120.95)]);
30
+ /// assert!(!line.is_empty());
31
+ /// ```
32
+ pub fn encode_polyline(coords: &[(f64, f64)]) -> String {
33
+ if coords.is_empty() {
34
+ return String::new();
35
+ }
36
+
37
+ let mut result = String::new();
38
+ let mut prev_lat = 0i64;
39
+ let mut prev_lng = 0i64;
40
+
41
+ for &(lat, lng) in coords {
42
+ // Convert to fixed-point with 5 decimal places
43
+ let lat_e5 = (lat * 1e5).round() as i64;
44
+ let lng_e5 = (lng * 1e5).round() as i64;
45
+
46
+ // Encode deltas
47
+ encode_value(lat_e5 - prev_lat, &mut result);
48
+ encode_value(lng_e5 - prev_lng, &mut result);
49
+
50
+ prev_lat = lat_e5;
51
+ prev_lng = lng_e5;
52
+ }
53
+
54
+ result
55
+ }
56
+
57
+ /// Encodes a single signed value using the polyline algorithm.
58
+ fn encode_value(value: i64, output: &mut String) {
59
+ // Left-shift and invert if negative
60
+ let mut encoded = if value < 0 {
61
+ !((value) << 1)
62
+ } else {
63
+ (value) << 1
64
+ };
65
+
66
+ // Break into 5-bit chunks, OR with 0x20 if more chunks follow
67
+ while encoded >= 0x20 {
68
+ output.push(char::from_u32(((encoded & 0x1f) | 0x20) as u32 + 63).unwrap());
69
+ encoded >>= 5;
70
+ }
71
+ output.push(char::from_u32(encoded as u32 + 63).unwrap());
72
+ }
73
+
74
+ /// Decodes a Google Polyline string back to coordinates.
75
+ ///
76
+ /// # Examples
77
+ ///
78
+ /// ```
79
+ /// use vehicle_routing::geometry::{encode_polyline, decode_polyline};
80
+ ///
81
+ /// let original = vec![(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)];
82
+ /// let encoded = encode_polyline(&original);
83
+ /// let decoded = decode_polyline(&encoded);
84
+ ///
85
+ /// // Check round-trip (within 5 decimal places precision)
86
+ /// assert_eq!(decoded.len(), original.len());
87
+ /// for (orig, dec) in original.iter().zip(decoded.iter()) {
88
+ /// assert!((orig.0 - dec.0).abs() < 0.00001);
89
+ /// assert!((orig.1 - dec.1).abs() < 0.00001);
90
+ /// }
91
+ /// ```
92
+ pub fn decode_polyline(encoded: &str) -> Vec<(f64, f64)> {
93
+ let mut coords = Vec::new();
94
+ let mut lat = 0i64;
95
+ let mut lng = 0i64;
96
+ let bytes = encoded.as_bytes();
97
+ let mut i = 0;
98
+
99
+ while i < bytes.len() {
100
+ // Decode latitude delta
101
+ let (lat_delta, consumed) = decode_value(&bytes[i..]);
102
+ i += consumed;
103
+ lat += lat_delta;
104
+
105
+ if i >= bytes.len() {
106
+ break;
107
+ }
108
+
109
+ // Decode longitude delta
110
+ let (lng_delta, consumed) = decode_value(&bytes[i..]);
111
+ i += consumed;
112
+ lng += lng_delta;
113
+
114
+ coords.push((lat as f64 / 1e5, lng as f64 / 1e5));
115
+ }
116
+
117
+ coords
118
+ }
119
+
120
+ /// Decodes a single value, returning (value, bytes_consumed).
121
+ fn decode_value(bytes: &[u8]) -> (i64, usize) {
122
+ let mut result = 0i64;
123
+ let mut shift = 0;
124
+ let mut consumed = 0;
125
+
126
+ for &b in bytes {
127
+ consumed += 1;
128
+ let chunk = (b as i64) - 63;
129
+ result |= (chunk & 0x1f) << shift;
130
+ shift += 5;
131
+
132
+ if chunk < 0x20 {
133
+ break;
134
+ }
135
+ }
136
+
137
+ // Invert if negative (check LSB)
138
+ if result & 1 != 0 {
139
+ result = !(result >> 1);
140
+ } else {
141
+ result >>= 1;
142
+ }
143
+
144
+ (result, consumed)
145
+ }
146
+
147
+ /// Encoded route segment for a vehicle's route.
148
+ #[derive(Debug, Clone, serde::Serialize, ToSchema)]
149
+ pub struct EncodedSegment {
150
+ /// Vehicle index.
151
+ pub vehicle_idx: usize,
152
+ /// Vehicle name.
153
+ pub vehicle_name: String,
154
+ /// Encoded polyline string (Google format).
155
+ pub polyline: String,
156
+ /// Number of points in the route.
157
+ pub point_count: usize,
158
+ }
159
+
160
+ /// Generates encoded polylines for all vehicle routes.
161
+ ///
162
+ /// Returns segments for each vehicle with non-empty routes.
163
+ ///
164
+ /// # Examples
165
+ ///
166
+ /// ```
167
+ /// use vehicle_routing::domain::{Location, Visit, Vehicle, VehicleRoutePlan};
168
+ /// use vehicle_routing::geometry::encode_routes;
169
+ ///
170
+ /// let depot = Location::new(0, 39.95, -75.16);
171
+ /// let loc_a = Location::new(1, 39.96, -75.17);
172
+ /// let loc_b = Location::new(2, 39.94, -75.15);
173
+ ///
174
+ /// let locations = vec![depot.clone(), loc_a.clone(), loc_b.clone()];
175
+ /// let visits = vec![
176
+ /// Visit::new(0, "A", loc_a),
177
+ /// Visit::new(1, "B", loc_b),
178
+ /// ];
179
+ /// let mut vehicle = Vehicle::new(0, "Alpha", 100, depot);
180
+ /// vehicle.visits = vec![0, 1]; // A -> B
181
+ ///
182
+ /// let mut plan = VehicleRoutePlan::new("test", locations, visits, vec![vehicle]);
183
+ ///
184
+ /// // Set up route geometries (normally done by init_routing)
185
+ /// // Route: depot(0) -> A(1) -> B(2) -> depot(0)
186
+ /// plan.route_geometries.insert((0, 1), vec![(39.95, -75.16), (39.96, -75.17)]);
187
+ /// plan.route_geometries.insert((1, 2), vec![(39.96, -75.17), (39.94, -75.15)]);
188
+ /// plan.route_geometries.insert((2, 0), vec![(39.94, -75.15), (39.95, -75.16)]);
189
+ ///
190
+ /// let segments = encode_routes(&plan);
191
+ /// assert_eq!(segments.len(), 1); // One vehicle with visits
192
+ /// assert_eq!(segments[0].vehicle_name, "Alpha");
193
+ /// assert_eq!(segments[0].point_count, 4); // depot -> A -> B -> depot
194
+ /// ```
195
+ pub fn encode_routes(plan: &VehicleRoutePlan) -> Vec<EncodedSegment> {
196
+ plan.vehicles
197
+ .iter()
198
+ .filter(|v| !v.visits.is_empty())
199
+ .map(|vehicle| {
200
+ let coords = get_route_coords(plan, vehicle);
201
+ let polyline = encode_polyline(&coords);
202
+ EncodedSegment {
203
+ vehicle_idx: vehicle.id,
204
+ vehicle_name: vehicle.name.clone(),
205
+ polyline,
206
+ point_count: coords.len(),
207
+ }
208
+ })
209
+ .collect()
210
+ }
211
+
212
+ /// Gets coordinates for a vehicle's complete route (depot -> visits -> depot).
213
+ ///
214
+ /// Uses stored route geometries from road network routing.
215
+ /// Returns empty if route geometries are not initialized.
216
+ fn get_route_coords(plan: &VehicleRoutePlan, vehicle: &Vehicle) -> Vec<(f64, f64)> {
217
+ let mut coords = Vec::new();
218
+ let depot_idx = vehicle.home_location.index;
219
+
220
+ // Build the sequence of location indices: depot -> visits -> depot
221
+ let visit_loc_indices: Vec<usize> = vehicle
222
+ .visits
223
+ .iter()
224
+ .filter_map(|&v| plan.get_visit(v).map(|visit| visit.location.index))
225
+ .collect();
226
+
227
+ let route: Vec<usize> = std::iter::once(depot_idx)
228
+ .chain(visit_loc_indices)
229
+ .chain(std::iter::once(depot_idx))
230
+ .collect();
231
+
232
+ // Process each leg
233
+ for i in 0..route.len().saturating_sub(1) {
234
+ let from_idx = route[i];
235
+ let to_idx = route[i + 1];
236
+
237
+ if let Some(geometry) = plan.route_geometry(from_idx, to_idx) {
238
+ // Use stored road geometry
239
+ // Skip first point of subsequent segments to avoid duplicates
240
+ let skip = if coords.is_empty() { 0 } else { 1 };
241
+ coords.extend(geometry.iter().skip(skip).copied());
242
+ } else {
243
+ // Fallback: use direct lat/lng when road geometry unavailable
244
+ if coords.is_empty() {
245
+ if let Some(from_loc) = plan.get_location(from_idx) {
246
+ coords.push((from_loc.latitude, from_loc.longitude));
247
+ }
248
+ }
249
+ if let Some(to_loc) = plan.get_location(to_idx) {
250
+ coords.push((to_loc.latitude, to_loc.longitude));
251
+ }
252
+ }
253
+ }
254
+
255
+ coords
256
+ }
257
+
258
+ #[cfg(test)]
259
+ mod tests {
260
+ use super::*;
261
+
262
+ #[test]
263
+ fn test_encode_decode_roundtrip() {
264
+ let coords = vec![
265
+ (38.5, -120.2),
266
+ (40.7, -120.95),
267
+ (43.252, -126.453),
268
+ ];
269
+ let encoded = encode_polyline(&coords);
270
+ let decoded = decode_polyline(&encoded);
271
+
272
+ assert_eq!(decoded.len(), coords.len());
273
+ for (orig, dec) in coords.iter().zip(decoded.iter()) {
274
+ assert!((orig.0 - dec.0).abs() < 0.00001);
275
+ assert!((orig.1 - dec.1).abs() < 0.00001);
276
+ }
277
+ }
278
+
279
+ #[test]
280
+ fn test_known_encoding() {
281
+ // Known encoding from Google's examples
282
+ let coords = vec![(38.5, -120.2), (40.7, -120.95), (43.252, -126.453)];
283
+ let encoded = encode_polyline(&coords);
284
+ // The encoding should be deterministic
285
+ assert!(!encoded.is_empty());
286
+ // Verify we can decode it back
287
+ let decoded = decode_polyline(&encoded);
288
+ assert_eq!(decoded.len(), 3);
289
+ }
290
+
291
+ #[test]
292
+ fn test_empty_coords() {
293
+ let encoded = encode_polyline(&[]);
294
+ assert!(encoded.is_empty());
295
+ let decoded = decode_polyline("");
296
+ assert!(decoded.is_empty());
297
+ }
298
+
299
+ #[test]
300
+ fn test_single_point() {
301
+ let coords = vec![(0.0, 0.0)];
302
+ let encoded = encode_polyline(&coords);
303
+ let decoded = decode_polyline(&encoded);
304
+ assert_eq!(decoded.len(), 1);
305
+ assert!((decoded[0].0).abs() < 0.00001);
306
+ assert!((decoded[0].1).abs() < 0.00001);
307
+ }
308
+ }
src/lib.rs ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Vehicle Routing Quickstart for SolverForge
2
+ //!
3
+ //! Solves vehicle routing problems with time windows, capacity constraints,
4
+ //! and travel time minimization using Late Acceptance local search.
5
+ //!
6
+ //! # Domain Model
7
+ //!
8
+ //! - [`Location`](domain::Location): Geographic point with haversine distance
9
+ //! - [`Visit`](domain::Visit): Customer to visit with time window and demand
10
+ //! - [`Vehicle`](domain::Vehicle): Delivery vehicle with capacity and route
11
+ //! - [`VehicleRoutePlan`](domain::VehicleRoutePlan): Complete planning solution
12
+ //!
13
+ //! # Constraints
14
+ //!
15
+ //! - **Vehicle capacity** (hard): Total demand must not exceed vehicle capacity
16
+ //! - **Time windows** (hard): Service must finish before max end time
17
+ //! - **Travel time** (soft): Minimize total driving time
18
+
19
+ pub mod api;
20
+ pub mod console;
21
+ pub mod constraints;
22
+ pub mod demo_data;
23
+ pub mod domain;
24
+ pub mod geometry;
25
+ pub mod routing;
26
+ pub mod solver;
src/main.rs ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Vehicle Routing Quickstart - Axum Server
2
+ //!
3
+ //! Run with: cargo run -p vehicle-routing
4
+ //! Then open: http://localhost:8082
5
+
6
+ use owo_colors::OwoColorize;
7
+ use std::net::SocketAddr;
8
+ use std::path::PathBuf;
9
+ use tower_http::cors::{Any, CorsLayer};
10
+ use tower_http::services::ServeDir;
11
+ use tracing_subscriber::EnvFilter;
12
+ use vehicle_routing::console;
13
+
14
+ #[tokio::main]
15
+ async fn main() {
16
+ // Initialize tracing (logs from vehicle_routing at INFO level)
17
+ tracing_subscriber::fmt()
18
+ .with_env_filter(
19
+ EnvFilter::from_default_env()
20
+ .add_directive("vehicle_routing=info".parse().unwrap()),
21
+ )
22
+ .init();
23
+
24
+ // Print colorful banner
25
+ console::print_banner();
26
+
27
+ // CORS for development
28
+ let cors = CorsLayer::new()
29
+ .allow_origin(Any)
30
+ .allow_methods(Any)
31
+ .allow_headers(Any);
32
+
33
+ // Determine static files path (works from workspace root or example dir)
34
+ let static_path = if PathBuf::from("examples/vehicle-routing/static").exists() {
35
+ "examples/vehicle-routing/static"
36
+ } else {
37
+ "static"
38
+ };
39
+
40
+ // Build router with static file fallback
41
+ let app = vehicle_routing::api::create_router()
42
+ .fallback_service(ServeDir::new(static_path))
43
+ .layer(cors);
44
+
45
+ // Bind and serve
46
+ let addr = SocketAddr::from(([0, 0, 0, 0], 7860));
47
+ println!(
48
+ "{} Server listening on {}",
49
+ "▸".bright_green(),
50
+ format!("http://{}", addr).bright_cyan().underline()
51
+ );
52
+ println!(
53
+ "{} Open {} in your browser\n",
54
+ "▸".bright_green(),
55
+ "http://localhost:7860".bright_cyan().underline()
56
+ );
57
+
58
+ let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
59
+ axum::serve(listener, app).await.unwrap();
60
+ }
src/routing.rs ADDED
@@ -0,0 +1,822 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Local OSM road routing using Overpass API and petgraph.
2
+ //!
3
+ //! Downloads OpenStreetMap road network data via Overpass API,
4
+ //! builds a graph locally, and computes shortest paths with Dijkstra.
5
+ //! Results are cached in memory (per-process) and `.osm_cache/` (persistent).
6
+
7
+ use ordered_float::OrderedFloat;
8
+ use petgraph::algo::{astar, dijkstra};
9
+ use petgraph::graph::{DiGraph, NodeIndex};
10
+ use serde::{Deserialize, Serialize};
11
+ use std::collections::HashMap;
12
+ use std::path::Path;
13
+ use std::sync::{Arc, OnceLock};
14
+ use tokio::sync::RwLock;
15
+ use tracing::{debug, error, info};
16
+
17
+ /// In-memory cache of road networks, keyed by bbox cache key.
18
+ /// First request downloads, subsequent requests reuse the cached network.
19
+ static NETWORK_CACHE: OnceLock<RwLock<HashMap<String, Arc<RoadNetwork>>>> = OnceLock::new();
20
+
21
+ fn network_cache() -> &'static RwLock<HashMap<String, Arc<RoadNetwork>>> {
22
+ NETWORK_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
23
+ }
24
+
25
+ /// Overpass API URL.
26
+ const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
27
+
28
+ /// Cache directory for downloaded OSM data.
29
+ const CACHE_DIR: &str = ".osm_cache";
30
+
31
+ /// Default driving speed in m/s (50 km/h = 13.89 m/s).
32
+ const DEFAULT_SPEED_MPS: f64 = 50.0 * 1000.0 / 3600.0;
33
+
34
+ /// Error type for routing operations.
35
+ #[derive(Debug)]
36
+ pub enum RoutingError {
37
+ /// Network request failed.
38
+ Network(String),
39
+ /// Failed to parse OSM data.
40
+ Parse(String),
41
+ /// I/O error.
42
+ Io(std::io::Error),
43
+ /// No route found.
44
+ NoRoute,
45
+ }
46
+
47
+ impl std::fmt::Display for RoutingError {
48
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49
+ match self {
50
+ RoutingError::Network(msg) => write!(f, "Network error: {}", msg),
51
+ RoutingError::Parse(msg) => write!(f, "Parse error: {}", msg),
52
+ RoutingError::Io(e) => write!(f, "I/O error: {}", e),
53
+ RoutingError::NoRoute => write!(f, "No route found"),
54
+ }
55
+ }
56
+ }
57
+
58
+ impl std::error::Error for RoutingError {}
59
+
60
+ impl From<std::io::Error> for RoutingError {
61
+ fn from(e: std::io::Error) -> Self {
62
+ RoutingError::Io(e)
63
+ }
64
+ }
65
+
66
+ /// Bounding box for OSM queries.
67
+ #[derive(Debug, Clone, Copy)]
68
+ pub struct BoundingBox {
69
+ pub min_lat: f64,
70
+ pub min_lng: f64,
71
+ pub max_lat: f64,
72
+ pub max_lng: f64,
73
+ }
74
+
75
+ impl BoundingBox {
76
+ /// Creates a new bounding box.
77
+ pub fn new(min_lat: f64, min_lng: f64, max_lat: f64, max_lng: f64) -> Self {
78
+ Self {
79
+ min_lat,
80
+ min_lng,
81
+ max_lat,
82
+ max_lng,
83
+ }
84
+ }
85
+
86
+ /// Expands the bounding box by a factor (e.g., 0.1 = 10% on each side).
87
+ pub fn expand(&self, factor: f64) -> Self {
88
+ let lat_range = self.max_lat - self.min_lat;
89
+ let lng_range = self.max_lng - self.min_lng;
90
+ let lat_pad = lat_range * factor;
91
+ let lng_pad = lng_range * factor;
92
+
93
+ Self {
94
+ min_lat: self.min_lat - lat_pad,
95
+ min_lng: self.min_lng - lng_pad,
96
+ max_lat: self.max_lat + lat_pad,
97
+ max_lng: self.max_lng + lng_pad,
98
+ }
99
+ }
100
+
101
+ /// Returns a cache key for this bounding box.
102
+ fn cache_key(&self) -> String {
103
+ format!(
104
+ "{:.4}_{:.4}_{:.4}_{:.4}",
105
+ self.min_lat, self.min_lng, self.max_lat, self.max_lng
106
+ )
107
+ }
108
+ }
109
+
110
+ /// Node data in the road graph.
111
+ #[derive(Debug, Clone)]
112
+ struct NodeData {
113
+ lat: f64,
114
+ lng: f64,
115
+ }
116
+
117
+ /// Edge data in the road graph.
118
+ #[derive(Debug, Clone)]
119
+ struct EdgeData {
120
+ /// Travel time in seconds.
121
+ travel_time_s: f64,
122
+ /// Distance in meters.
123
+ distance_m: f64,
124
+ /// Intermediate geometry points (for future full path reconstruction).
125
+ #[allow(dead_code)]
126
+ geometry: Vec<(f64, f64)>,
127
+ }
128
+
129
+ /// Result of a route computation.
130
+ #[derive(Debug, Clone)]
131
+ pub struct RouteResult {
132
+ /// Travel time in seconds.
133
+ pub duration_seconds: i64,
134
+ /// Distance in meters.
135
+ pub distance_meters: f64,
136
+ /// Full route geometry (lat, lng pairs).
137
+ pub geometry: Vec<(f64, f64)>,
138
+ }
139
+
140
+ /// Road network graph built from OSM data.
141
+ pub struct RoadNetwork {
142
+ /// Directed graph with travel times as edge weights.
143
+ graph: DiGraph<NodeData, EdgeData>,
144
+ /// Map from (lat_e7, lng_e7) to node index.
145
+ coord_to_node: HashMap<(i64, i64), NodeIndex>,
146
+ }
147
+
148
+ impl RoadNetwork {
149
+ /// Creates an empty road network.
150
+ pub fn new() -> Self {
151
+ Self {
152
+ graph: DiGraph::new(),
153
+ coord_to_node: HashMap::new(),
154
+ }
155
+ }
156
+
157
+ /// Loads or fetches road network for a bounding box.
158
+ ///
159
+ /// Uses three-tier caching:
160
+ /// 1. In-memory cache (instant, per-process)
161
+ /// 2. File cache (fast, persists across restarts)
162
+ /// 3. Overpass API download (slow, ~5-30s)
163
+ ///
164
+ /// Thread-safe: concurrent requests for the same bbox will wait for
165
+ /// the first download to complete rather than downloading multiple times.
166
+ pub async fn load_or_fetch(bbox: &BoundingBox) -> Result<Arc<Self>, RoutingError> {
167
+ let cache_key = bbox.cache_key();
168
+
169
+ // 1. Check in-memory cache (fast path, read lock)
170
+ {
171
+ let cache = network_cache().read().await;
172
+ if let Some(network) = cache.get(&cache_key) {
173
+ info!("Using in-memory cached road network for {}", cache_key);
174
+ return Ok(Arc::clone(network));
175
+ }
176
+ }
177
+
178
+ // 2. Acquire write lock and double-check (another request may have loaded it)
179
+ let mut cache = network_cache().write().await;
180
+ if let Some(network) = cache.get(&cache_key) {
181
+ info!("Using in-memory cached road network for {}", cache_key);
182
+ return Ok(Arc::clone(network));
183
+ }
184
+
185
+ // 3. Try loading from file cache
186
+ tokio::fs::create_dir_all(CACHE_DIR).await?;
187
+ let cache_path = Path::new(CACHE_DIR).join(format!("{}.json", cache_key));
188
+
189
+ let network = if tokio::fs::try_exists(&cache_path).await.unwrap_or(false) {
190
+ info!("Loading road network from file cache: {:?}", cache_path);
191
+ match Self::load_from_cache(&cache_path).await {
192
+ Ok(n) => n,
193
+ Err(e) => {
194
+ // File cache failed (corrupted/old version), download fresh
195
+ info!("File cache invalid ({}), downloading fresh", e);
196
+ let n = Self::from_bbox(bbox).await?;
197
+ n.save_to_cache(&cache_path).await?;
198
+ info!("Saved road network to file cache: {:?}", cache_path);
199
+ n
200
+ }
201
+ }
202
+ } else {
203
+ // 4. Download from Overpass API
204
+ info!("Downloading road network from Overpass API");
205
+ let n = Self::from_bbox(bbox).await?;
206
+ n.save_to_cache(&cache_path).await?;
207
+ info!("Saved road network to file cache: {:?}", cache_path);
208
+ n
209
+ };
210
+
211
+ // Store in memory cache
212
+ let network = Arc::new(network);
213
+ cache.insert(cache_key, Arc::clone(&network));
214
+
215
+ Ok(network)
216
+ }
217
+
218
+ /// Downloads and builds road network from Overpass API.
219
+ pub async fn from_bbox(bbox: &BoundingBox) -> Result<Self, RoutingError> {
220
+ let query = format!(
221
+ r#"[out:json][timeout:120];
222
+ (
223
+ way["highway"~"^(motorway|trunk|primary|secondary|tertiary|residential|unclassified|service|living_street)$"]
224
+ ({},{},{},{});
225
+ );
226
+ (._;>;);
227
+ out body;"#,
228
+ bbox.min_lat, bbox.min_lng, bbox.max_lat, bbox.max_lng
229
+ );
230
+
231
+ debug!("Overpass query:\n{}", query);
232
+
233
+ info!("Preparing Overpass query for bbox: {:.4},{:.4} to {:.4},{:.4}",
234
+ bbox.min_lat, bbox.min_lng, bbox.max_lat, bbox.max_lng);
235
+
236
+ let client = reqwest::Client::builder()
237
+ .connect_timeout(std::time::Duration::from_secs(30))
238
+ .read_timeout(std::time::Duration::from_secs(180))
239
+ .timeout(std::time::Duration::from_secs(180))
240
+ .user_agent("SolverForge/0.4.0")
241
+ .build()
242
+ .map_err(|e| RoutingError::Network(e.to_string()))?;
243
+
244
+ info!("Sending request to Overpass API...");
245
+
246
+ let response = client
247
+ .post(OVERPASS_URL)
248
+ .body(query)
249
+ .header("Content-Type", "text/plain")
250
+ .send()
251
+ .await
252
+ .map_err(|e| {
253
+ error!("Overpass request failed: {}", e);
254
+ RoutingError::Network(e.to_string())
255
+ })?;
256
+
257
+ info!("Received response: status={}", response.status());
258
+
259
+ if !response.status().is_success() {
260
+ return Err(RoutingError::Network(format!(
261
+ "Overpass API returned status {}",
262
+ response.status()
263
+ )));
264
+ }
265
+
266
+ let osm_data: OverpassResponse = response
267
+ .json()
268
+ .await
269
+ .map_err(|e| RoutingError::Parse(e.to_string()))?;
270
+
271
+ info!(
272
+ "Downloaded {} OSM elements",
273
+ osm_data.elements.len()
274
+ );
275
+
276
+ Self::build_from_osm(&osm_data)
277
+ }
278
+
279
+ /// Builds the road network from parsed OSM data.
280
+ fn build_from_osm(osm: &OverpassResponse) -> Result<Self, RoutingError> {
281
+ let mut network = Self::new();
282
+
283
+ // First pass: collect all nodes
284
+ let mut nodes: HashMap<i64, (f64, f64)> = HashMap::new();
285
+ for elem in &osm.elements {
286
+ if elem.elem_type == "node" {
287
+ if let (Some(lat), Some(lon)) = (elem.lat, elem.lon) {
288
+ nodes.insert(elem.id, (lat, lon));
289
+ }
290
+ }
291
+ }
292
+
293
+ info!("Parsed {} nodes", nodes.len());
294
+
295
+ // Second pass: process ways and build graph
296
+ let mut way_count = 0;
297
+ for elem in &osm.elements {
298
+ if elem.elem_type == "way" {
299
+ if let Some(ref node_ids) = elem.nodes {
300
+ let highway = elem.tags.as_ref().and_then(|t| t.highway.as_deref());
301
+ let oneway = elem.tags.as_ref().and_then(|t| t.oneway.as_deref());
302
+ let speed = get_speed_for_highway(highway.unwrap_or("residential"));
303
+ let is_oneway = matches!(oneway, Some("yes") | Some("1"));
304
+
305
+ // Process consecutive node pairs
306
+ for window in node_ids.windows(2) {
307
+ let n1_id = window[0];
308
+ let n2_id = window[1];
309
+
310
+ let Some(&(lat1, lng1)) = nodes.get(&n1_id) else {
311
+ continue;
312
+ };
313
+ let Some(&(lat2, lng2)) = nodes.get(&n2_id) else {
314
+ continue;
315
+ };
316
+
317
+ // Get or create node indices
318
+ let idx1 = network.get_or_create_node(lat1, lng1);
319
+ let idx2 = network.get_or_create_node(lat2, lng2);
320
+
321
+ // Calculate edge properties
322
+ let distance = haversine_distance(lat1, lng1, lat2, lng2);
323
+ let travel_time = distance / speed;
324
+
325
+ let edge_data = EdgeData {
326
+ travel_time_s: travel_time,
327
+ distance_m: distance,
328
+ geometry: vec![(lat1, lng1), (lat2, lng2)],
329
+ };
330
+
331
+ // Add forward edge
332
+ network.graph.add_edge(idx1, idx2, edge_data.clone());
333
+
334
+ // Add reverse edge if not oneway
335
+ if !is_oneway {
336
+ network.graph.add_edge(idx2, idx1, edge_data);
337
+ }
338
+ }
339
+
340
+ way_count += 1;
341
+ }
342
+ }
343
+ }
344
+
345
+ info!(
346
+ "Built graph with {} nodes and {} edges from {} ways",
347
+ network.graph.node_count(),
348
+ network.graph.edge_count(),
349
+ way_count
350
+ );
351
+
352
+ Ok(network)
353
+ }
354
+
355
+ /// Gets or creates a node for the given coordinates.
356
+ fn get_or_create_node(&mut self, lat: f64, lng: f64) -> NodeIndex {
357
+ let key = coord_key(lat, lng);
358
+ if let Some(&idx) = self.coord_to_node.get(&key) {
359
+ idx
360
+ } else {
361
+ let idx = self.graph.add_node(NodeData { lat, lng });
362
+ self.coord_to_node.insert(key, idx);
363
+ idx
364
+ }
365
+ }
366
+
367
+ /// Finds the nearest road node to the given coordinates.
368
+ pub fn snap_to_road(&self, lat: f64, lng: f64) -> Option<NodeIndex> {
369
+ self.coord_to_node
370
+ .iter()
371
+ .min_by_key(|((lat_e7, lng_e7), _)| {
372
+ let node_lat = *lat_e7 as f64 / 1e7;
373
+ let node_lng = *lng_e7 as f64 / 1e7;
374
+ OrderedFloat(haversine_distance(lat, lng, node_lat, node_lng))
375
+ })
376
+ .map(|(_, &idx)| idx)
377
+ }
378
+
379
+ /// Computes shortest path between two coordinates.
380
+ ///
381
+ /// Returns the route with full geometry following roads.
382
+ pub fn route(&self, from: (f64, f64), to: (f64, f64)) -> Option<RouteResult> {
383
+ let start = self.snap_to_road(from.0, from.1)?;
384
+ let end = self.snap_to_road(to.0, to.1)?;
385
+
386
+ if start == end {
387
+ return Some(RouteResult {
388
+ duration_seconds: 0,
389
+ distance_meters: 0.0,
390
+ geometry: vec![from, to],
391
+ });
392
+ }
393
+
394
+ // Use A* with zero heuristic (equivalent to Dijkstra, but returns full path)
395
+ let (cost, path) = astar(
396
+ &self.graph,
397
+ start,
398
+ |n| n == end,
399
+ |e| OrderedFloat(e.weight().travel_time_s),
400
+ |_| OrderedFloat(0.0),
401
+ )?;
402
+
403
+ let total_time = cost.0;
404
+
405
+ // Build geometry from path nodes
406
+ let geometry: Vec<(f64, f64)> = path
407
+ .iter()
408
+ .filter_map(|&idx| self.graph.node_weight(idx).map(|n| (n.lat, n.lng)))
409
+ .collect();
410
+
411
+ // Sum actual edge distances along the path
412
+ let mut distance = 0.0;
413
+ for window in path.windows(2) {
414
+ if let Some(edge) = self.graph.find_edge(window[0], window[1]) {
415
+ if let Some(weight) = self.graph.edge_weight(edge) {
416
+ distance += weight.distance_m;
417
+ }
418
+ }
419
+ }
420
+
421
+ Some(RouteResult {
422
+ duration_seconds: total_time.round() as i64,
423
+ distance_meters: distance,
424
+ geometry,
425
+ })
426
+ }
427
+
428
+ /// Computes route geometries for all location pairs.
429
+ ///
430
+ /// Returns a map from `(from_idx, to_idx)` to the route geometry.
431
+ pub fn compute_all_geometries(
432
+ &self,
433
+ locations: &[(f64, f64)],
434
+ ) -> HashMap<(usize, usize), Vec<(f64, f64)>> {
435
+ self.compute_all_geometries_with_progress(locations, |_, _| {})
436
+ }
437
+
438
+ /// Computes route geometries with row-level progress callback.
439
+ ///
440
+ /// The callback receives `(completed_row, total_rows)` after each source row is computed.
441
+ /// For n locations, this computes n*(n-1) routes, calling the callback n times.
442
+ ///
443
+ /// # Example
444
+ ///
445
+ /// ```
446
+ /// # use vehicle_routing::routing::RoadNetwork;
447
+ /// let network = RoadNetwork::new();
448
+ /// let locations = vec![(39.95, -75.16), (39.96, -75.17)];
449
+ /// let mut progress_calls = 0;
450
+ /// let geometries = network.compute_all_geometries_with_progress(&locations, |row, total| {
451
+ /// progress_calls += 1;
452
+ /// assert!(row < total);
453
+ /// });
454
+ /// assert_eq!(progress_calls, 2); // One call per source location
455
+ /// ```
456
+ pub fn compute_all_geometries_with_progress<F>(
457
+ &self,
458
+ locations: &[(f64, f64)],
459
+ mut on_row_complete: F,
460
+ ) -> HashMap<(usize, usize), Vec<(f64, f64)>>
461
+ where
462
+ F: FnMut(usize, usize),
463
+ {
464
+ let n = locations.len();
465
+ let mut geometries = HashMap::new();
466
+
467
+ for i in 0..n {
468
+ for j in 0..n {
469
+ if i == j {
470
+ continue;
471
+ }
472
+ if let Some(result) = self.route(locations[i], locations[j]) {
473
+ geometries.insert((i, j), result.geometry);
474
+ }
475
+ }
476
+ // Report progress after each source row
477
+ on_row_complete(i, n);
478
+ }
479
+
480
+ geometries
481
+ }
482
+
483
+ /// Computes all-pairs travel time matrix for given locations.
484
+ ///
485
+ /// Returns a matrix where `result[i][j]` is the travel time from location i to j.
486
+ pub fn compute_matrix(&self, locations: &[(f64, f64)]) -> Vec<Vec<i64>> {
487
+ self.compute_matrix_with_progress(locations, |_, _| {})
488
+ }
489
+
490
+ /// Computes all-pairs travel time matrix with row-level progress callback.
491
+ ///
492
+ /// The callback receives `(completed_row, total_rows)` after each row is computed.
493
+ /// This enables progress reporting during the O(n) Dijkstra runs.
494
+ ///
495
+ /// # Example
496
+ ///
497
+ /// ```
498
+ /// # use vehicle_routing::routing::RoadNetwork;
499
+ /// let network = RoadNetwork::new();
500
+ /// let locations = vec![(39.95, -75.16), (39.96, -75.17)];
501
+ /// let mut progress_calls = 0;
502
+ /// let matrix = network.compute_matrix_with_progress(&locations, |row, total| {
503
+ /// progress_calls += 1;
504
+ /// assert!(row < total);
505
+ /// });
506
+ /// assert_eq!(progress_calls, 2); // One call per row
507
+ /// assert_eq!(matrix.len(), 2);
508
+ /// ```
509
+ pub fn compute_matrix_with_progress<F>(
510
+ &self,
511
+ locations: &[(f64, f64)],
512
+ mut on_row_complete: F,
513
+ ) -> Vec<Vec<i64>>
514
+ where
515
+ F: FnMut(usize, usize),
516
+ {
517
+ let n = locations.len();
518
+ let mut matrix = vec![vec![0i64; n]; n];
519
+
520
+ // Snap all locations to nodes
521
+ let nodes: Vec<Option<NodeIndex>> = locations
522
+ .iter()
523
+ .map(|&(lat, lng)| self.snap_to_road(lat, lng))
524
+ .collect();
525
+
526
+ // Compute travel times row by row
527
+ for i in 0..n {
528
+ if let Some(from_node) = nodes[i] {
529
+ // Run Dijkstra from this node
530
+ let costs = dijkstra(&self.graph, from_node, None, |e| {
531
+ OrderedFloat(e.weight().travel_time_s)
532
+ });
533
+
534
+ for j in 0..n {
535
+ if i == j {
536
+ continue;
537
+ }
538
+ if let Some(to_node) = nodes[j] {
539
+ if let Some(cost) = costs.get(&to_node) {
540
+ matrix[i][j] = cost.0.round() as i64;
541
+ } else {
542
+ // No route found, use haversine estimate
543
+ let dist = haversine_distance(
544
+ locations[i].0,
545
+ locations[i].1,
546
+ locations[j].0,
547
+ locations[j].1,
548
+ );
549
+ matrix[i][j] = (dist / DEFAULT_SPEED_MPS).round() as i64;
550
+ }
551
+ }
552
+ }
553
+ } else {
554
+ // Location couldn't be snapped, use haversine for all
555
+ for j in 0..n {
556
+ if i == j {
557
+ continue;
558
+ }
559
+ let dist = haversine_distance(
560
+ locations[i].0,
561
+ locations[i].1,
562
+ locations[j].0,
563
+ locations[j].1,
564
+ );
565
+ matrix[i][j] = (dist / DEFAULT_SPEED_MPS).round() as i64;
566
+ }
567
+ }
568
+
569
+ // Report progress after each row
570
+ on_row_complete(i, n);
571
+ }
572
+
573
+ matrix
574
+ }
575
+
576
+ /// Returns the number of nodes in the graph.
577
+ pub fn node_count(&self) -> usize {
578
+ self.graph.node_count()
579
+ }
580
+
581
+ /// Returns the number of edges in the graph.
582
+ pub fn edge_count(&self) -> usize {
583
+ self.graph.edge_count()
584
+ }
585
+
586
+ /// Loads road network from cache file.
587
+ async fn load_from_cache(path: &Path) -> Result<Self, RoutingError> {
588
+ let data = tokio::fs::read_to_string(path).await?;
589
+
590
+ // Parse cached data, handling corrupted files
591
+ let cached: CachedNetwork = match serde_json::from_str(&data) {
592
+ Ok(c) => c,
593
+ Err(e) => {
594
+ info!("Cache file corrupted, will re-download: {}", e);
595
+ let _ = tokio::fs::remove_file(path).await;
596
+ return Err(RoutingError::Parse(e.to_string()));
597
+ }
598
+ };
599
+
600
+ // Check version - delete old format and re-download
601
+ if cached.version != CACHE_VERSION {
602
+ info!(
603
+ "Cache version mismatch (got {}, need {}), will re-download",
604
+ cached.version, CACHE_VERSION
605
+ );
606
+ let _ = tokio::fs::remove_file(path).await;
607
+ return Err(RoutingError::Parse("cache version mismatch".into()));
608
+ }
609
+
610
+ let mut network = Self::new();
611
+
612
+ // Rebuild graph from cached data
613
+ for node in &cached.nodes {
614
+ let idx = network.graph.add_node(NodeData {
615
+ lat: node.lat,
616
+ lng: node.lng,
617
+ });
618
+ let key = coord_key(node.lat, node.lng);
619
+ network.coord_to_node.insert(key, idx);
620
+ }
621
+
622
+ for edge in &cached.edges {
623
+ let from = NodeIndex::new(edge.from);
624
+ let to = NodeIndex::new(edge.to);
625
+ network.graph.add_edge(
626
+ from,
627
+ to,
628
+ EdgeData {
629
+ travel_time_s: edge.travel_time_s,
630
+ distance_m: edge.distance_m,
631
+ geometry: vec![],
632
+ },
633
+ );
634
+ }
635
+
636
+ Ok(network)
637
+ }
638
+
639
+ /// Saves road network to cache file.
640
+ async fn save_to_cache(&self, path: &Path) -> Result<(), RoutingError> {
641
+ let nodes: Vec<CachedNode> = self
642
+ .graph
643
+ .node_indices()
644
+ .filter_map(|idx| {
645
+ self.graph.node_weight(idx).map(|n| CachedNode {
646
+ lat: n.lat,
647
+ lng: n.lng,
648
+ })
649
+ })
650
+ .collect();
651
+
652
+ let edges: Vec<CachedEdge> = self
653
+ .graph
654
+ .edge_indices()
655
+ .filter_map(|idx| {
656
+ let (from, to) = self.graph.edge_endpoints(idx)?;
657
+ let weight = self.graph.edge_weight(idx)?;
658
+ Some(CachedEdge {
659
+ from: from.index(),
660
+ to: to.index(),
661
+ travel_time_s: weight.travel_time_s,
662
+ distance_m: weight.distance_m,
663
+ })
664
+ })
665
+ .collect();
666
+
667
+ let cached = CachedNetwork {
668
+ version: CACHE_VERSION,
669
+ nodes,
670
+ edges,
671
+ };
672
+ let data = serde_json::to_string(&cached).map_err(|e| RoutingError::Parse(e.to_string()))?;
673
+ tokio::fs::write(path, data).await?;
674
+
675
+ Ok(())
676
+ }
677
+ }
678
+
679
+ impl Default for RoadNetwork {
680
+ fn default() -> Self {
681
+ Self::new()
682
+ }
683
+ }
684
+
685
+ // ============================================================================
686
+ // OSM Data Structures (Overpass API)
687
+ // ============================================================================
688
+
689
+ #[derive(Debug, Deserialize)]
690
+ struct OverpassResponse {
691
+ elements: Vec<OsmElement>,
692
+ }
693
+
694
+ #[derive(Debug, Deserialize)]
695
+ struct OsmElement {
696
+ #[serde(rename = "type")]
697
+ elem_type: String,
698
+ id: i64,
699
+ lat: Option<f64>,
700
+ lon: Option<f64>,
701
+ nodes: Option<Vec<i64>>,
702
+ tags: Option<OsmTags>,
703
+ }
704
+
705
+ #[derive(Debug, Deserialize)]
706
+ struct OsmTags {
707
+ highway: Option<String>,
708
+ oneway: Option<String>,
709
+ /// Maxspeed tag (for future use with dynamic speed calculation).
710
+ #[allow(dead_code)]
711
+ maxspeed: Option<String>,
712
+ }
713
+
714
+ // ============================================================================
715
+ // Cache Data Structures
716
+ // ============================================================================
717
+
718
+ /// Cache format version. Bump this when changing the cache structure.
719
+ const CACHE_VERSION: u32 = 1;
720
+
721
+ #[derive(Debug, Serialize, Deserialize)]
722
+ struct CachedNetwork {
723
+ /// Cache format version for automatic invalidation.
724
+ version: u32,
725
+ nodes: Vec<CachedNode>,
726
+ edges: Vec<CachedEdge>,
727
+ }
728
+
729
+ #[derive(Debug, Serialize, Deserialize)]
730
+ struct CachedNode {
731
+ lat: f64,
732
+ lng: f64,
733
+ }
734
+
735
+ #[derive(Debug, Serialize, Deserialize)]
736
+ struct CachedEdge {
737
+ from: usize,
738
+ to: usize,
739
+ travel_time_s: f64,
740
+ distance_m: f64,
741
+ }
742
+
743
+ // ============================================================================
744
+ // Helper Functions
745
+ // ============================================================================
746
+
747
+ /// Converts coordinates to a hash key (7 decimal places precision).
748
+ fn coord_key(lat: f64, lng: f64) -> (i64, i64) {
749
+ ((lat * 1e7).round() as i64, (lng * 1e7).round() as i64)
750
+ }
751
+
752
+ /// Returns speed in m/s for a highway type.
753
+ fn get_speed_for_highway(highway: &str) -> f64 {
754
+ let kmh = match highway {
755
+ "motorway" | "motorway_link" => 100.0,
756
+ "trunk" | "trunk_link" => 80.0,
757
+ "primary" | "primary_link" => 60.0,
758
+ "secondary" | "secondary_link" => 50.0,
759
+ "tertiary" | "tertiary_link" => 40.0,
760
+ "residential" => 30.0,
761
+ "unclassified" => 30.0,
762
+ "service" => 20.0,
763
+ "living_street" => 10.0,
764
+ _ => 30.0,
765
+ };
766
+ kmh * 1000.0 / 3600.0
767
+ }
768
+
769
+ /// Haversine distance between two points in meters.
770
+ fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
771
+ const R: f64 = 6_371_000.0; // Earth radius in meters
772
+
773
+ let lat1_rad = lat1.to_radians();
774
+ let lat2_rad = lat2.to_radians();
775
+ let dlat = (lat2 - lat1).to_radians();
776
+ let dlng = (lng2 - lng1).to_radians();
777
+
778
+ let a = (dlat / 2.0).sin().powi(2)
779
+ + lat1_rad.cos() * lat2_rad.cos() * (dlng / 2.0).sin().powi(2);
780
+ let c = 2.0 * a.sqrt().asin();
781
+
782
+ R * c
783
+ }
784
+
785
+ #[cfg(test)]
786
+ mod tests {
787
+ use super::*;
788
+
789
+ #[test]
790
+ fn test_haversine_distance() {
791
+ // Philadelphia City Hall to Liberty Bell (~500m)
792
+ let dist = haversine_distance(39.9526, -75.1635, 39.9496, -75.1503);
793
+ assert!((dist - 1200.0).abs() < 100.0); // Approximately 1.2 km
794
+ }
795
+
796
+ #[test]
797
+ fn test_coord_key() {
798
+ let key = coord_key(39.9526, -75.1635);
799
+ assert_eq!(key, (399526000, -751635000));
800
+ }
801
+
802
+ #[test]
803
+ fn test_bbox_expand() {
804
+ let bbox = BoundingBox::new(39.9, -75.2, 40.0, -75.1);
805
+ let expanded = bbox.expand(0.1);
806
+ assert!(expanded.min_lat < bbox.min_lat);
807
+ assert!(expanded.max_lat > bbox.max_lat);
808
+ }
809
+
810
+ #[test]
811
+ fn test_empty_network() {
812
+ let network = RoadNetwork::new();
813
+ assert_eq!(network.node_count(), 0);
814
+ assert_eq!(network.edge_count(), 0);
815
+ }
816
+
817
+ #[test]
818
+ fn test_snap_to_road_empty() {
819
+ let network = RoadNetwork::new();
820
+ assert!(network.snap_to_road(39.95, -75.16).is_none());
821
+ }
822
+ }
src/solver.rs ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Solver service for Vehicle Routing Problem.
2
+ //!
3
+ //! Uses Late Acceptance local search with 3-opt moves for efficient route optimization.
4
+ //! Direct score calculation with full solution access (no global state).
5
+
6
+ use parking_lot::RwLock;
7
+ use rand::Rng;
8
+ use solverforge::prelude::*;
9
+ use std::collections::HashMap;
10
+ use std::sync::Arc;
11
+ use std::time::{Duration, Instant};
12
+ use tokio::sync::oneshot;
13
+ use tracing::{debug, info};
14
+
15
+ use crate::console::{self, PhaseTimer};
16
+ use crate::constraints::calculate_score;
17
+ use crate::domain::VehicleRoutePlan;
18
+
19
+ /// Default solving time: 30 seconds.
20
+ const DEFAULT_TIME_LIMIT_SECS: u64 = 30;
21
+
22
+ /// Late acceptance history size.
23
+ const LATE_ACCEPTANCE_SIZE: usize = 400;
24
+
25
+ /// Solver configuration with termination criteria.
26
+ ///
27
+ /// Multiple termination conditions combine with OR logic (any triggers termination).
28
+ #[derive(Debug, Clone, Default)]
29
+ pub struct SolverConfig {
30
+ /// Stop after this duration.
31
+ pub time_limit: Option<Duration>,
32
+ /// Stop after this duration without improvement.
33
+ pub unimproved_time_limit: Option<Duration>,
34
+ /// Stop after this many steps.
35
+ pub step_limit: Option<u64>,
36
+ /// Stop after this many steps without improvement.
37
+ pub unimproved_step_limit: Option<u64>,
38
+ }
39
+
40
+ impl SolverConfig {
41
+ /// Creates a config with default 30-second time limit.
42
+ pub fn default_config() -> Self {
43
+ Self {
44
+ time_limit: Some(Duration::from_secs(DEFAULT_TIME_LIMIT_SECS)),
45
+ ..Default::default()
46
+ }
47
+ }
48
+
49
+ /// Checks if any termination condition is met.
50
+ fn should_terminate(
51
+ &self,
52
+ elapsed: Duration,
53
+ steps: u64,
54
+ time_since_improvement: Duration,
55
+ steps_since_improvement: u64,
56
+ ) -> bool {
57
+ if let Some(limit) = self.time_limit {
58
+ if elapsed >= limit {
59
+ return true;
60
+ }
61
+ }
62
+ if let Some(limit) = self.unimproved_time_limit {
63
+ if time_since_improvement >= limit {
64
+ return true;
65
+ }
66
+ }
67
+ if let Some(limit) = self.step_limit {
68
+ if steps >= limit {
69
+ return true;
70
+ }
71
+ }
72
+ if let Some(limit) = self.unimproved_step_limit {
73
+ if steps_since_improvement >= limit {
74
+ return true;
75
+ }
76
+ }
77
+ false
78
+ }
79
+ }
80
+
81
+ /// Status of a solving job.
82
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
83
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
84
+ pub enum SolverStatus {
85
+ /// Not currently solving.
86
+ NotSolving,
87
+ /// Actively solving.
88
+ Solving,
89
+ }
90
+
91
+ impl SolverStatus {
92
+ /// Returns the status as a SCREAMING_SNAKE_CASE string for API responses.
93
+ ///
94
+ /// ```
95
+ /// use vehicle_routing::solver::SolverStatus;
96
+ ///
97
+ /// assert_eq!(SolverStatus::NotSolving.as_str(), "NOT_SOLVING");
98
+ /// assert_eq!(SolverStatus::Solving.as_str(), "SOLVING");
99
+ /// ```
100
+ pub fn as_str(self) -> &'static str {
101
+ match self {
102
+ SolverStatus::NotSolving => "NOT_SOLVING",
103
+ SolverStatus::Solving => "SOLVING",
104
+ }
105
+ }
106
+ }
107
+
108
+ /// A solving job with current state.
109
+ pub struct SolveJob {
110
+ /// Unique job identifier.
111
+ pub id: String,
112
+ /// Current status.
113
+ pub status: SolverStatus,
114
+ /// Current best solution.
115
+ pub plan: VehicleRoutePlan,
116
+ /// Solver configuration.
117
+ pub config: SolverConfig,
118
+ /// Stop signal sender.
119
+ stop_signal: Option<oneshot::Sender<()>>,
120
+ }
121
+
122
+ impl SolveJob {
123
+ /// Creates a new solve job with default config.
124
+ pub fn new(id: String, plan: VehicleRoutePlan) -> Self {
125
+ Self {
126
+ id,
127
+ status: SolverStatus::NotSolving,
128
+ plan,
129
+ config: SolverConfig::default_config(),
130
+ stop_signal: None,
131
+ }
132
+ }
133
+
134
+ /// Creates a new solve job with custom config.
135
+ pub fn with_config(id: String, plan: VehicleRoutePlan, config: SolverConfig) -> Self {
136
+ Self {
137
+ id,
138
+ status: SolverStatus::NotSolving,
139
+ plan,
140
+ config,
141
+ stop_signal: None,
142
+ }
143
+ }
144
+ }
145
+
146
+ /// Manages VRP solving jobs.
147
+ ///
148
+ /// # Examples
149
+ ///
150
+ /// ```
151
+ /// use vehicle_routing::solver::SolverService;
152
+ /// use vehicle_routing::demo_data::generate_philadelphia;
153
+ ///
154
+ /// let service = SolverService::new();
155
+ /// let plan = generate_philadelphia();
156
+ ///
157
+ /// // Create a job (doesn't start solving yet)
158
+ /// let job = service.create_job("test-1".to_string(), plan);
159
+ /// assert_eq!(job.read().status, vehicle_routing::solver::SolverStatus::NotSolving);
160
+ /// ```
161
+ pub struct SolverService {
162
+ jobs: RwLock<HashMap<String, Arc<RwLock<SolveJob>>>>,
163
+ }
164
+
165
+ impl SolverService {
166
+ /// Creates a new solver service.
167
+ pub fn new() -> Self {
168
+ Self {
169
+ jobs: RwLock::new(HashMap::new()),
170
+ }
171
+ }
172
+
173
+ /// Creates a new job for the given plan with default config.
174
+ pub fn create_job(&self, id: String, plan: VehicleRoutePlan) -> Arc<RwLock<SolveJob>> {
175
+ let job = Arc::new(RwLock::new(SolveJob::new(id.clone(), plan)));
176
+ self.jobs.write().insert(id, job.clone());
177
+ job
178
+ }
179
+
180
+ /// Creates a new job with custom config.
181
+ pub fn create_job_with_config(
182
+ &self,
183
+ id: String,
184
+ plan: VehicleRoutePlan,
185
+ config: SolverConfig,
186
+ ) -> Arc<RwLock<SolveJob>> {
187
+ let job = Arc::new(RwLock::new(SolveJob::with_config(id.clone(), plan, config)));
188
+ self.jobs.write().insert(id, job.clone());
189
+ job
190
+ }
191
+
192
+ /// Gets a job by ID.
193
+ pub fn get_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> {
194
+ self.jobs.read().get(id).cloned()
195
+ }
196
+
197
+ /// Lists all job IDs.
198
+ pub fn list_jobs(&self) -> Vec<String> {
199
+ self.jobs.read().keys().cloned().collect()
200
+ }
201
+
202
+ /// Removes a job by ID.
203
+ pub fn remove_job(&self, id: &str) -> Option<Arc<RwLock<SolveJob>>> {
204
+ self.jobs.write().remove(id)
205
+ }
206
+
207
+ /// Starts solving a job in the background.
208
+ pub fn start_solving(&self, job: Arc<RwLock<SolveJob>>) {
209
+ let (tx, rx) = oneshot::channel();
210
+ let config = job.read().config.clone();
211
+
212
+ {
213
+ let mut job_guard = job.write();
214
+ job_guard.status = SolverStatus::Solving;
215
+ job_guard.stop_signal = Some(tx);
216
+ }
217
+
218
+ let job_clone = job.clone();
219
+
220
+ tokio::task::spawn_blocking(move || {
221
+ solve_blocking(job_clone, rx, config);
222
+ });
223
+ }
224
+
225
+ /// Stops a solving job.
226
+ pub fn stop_solving(&self, id: &str) -> bool {
227
+ if let Some(job) = self.get_job(id) {
228
+ let mut job_guard = job.write();
229
+ if let Some(stop_signal) = job_guard.stop_signal.take() {
230
+ let _ = stop_signal.send(());
231
+ job_guard.status = SolverStatus::NotSolving;
232
+ return true;
233
+ }
234
+ }
235
+ false
236
+ }
237
+ }
238
+
239
+ impl Default for SolverService {
240
+ fn default() -> Self {
241
+ Self::new()
242
+ }
243
+ }
244
+
245
+ /// Runs the solver in a blocking context.
246
+ fn solve_blocking(
247
+ job: Arc<RwLock<SolveJob>>,
248
+ mut stop_rx: oneshot::Receiver<()>,
249
+ config: SolverConfig,
250
+ ) {
251
+ let mut solution = job.read().plan.clone();
252
+ let job_id = job.read().id.clone();
253
+ let solve_start = Instant::now();
254
+
255
+ // Print problem configuration
256
+ console::print_config(
257
+ solution.vehicles.len(),
258
+ solution.visits.len(),
259
+ solution.locations.len(),
260
+ );
261
+
262
+ info!(
263
+ job_id = %job_id,
264
+ visits = solution.visits.len(),
265
+ vehicles = solution.vehicles.len(),
266
+ "Starting VRP solver"
267
+ );
268
+
269
+ // Phase 1: Construction heuristic (round-robin)
270
+ let mut ch_timer = PhaseTimer::start("ConstructionHeuristic", 0);
271
+ let mut current_score = construction_heuristic(&mut solution, &mut ch_timer);
272
+ ch_timer.finish();
273
+
274
+ // Print solving started after construction
275
+ console::print_solving_started(
276
+ solve_start.elapsed().as_millis() as u64,
277
+ &current_score.to_string(),
278
+ solution.visits.len(),
279
+ solution.visits.len(),
280
+ solution.vehicles.len(),
281
+ );
282
+
283
+ // Update job with constructed solution
284
+ update_job(&job, &solution, current_score);
285
+
286
+ // Phase 2: Late Acceptance local search with 3-opt
287
+ let n_vehicles = solution.vehicles.len();
288
+ if n_vehicles == 0 {
289
+ info!("No vehicles to optimize");
290
+ console::print_solving_ended(
291
+ solve_start.elapsed(),
292
+ 0,
293
+ 1,
294
+ &current_score.to_string(),
295
+ current_score.is_feasible(),
296
+ );
297
+ finish_job(&job, &solution, current_score);
298
+ return;
299
+ }
300
+
301
+ let mut ls_timer = PhaseTimer::start("LateAcceptance", 1);
302
+ let mut late_scores = vec![current_score; LATE_ACCEPTANCE_SIZE];
303
+ let mut step: u64 = 0;
304
+ let mut rng = rand::thread_rng();
305
+
306
+ // Track best score and improvement times
307
+ let mut best_score = current_score;
308
+ let mut last_improvement_time = solve_start;
309
+ let mut last_improvement_step: u64 = 0;
310
+
311
+ loop {
312
+ // Check termination conditions
313
+ let elapsed = solve_start.elapsed();
314
+ let time_since_improvement = last_improvement_time.elapsed();
315
+ let steps_since_improvement = step - last_improvement_step;
316
+
317
+ if config.should_terminate(elapsed, step, time_since_improvement, steps_since_improvement) {
318
+ debug!("Termination condition met");
319
+ break;
320
+ }
321
+
322
+ // Check for stop signal
323
+ if stop_rx.try_recv().is_ok() {
324
+ info!("Solving terminated early by user");
325
+ break;
326
+ }
327
+
328
+ // Alternate between list-change and 2-opt moves
329
+ let accepted = if step % 3 == 0 {
330
+ // 2-opt move (intra-route segment reversal)
331
+ try_two_opt_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer)
332
+ } else {
333
+ // List-change move (visit relocation)
334
+ try_list_change_move(&mut solution, &mut current_score, &late_scores, step, &mut rng, &mut ls_timer)
335
+ };
336
+
337
+ if accepted {
338
+ // Update late acceptance history
339
+ let late_idx = (step as usize) % LATE_ACCEPTANCE_SIZE;
340
+ late_scores[late_idx] = current_score;
341
+
342
+ // Track improvements
343
+ if current_score > best_score {
344
+ best_score = current_score;
345
+ last_improvement_time = Instant::now();
346
+ last_improvement_step = step;
347
+ }
348
+
349
+ // Periodic update
350
+ if ls_timer.steps_accepted().is_multiple_of(1000) {
351
+ update_job(&job, &solution, current_score);
352
+ debug!(
353
+ step,
354
+ moves_accepted = ls_timer.steps_accepted(),
355
+ score = %current_score,
356
+ elapsed_secs = solve_start.elapsed().as_secs(),
357
+ "Progress update"
358
+ );
359
+ }
360
+
361
+ // Periodic console progress (every 10000 moves)
362
+ if ls_timer.moves_evaluated().is_multiple_of(10000) {
363
+ console::print_step_progress(
364
+ ls_timer.steps_accepted(),
365
+ ls_timer.elapsed(),
366
+ ls_timer.moves_evaluated(),
367
+ &current_score.to_string(),
368
+ );
369
+ }
370
+ }
371
+
372
+ step += 1;
373
+ }
374
+
375
+ ls_timer.finish();
376
+
377
+ let total_duration = solve_start.elapsed();
378
+ let total_moves = step;
379
+
380
+ info!(
381
+ job_id = %job_id,
382
+ duration_secs = total_duration.as_secs_f64(),
383
+ steps = step,
384
+ score = %current_score,
385
+ feasible = current_score.is_feasible(),
386
+ "Solving complete"
387
+ );
388
+
389
+ console::print_solving_ended(
390
+ total_duration,
391
+ total_moves,
392
+ 2,
393
+ &current_score.to_string(),
394
+ current_score.is_feasible(),
395
+ );
396
+
397
+ finish_job(&job, &solution, current_score);
398
+ }
399
+
400
+ /// Construction heuristic: round-robin visit assignment.
401
+ ///
402
+ /// Skips construction if all visits are already assigned (continue mode).
403
+ fn construction_heuristic(solution: &mut VehicleRoutePlan, timer: &mut PhaseTimer) -> HardSoftScore {
404
+ let n_visits = solution.visits.len();
405
+ let n_vehicles = solution.vehicles.len();
406
+
407
+ if n_vehicles == 0 || n_visits == 0 {
408
+ return calculate_score(solution);
409
+ }
410
+
411
+ // Count already-assigned visits
412
+ let assigned_count: usize = solution.vehicles.iter().map(|v| v.visits.len()).sum();
413
+
414
+ // If all visits already assigned, skip construction (continue mode)
415
+ if assigned_count == n_visits {
416
+ info!("All visits already assigned, skipping construction heuristic");
417
+ return calculate_score(solution);
418
+ }
419
+
420
+ // Build set of already-assigned visits
421
+ let assigned: std::collections::HashSet<usize> = solution
422
+ .vehicles
423
+ .iter()
424
+ .flat_map(|v| v.visits.iter().copied())
425
+ .collect();
426
+
427
+ // Round-robin assignment for unassigned visits only
428
+ let mut vehicle_idx = 0;
429
+ for visit_idx in 0..n_visits {
430
+ if assigned.contains(&visit_idx) {
431
+ continue;
432
+ }
433
+
434
+ timer.record_move();
435
+ solution.vehicles[vehicle_idx].visits.push(visit_idx);
436
+
437
+ let score = calculate_score(solution);
438
+ timer.record_accepted(&score.to_string());
439
+
440
+ vehicle_idx = (vehicle_idx + 1) % n_vehicles;
441
+ }
442
+
443
+ calculate_score(solution)
444
+ }
445
+
446
+ /// Tries a list-change (visit relocation) move.
447
+ /// Returns true if the move was accepted.
448
+ fn try_list_change_move<R: Rng>(
449
+ solution: &mut VehicleRoutePlan,
450
+ current_score: &mut HardSoftScore,
451
+ late_scores: &[HardSoftScore],
452
+ step: u64,
453
+ rng: &mut R,
454
+ timer: &mut PhaseTimer,
455
+ ) -> bool {
456
+ let n_vehicles = solution.vehicles.len();
457
+
458
+ // Find a non-empty source vehicle
459
+ let non_empty: Vec<usize> = solution
460
+ .vehicles
461
+ .iter()
462
+ .enumerate()
463
+ .filter(|(_, v)| !v.visits.is_empty())
464
+ .map(|(i, _)| i)
465
+ .collect();
466
+
467
+ if non_empty.is_empty() {
468
+ return false;
469
+ }
470
+
471
+ let src_vehicle = non_empty[rng.gen_range(0..non_empty.len())];
472
+ let src_len = solution.vehicles[src_vehicle].visits.len();
473
+ let src_pos = rng.gen_range(0..src_len);
474
+
475
+ // Pick destination vehicle and position
476
+ let dst_vehicle = rng.gen_range(0..n_vehicles);
477
+ let dst_len = solution.vehicles[dst_vehicle].visits.len();
478
+
479
+ // Valid insertion position
480
+ let max_pos = if src_vehicle == dst_vehicle {
481
+ src_len
482
+ } else {
483
+ dst_len + 1
484
+ };
485
+
486
+ if max_pos == 0 {
487
+ return false;
488
+ }
489
+
490
+ let dst_pos = rng.gen_range(0..max_pos);
491
+
492
+ // Skip no-op moves
493
+ if src_vehicle == dst_vehicle {
494
+ let effective_dst = if dst_pos > src_pos { dst_pos - 1 } else { dst_pos };
495
+ if src_pos == effective_dst {
496
+ return false;
497
+ }
498
+ }
499
+
500
+ timer.record_move();
501
+
502
+ // Apply move
503
+ let visit_idx = solution.vehicles[src_vehicle].visits.remove(src_pos);
504
+ let adjusted_dst = if src_vehicle == dst_vehicle && dst_pos > src_pos {
505
+ dst_pos - 1
506
+ } else {
507
+ dst_pos
508
+ };
509
+ solution.vehicles[dst_vehicle].visits.insert(adjusted_dst, visit_idx);
510
+
511
+ // Evaluate
512
+ let new_score = calculate_score(solution);
513
+ let late_idx = (step as usize) % late_scores.len();
514
+ let late_score = late_scores[late_idx];
515
+
516
+ if new_score >= *current_score || new_score >= late_score {
517
+ // Accept
518
+ timer.record_accepted(&new_score.to_string());
519
+ *current_score = new_score;
520
+ true
521
+ } else {
522
+ // Reject - undo
523
+ solution.vehicles[dst_vehicle].visits.remove(adjusted_dst);
524
+ solution.vehicles[src_vehicle].visits.insert(src_pos, visit_idx);
525
+ false
526
+ }
527
+ }
528
+
529
+ /// Tries a 2-opt move (reverse a segment within a route).
530
+ /// Returns true if the move was accepted.
531
+ fn try_two_opt_move<R: Rng>(
532
+ solution: &mut VehicleRoutePlan,
533
+ current_score: &mut HardSoftScore,
534
+ late_scores: &[HardSoftScore],
535
+ step: u64,
536
+ rng: &mut R,
537
+ timer: &mut PhaseTimer,
538
+ ) -> bool {
539
+ // Find a vehicle with at least 2 visits
540
+ let eligible: Vec<usize> = solution
541
+ .vehicles
542
+ .iter()
543
+ .enumerate()
544
+ .filter(|(_, v)| v.visits.len() >= 2)
545
+ .map(|(i, _)| i)
546
+ .collect();
547
+
548
+ if eligible.is_empty() {
549
+ return false;
550
+ }
551
+
552
+ let vehicle_idx = eligible[rng.gen_range(0..eligible.len())];
553
+ let route_len = solution.vehicles[vehicle_idx].visits.len();
554
+
555
+ // Pick two cut points for 2-opt
556
+ let i = rng.gen_range(0..route_len);
557
+ let j = rng.gen_range(0..route_len);
558
+
559
+ if i == j {
560
+ return false;
561
+ }
562
+
563
+ let (start, end) = if i < j { (i, j) } else { (j, i) };
564
+
565
+ // Need at least 2 elements to reverse
566
+ if end - start < 1 {
567
+ return false;
568
+ }
569
+
570
+ timer.record_move();
571
+
572
+ // Apply 2-opt: reverse segment [start, end]
573
+ solution.vehicles[vehicle_idx].visits[start..=end].reverse();
574
+
575
+ // Evaluate
576
+ let new_score = calculate_score(solution);
577
+ let late_idx = (step as usize) % late_scores.len();
578
+ let late_score = late_scores[late_idx];
579
+
580
+ if new_score >= *current_score || new_score >= late_score {
581
+ // Accept
582
+ timer.record_accepted(&new_score.to_string());
583
+ *current_score = new_score;
584
+ true
585
+ } else {
586
+ // Reject - undo (reverse again)
587
+ solution.vehicles[vehicle_idx].visits[start..=end].reverse();
588
+ false
589
+ }
590
+ }
591
+
592
+ /// Updates job with current solution.
593
+ fn update_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) {
594
+ let mut job_guard = job.write();
595
+ job_guard.plan = solution.clone();
596
+ job_guard.plan.score = Some(score);
597
+ }
598
+
599
+ /// Finishes job and sets status.
600
+ fn finish_job(job: &Arc<RwLock<SolveJob>>, solution: &VehicleRoutePlan, score: HardSoftScore) {
601
+ let mut job_guard = job.write();
602
+ job_guard.plan = solution.clone();
603
+ job_guard.plan.score = Some(score);
604
+ job_guard.status = SolverStatus::NotSolving;
605
+ }
606
+
607
+ #[cfg(test)]
608
+ mod tests {
609
+ use super::*;
610
+ use crate::demo_data::generate_philadelphia;
611
+
612
+ #[test]
613
+ fn test_construction_heuristic() {
614
+ let mut plan = generate_philadelphia();
615
+
616
+ // Create a timer but don't print (we're in a test)
617
+ let mut timer = PhaseTimer::start("ConstructionHeuristic", 0);
618
+ let score = construction_heuristic(&mut plan, &mut timer);
619
+
620
+ // All visits should be assigned
621
+ let total_visits: usize = plan.vehicles.iter().map(|v| v.visits.len()).sum();
622
+ assert_eq!(total_visits, 49); // Philadelphia has 49 visits
623
+ assert!(score.hard() <= 0); // May have some violations
624
+ }
625
+ }
static/app.js ADDED
@@ -0,0 +1,1627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let autoRefreshIntervalId = null;
2
+ let initialized = false;
3
+ let optimizing = false;
4
+ let demoDataId = null;
5
+ let scheduleId = null;
6
+ let loadedRoutePlan = null;
7
+ let newVisit = null;
8
+ let visitMarker = null;
9
+ let routeGeometries = null; // Cache for encoded polyline geometries
10
+ let useRealRoads = true; // Routing mode toggle state (default: real roads)
11
+ const solveButton = $("#solveButton");
12
+ const stopSolvingButton = $("#stopSolvingButton");
13
+ const vehiclesTable = $("#vehicles");
14
+ const analyzeButton = $("#analyzeButton");
15
+
16
+ /**
17
+ * Decode an encoded polyline string into an array of [lat, lng] coordinates.
18
+ * This is the Google polyline encoding algorithm.
19
+ * @param {string} encoded - The encoded polyline string
20
+ * @returns {Array<Array<number>>} Array of [lat, lng] coordinate pairs
21
+ */
22
+ function decodePolyline(encoded) {
23
+ if (!encoded) return [];
24
+
25
+ const points = [];
26
+ let index = 0;
27
+ let lat = 0;
28
+ let lng = 0;
29
+
30
+ while (index < encoded.length) {
31
+ // Decode latitude
32
+ let shift = 0;
33
+ let result = 0;
34
+ let byte;
35
+ do {
36
+ byte = encoded.charCodeAt(index++) - 63;
37
+ result |= (byte & 0x1f) << shift;
38
+ shift += 5;
39
+ } while (byte >= 0x20);
40
+ const dlat = (result & 1) ? ~(result >> 1) : (result >> 1);
41
+ lat += dlat;
42
+
43
+ // Decode longitude
44
+ shift = 0;
45
+ result = 0;
46
+ do {
47
+ byte = encoded.charCodeAt(index++) - 63;
48
+ result |= (byte & 0x1f) << shift;
49
+ shift += 5;
50
+ } while (byte >= 0x20);
51
+ const dlng = (result & 1) ? ~(result >> 1) : (result >> 1);
52
+ lng += dlng;
53
+
54
+ // Polyline encoding uses precision of 5 decimal places
55
+ points.push([lat / 1e5, lng / 1e5]);
56
+ }
57
+
58
+ return points;
59
+ }
60
+
61
+ /**
62
+ * Fetch route geometries for the current schedule from the backend.
63
+ * @returns {Promise<Object|null>} The geometries object or null if unavailable
64
+ */
65
+ async function fetchRouteGeometries() {
66
+ if (!scheduleId) return null;
67
+
68
+ try {
69
+ const response = await fetch(`/route-plans/${scheduleId}/geometry`);
70
+ if (response.ok) {
71
+ const data = await response.json();
72
+ // Transform segments array into map: { vehicleId: [polyline] }
73
+ const geometries = {};
74
+ for (const segment of data.segments || []) {
75
+ const vehicleId = String(segment.vehicle_idx);
76
+ if (!geometries[vehicleId]) {
77
+ geometries[vehicleId] = [];
78
+ }
79
+ geometries[vehicleId].push(segment.polyline);
80
+ }
81
+ return Object.keys(geometries).length > 0 ? geometries : null;
82
+ }
83
+ } catch (e) {
84
+ console.warn('Could not fetch route geometries:', e);
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /*************************************** Loading Overlay Functions **************************************/
90
+
91
+ function showLoadingOverlay(title = "Loading Demo Data", message = "Initializing...") {
92
+ $("#loadingTitle").text(title);
93
+ $("#loadingMessage").text(message);
94
+ $("#loadingProgress").css("width", "0%");
95
+ $("#loadingDetail").text("");
96
+ $("#loadingOverlay").removeClass("hidden");
97
+ }
98
+
99
+ function hideLoadingOverlay() {
100
+ $("#loadingOverlay").addClass("hidden");
101
+ }
102
+
103
+ function updateLoadingProgress(message, percent, detail = "") {
104
+ $("#loadingMessage").text(message);
105
+ $("#loadingProgress").css("width", `${percent}%`);
106
+ $("#loadingDetail").text(detail);
107
+ }
108
+
109
+ /**
110
+ * Load demo data with progress updates via Server-Sent Events.
111
+ * Used when Real Roads mode is enabled.
112
+ */
113
+ function loadDemoDataWithProgress(demoId) {
114
+ return new Promise((resolve, reject) => {
115
+ const routingMode = useRealRoads ? "real_roads" : "haversine";
116
+ const url = `/demo-data/${demoId}/stream?routing=${routingMode}`;
117
+
118
+ showLoadingOverlay(
119
+ useRealRoads ? "Loading Real Road Data" : "Loading Demo Data",
120
+ "Connecting..."
121
+ );
122
+
123
+ const eventSource = new EventSource(url);
124
+ let solution = null;
125
+
126
+ eventSource.onmessage = function(event) {
127
+ try {
128
+ const data = JSON.parse(event.data);
129
+
130
+ if (data.event === "progress") {
131
+ let statusIcon = "";
132
+ if (data.phase === "network") {
133
+ statusIcon = '<i class="fas fa-download me-2"></i>';
134
+ } else if (data.phase === "routes") {
135
+ statusIcon = '<i class="fas fa-route me-2"></i>';
136
+ } else if (data.phase === "complete") {
137
+ statusIcon = '<i class="fas fa-check-circle me-2 text-success"></i>';
138
+ }
139
+ updateLoadingProgress(data.message, data.percent, data.detail || "");
140
+ } else if (data.event === "complete") {
141
+ solution = data.solution;
142
+ // Store geometries from the response if available
143
+ if (data.geometries) {
144
+ routeGeometries = data.geometries;
145
+ }
146
+ eventSource.close();
147
+ hideLoadingOverlay();
148
+ resolve(solution);
149
+ } else if (data.event === "error") {
150
+ eventSource.close();
151
+ hideLoadingOverlay();
152
+ reject(new Error(data.message));
153
+ }
154
+ } catch (e) {
155
+ console.error("Error parsing SSE event:", e);
156
+ }
157
+ };
158
+
159
+ eventSource.onerror = function(error) {
160
+ eventSource.close();
161
+ hideLoadingOverlay();
162
+ reject(new Error("Connection lost while loading data"));
163
+ };
164
+ });
165
+ }
166
+
167
+ /*************************************** Map constants and variable definitions **************************************/
168
+
169
+ const homeLocationMarkerByIdMap = new Map();
170
+ const visitMarkerByIdMap = new Map();
171
+
172
+ const map = L.map("map", { doubleClickZoom: false }).setView(
173
+ [51.505, -0.09],
174
+ 13,
175
+ );
176
+ const visitGroup = L.layerGroup().addTo(map);
177
+ const homeLocationGroup = L.layerGroup().addTo(map);
178
+ const routeGroup = L.layerGroup().addTo(map);
179
+
180
+ /************************************ Time line constants and variable definitions ************************************/
181
+
182
+ let byVehicleTimeline;
183
+ let byVisitTimeline;
184
+ const byVehicleGroupData = new vis.DataSet();
185
+ const byVehicleItemData = new vis.DataSet();
186
+ const byVisitGroupData = new vis.DataSet();
187
+ const byVisitItemData = new vis.DataSet();
188
+
189
+ const byVehicleTimelineOptions = {
190
+ timeAxis: { scale: "hour" },
191
+ orientation: { axis: "top" },
192
+ xss: { disabled: true }, // Items are XSS safe through JQuery
193
+ stack: false,
194
+ stackSubgroups: false,
195
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
196
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
197
+ };
198
+
199
+ const byVisitTimelineOptions = {
200
+ timeAxis: { scale: "hour" },
201
+ orientation: { axis: "top" },
202
+ verticalScroll: true,
203
+ xss: { disabled: true }, // Items are XSS safe through JQuery
204
+ stack: false,
205
+ stackSubgroups: false,
206
+ zoomMin: 1000 * 60 * 60, // A single hour in milliseconds
207
+ zoomMax: 1000 * 60 * 60 * 24, // A single day in milliseconds
208
+ };
209
+
210
+ /************************************ Initialize ************************************/
211
+
212
+ // Vehicle management state
213
+ let addingVehicleMode = false;
214
+ let pickingVehicleLocation = false;
215
+ let tempVehicleMarker = null;
216
+ let vehicleDeparturePicker = null;
217
+
218
+ // Route highlighting state
219
+ let highlightedVehicleId = null;
220
+ let routeNumberMarkers = []; // Markers showing 1, 2, 3... on route stops
221
+
222
+
223
+ $(document).ready(function () {
224
+ replaceQuickstartSolverForgeAutoHeaderFooter();
225
+
226
+ // Initialize timelines after DOM is ready with a small delay to ensure Bootstrap tabs are rendered
227
+ setTimeout(function () {
228
+ const byVehiclePanel = document.getElementById("byVehiclePanel");
229
+ const byVisitPanel = document.getElementById("byVisitPanel");
230
+
231
+ if (byVehiclePanel) {
232
+ byVehicleTimeline = new vis.Timeline(
233
+ byVehiclePanel,
234
+ byVehicleItemData,
235
+ byVehicleGroupData,
236
+ byVehicleTimelineOptions,
237
+ );
238
+ }
239
+
240
+ if (byVisitPanel) {
241
+ byVisitTimeline = new vis.Timeline(
242
+ byVisitPanel,
243
+ byVisitItemData,
244
+ byVisitGroupData,
245
+ byVisitTimelineOptions,
246
+ );
247
+ }
248
+ }, 100);
249
+
250
+ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
251
+ maxZoom: 19,
252
+ attribution:
253
+ '&copy; <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors',
254
+ }).addTo(map);
255
+
256
+ solveButton.click(solve);
257
+ stopSolvingButton.click(stopSolving);
258
+ analyzeButton.click(analyze);
259
+ refreshSolvingButtons(false);
260
+
261
+ // HACK to allow vis-timeline to work within Bootstrap tabs
262
+ $("#byVehicleTab").on("shown.bs.tab", function (event) {
263
+ if (byVehicleTimeline) {
264
+ byVehicleTimeline.redraw();
265
+ }
266
+ });
267
+ $("#byVisitTab").on("shown.bs.tab", function (event) {
268
+ if (byVisitTimeline) {
269
+ byVisitTimeline.redraw();
270
+ }
271
+ });
272
+
273
+ // Map click handler - context aware
274
+ map.on("click", function (e) {
275
+ if (addingVehicleMode) {
276
+ // Set vehicle home location
277
+ setVehicleHomeLocation(e.latlng.lat, e.latlng.lng);
278
+ } else if (!optimizing) {
279
+ // Add new visit
280
+ visitMarker = L.circleMarker(e.latlng);
281
+ visitMarker.setStyle({ color: "green" });
282
+ visitMarker.addTo(map);
283
+ openRecommendationModal(e.latlng.lat, e.latlng.lng);
284
+ }
285
+ });
286
+
287
+ // Remove visit marker when modal closes
288
+ $("#newVisitModal").on("hidden.bs.modal", function () {
289
+ if (visitMarker) {
290
+ map.removeLayer(visitMarker);
291
+ }
292
+ });
293
+
294
+ // Vehicle management
295
+ $("#addVehicleBtn").click(openAddVehicleModal);
296
+ $("#removeVehicleBtn").click(removeLastVehicle);
297
+ $("#confirmAddVehicle").click(confirmAddVehicle);
298
+ $("#pickLocationBtn").click(pickVehicleLocationOnMap);
299
+
300
+ // Clean up when add vehicle modal closes (only if not picking location)
301
+ $("#addVehicleModal").on("hidden.bs.modal", function () {
302
+ if (!pickingVehicleLocation) {
303
+ addingVehicleMode = false;
304
+ if (tempVehicleMarker) {
305
+ map.removeLayer(tempVehicleMarker);
306
+ tempVehicleMarker = null;
307
+ }
308
+ }
309
+ });
310
+
311
+ // Real Roads toggle handler
312
+ $(document).on('change', '#realRoadRouting', function() {
313
+ useRealRoads = $(this).is(':checked');
314
+
315
+ // If we have a demo dataset loaded, reload it with the new routing mode
316
+ if (demoDataId && !optimizing) {
317
+ scheduleId = null;
318
+ initialized = false;
319
+ homeLocationGroup.clearLayers();
320
+ homeLocationMarkerByIdMap.clear();
321
+ visitGroup.clearLayers();
322
+ visitMarkerByIdMap.clear();
323
+ routeGeometries = null;
324
+ refreshRoutePlan();
325
+ }
326
+ });
327
+
328
+ setupAjax();
329
+ fetchDemoData();
330
+ });
331
+
332
+ /*************************************** Vehicle Management **************************************/
333
+
334
+ function openAddVehicleModal() {
335
+ if (optimizing) {
336
+ alert("Cannot add vehicles while solving. Please stop solving first.");
337
+ return;
338
+ }
339
+ if (!loadedRoutePlan) {
340
+ alert("Please load a dataset first.");
341
+ return;
342
+ }
343
+
344
+ addingVehicleMode = true;
345
+
346
+ // Suggest next vehicle name
347
+ $("#vehicleName").val("").attr("placeholder", `e.g., ${getNextVehicleName()}`);
348
+
349
+ // Set default values based on existing vehicles
350
+ const existingVehicle = loadedRoutePlan.vehicles[0];
351
+ if (existingVehicle) {
352
+ $("#vehicleCapacity").val(existingVehicle.capacity || 25);
353
+ const defaultLat = existingVehicle.homeLocation[0];
354
+ const defaultLng = existingVehicle.homeLocation[1];
355
+ $("#vehicleHomeLat").val(defaultLat.toFixed(6));
356
+ $("#vehicleHomeLng").val(defaultLng.toFixed(6));
357
+ }
358
+
359
+ // Initialize departure time picker
360
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
361
+ const defaultDeparture = tomorrow.atTime(JSJoda.LocalTime.of(6, 0));
362
+
363
+ if (vehicleDeparturePicker) {
364
+ vehicleDeparturePicker.destroy();
365
+ }
366
+ vehicleDeparturePicker = flatpickr("#vehicleDepartureTime", {
367
+ enableTime: true,
368
+ dateFormat: "Y-m-d H:i",
369
+ defaultDate: defaultDeparture.format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
370
+ });
371
+
372
+ $("#addVehicleModal").modal("show");
373
+ }
374
+
375
+ function pickVehicleLocationOnMap() {
376
+ // Hide modal temporarily while user picks location
377
+ pickingVehicleLocation = true;
378
+ addingVehicleMode = true;
379
+ $("#addVehicleModal").modal("hide");
380
+
381
+ // Show hint on map
382
+ $("#mapHint").html('<i class="fas fa-crosshairs"></i> Click on the map to set vehicle depot location').removeClass("hidden");
383
+ }
384
+
385
+ function setVehicleHomeLocation(lat, lng) {
386
+ $("#vehicleHomeLat").val(lat.toFixed(6));
387
+ $("#vehicleHomeLng").val(lng.toFixed(6));
388
+ $("#vehicleLocationPreview").html(`<i class="fas fa-check text-success"></i> Location set: ${lat.toFixed(4)}, ${lng.toFixed(4)}`);
389
+
390
+ // Show temporary marker
391
+ if (tempVehicleMarker) {
392
+ map.removeLayer(tempVehicleMarker);
393
+ }
394
+ tempVehicleMarker = L.marker([lat, lng], {
395
+ icon: L.divIcon({
396
+ className: 'temp-vehicle-marker',
397
+ html: `<div style="
398
+ background-color: #6366f1;
399
+ border: 3px solid white;
400
+ border-radius: 4px;
401
+ width: 28px;
402
+ height: 28px;
403
+ display: flex;
404
+ align-items: center;
405
+ justify-content: center;
406
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
407
+ animation: pulse 1s infinite;
408
+ "><i class="fas fa-warehouse" style="color: white; font-size: 12px;"></i></div>`,
409
+ iconSize: [28, 28],
410
+ iconAnchor: [14, 14]
411
+ })
412
+ });
413
+ tempVehicleMarker.addTo(map);
414
+
415
+ // If we were picking location, re-open the modal
416
+ if (pickingVehicleLocation) {
417
+ pickingVehicleLocation = false;
418
+ addingVehicleMode = false;
419
+ $("#addVehicleModal").modal("show");
420
+ // Restore normal map hint
421
+ $("#mapHint").html('<i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit');
422
+ }
423
+ }
424
+
425
+ // Extended phonetic alphabet for generating vehicle names
426
+ const PHONETIC_NAMES = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot", "Golf", "Hotel", "India", "Juliet", "Kilo", "Lima", "Mike", "November", "Oscar", "Papa", "Quebec", "Romeo", "Sierra", "Tango", "Uniform", "Victor", "Whiskey", "X-ray", "Yankee", "Zulu"];
427
+
428
+ function getNextVehicleName() {
429
+ if (!loadedRoutePlan) return "Alpha";
430
+ const usedNames = new Set(loadedRoutePlan.vehicles.map(v => v.name));
431
+ for (const name of PHONETIC_NAMES) {
432
+ if (!usedNames.has(name)) return name;
433
+ }
434
+ // Fallback if all names used
435
+ return `Vehicle ${loadedRoutePlan.vehicles.length + 1}`;
436
+ }
437
+
438
+ async function confirmAddVehicle() {
439
+ const vehicleName = $("#vehicleName").val().trim() || getNextVehicleName();
440
+ const capacity = parseInt($("#vehicleCapacity").val());
441
+ const lat = parseFloat($("#vehicleHomeLat").val());
442
+ const lng = parseFloat($("#vehicleHomeLng").val());
443
+ const departureTime = $("#vehicleDepartureTime").val();
444
+
445
+ if (!capacity || capacity < 1) {
446
+ alert("Please enter a valid capacity (minimum 1).");
447
+ return;
448
+ }
449
+ if (isNaN(lat) || isNaN(lng)) {
450
+ alert("Please set a valid home location by clicking on the map or entering coordinates.");
451
+ return;
452
+ }
453
+ if (!departureTime) {
454
+ alert("Please set a departure time.");
455
+ return;
456
+ }
457
+
458
+ // Generate new vehicle ID
459
+ const maxId = Math.max(...loadedRoutePlan.vehicles.map(v => parseInt(v.id)), 0);
460
+ const newId = String(maxId + 1);
461
+
462
+ // Format departure time
463
+ const formattedDeparture = JSJoda.LocalDateTime.parse(
464
+ departureTime,
465
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
466
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
467
+
468
+ // Create new vehicle
469
+ const newVehicle = {
470
+ id: newId,
471
+ name: vehicleName,
472
+ capacity: capacity,
473
+ homeLocation: [lat, lng],
474
+ departureTime: formattedDeparture,
475
+ visits: [],
476
+ totalDemand: 0,
477
+ totalDrivingTimeSeconds: 0,
478
+ arrivalTime: formattedDeparture
479
+ };
480
+
481
+ // Add to solution
482
+ loadedRoutePlan.vehicles.push(newVehicle);
483
+
484
+ // Close modal and refresh
485
+ $("#addVehicleModal").modal("hide");
486
+ addingVehicleMode = false;
487
+
488
+ if (tempVehicleMarker) {
489
+ map.removeLayer(tempVehicleMarker);
490
+ tempVehicleMarker = null;
491
+ }
492
+
493
+ // Refresh display
494
+ await renderRoutes(loadedRoutePlan);
495
+ renderTimelines(loadedRoutePlan);
496
+
497
+ showNotification(`Vehicle "${vehicleName}" added successfully!`, "success");
498
+ }
499
+
500
+ async function removeLastVehicle() {
501
+ if (optimizing) {
502
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
503
+ return;
504
+ }
505
+ if (!loadedRoutePlan || loadedRoutePlan.vehicles.length <= 1) {
506
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
507
+ return;
508
+ }
509
+
510
+ const lastVehicle = loadedRoutePlan.vehicles[loadedRoutePlan.vehicles.length - 1];
511
+
512
+ if (lastVehicle.visits && lastVehicle.visits.length > 0) {
513
+ if (!confirm(`Vehicle ${lastVehicle.id} has ${lastVehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
514
+ return;
515
+ }
516
+ // Unassign visits from the vehicle
517
+ lastVehicle.visits.forEach(visitId => {
518
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
519
+ if (visit) {
520
+ visit.vehicle = null;
521
+ visit.previousVisit = null;
522
+ visit.nextVisit = null;
523
+ visit.arrivalTime = null;
524
+ visit.departureTime = null;
525
+ }
526
+ });
527
+ }
528
+
529
+ // Remove vehicle
530
+ loadedRoutePlan.vehicles.pop();
531
+
532
+ // Remove marker
533
+ const marker = homeLocationMarkerByIdMap.get(lastVehicle.id);
534
+ if (marker) {
535
+ homeLocationGroup.removeLayer(marker);
536
+ homeLocationMarkerByIdMap.delete(lastVehicle.id);
537
+ }
538
+
539
+ // Refresh display
540
+ await renderRoutes(loadedRoutePlan);
541
+ renderTimelines(loadedRoutePlan);
542
+
543
+ showNotification(`Vehicle "${lastVehicle.name || lastVehicle.id}" removed.`, "info");
544
+ }
545
+
546
+ async function removeVehicle(vehicleId) {
547
+ if (optimizing) {
548
+ alert("Cannot remove vehicles while solving. Please stop solving first.");
549
+ return;
550
+ }
551
+
552
+ const vehicleIndex = loadedRoutePlan.vehicles.findIndex(v => v.id === vehicleId);
553
+ if (vehicleIndex === -1) return;
554
+
555
+ if (loadedRoutePlan.vehicles.length <= 1) {
556
+ alert("Cannot remove the last vehicle. At least one vehicle is required.");
557
+ return;
558
+ }
559
+
560
+ const vehicle = loadedRoutePlan.vehicles[vehicleIndex];
561
+
562
+ if (vehicle.visits && vehicle.visits.length > 0) {
563
+ if (!confirm(`Vehicle ${vehicle.id} has ${vehicle.visits.length} assigned visits. These will become unassigned. Continue?`)) {
564
+ return;
565
+ }
566
+ // Unassign visits
567
+ vehicle.visits.forEach(visitId => {
568
+ const visit = loadedRoutePlan.visits.find(v => v.id === visitId);
569
+ if (visit) {
570
+ visit.vehicle = null;
571
+ visit.previousVisit = null;
572
+ visit.nextVisit = null;
573
+ visit.arrivalTime = null;
574
+ visit.departureTime = null;
575
+ }
576
+ });
577
+ }
578
+
579
+ // Remove vehicle
580
+ loadedRoutePlan.vehicles.splice(vehicleIndex, 1);
581
+
582
+ // Remove marker
583
+ const marker = homeLocationMarkerByIdMap.get(vehicleId);
584
+ if (marker) {
585
+ homeLocationGroup.removeLayer(marker);
586
+ homeLocationMarkerByIdMap.delete(vehicleId);
587
+ }
588
+
589
+ // Refresh display
590
+ await renderRoutes(loadedRoutePlan);
591
+ renderTimelines(loadedRoutePlan);
592
+
593
+ showNotification(`Vehicle "${vehicle.name || vehicleId}" removed.`, "info");
594
+ }
595
+
596
+ function showNotification(message, type = "info") {
597
+ const alertClass = type === "success" ? "alert-success" : type === "error" ? "alert-danger" : "alert-info";
598
+ const icon = type === "success" ? "fa-check-circle" : type === "error" ? "fa-exclamation-circle" : "fa-info-circle";
599
+
600
+ const notification = $(`
601
+ <div class="alert ${alertClass} alert-dismissible fade show" role="alert" style="min-width: 300px;">
602
+ <i class="fas ${icon} me-2"></i>${message}
603
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
604
+ </div>
605
+ `);
606
+
607
+ $("#notificationPanel").append(notification);
608
+
609
+ // Auto-dismiss after 3 seconds
610
+ setTimeout(() => {
611
+ notification.alert('close');
612
+ }, 3000);
613
+ }
614
+
615
+ /*************************************** Route Highlighting **************************************/
616
+
617
+ function toggleVehicleHighlight(vehicleId) {
618
+ if (highlightedVehicleId === vehicleId) {
619
+ // Already highlighted - clear it
620
+ clearRouteHighlight();
621
+ } else {
622
+ // Highlight this vehicle's route
623
+ highlightVehicleRoute(vehicleId);
624
+ }
625
+ }
626
+
627
+ function clearRouteHighlight() {
628
+ // Remove number markers
629
+ routeNumberMarkers.forEach(marker => map.removeLayer(marker));
630
+ routeNumberMarkers = [];
631
+
632
+ // Reset all vehicle icons to normal and restore opacity
633
+ if (loadedRoutePlan) {
634
+ loadedRoutePlan.vehicles.forEach(vehicle => {
635
+ const marker = homeLocationMarkerByIdMap.get(vehicle.id);
636
+ if (marker) {
637
+ marker.setIcon(createVehicleHomeIcon(vehicle, false));
638
+ marker.setOpacity(1);
639
+ }
640
+ });
641
+
642
+ // Reset all visit markers to normal and restore opacity
643
+ loadedRoutePlan.visits.forEach(visit => {
644
+ const marker = visitMarkerByIdMap.get(visit.id);
645
+ if (marker) {
646
+ const customerType = getCustomerType(visit);
647
+ const isAssigned = visit.vehicle != null;
648
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned, false));
649
+ marker.setOpacity(1);
650
+ }
651
+ });
652
+ }
653
+
654
+ // Reset route lines
655
+ renderRouteLines();
656
+
657
+ // Update vehicle table highlighting
658
+ $("#vehicles tr").removeClass("table-active");
659
+
660
+ highlightedVehicleId = null;
661
+ }
662
+
663
+ function highlightVehicleRoute(vehicleId) {
664
+ // Clear any existing highlight first
665
+ clearRouteHighlight();
666
+
667
+ highlightedVehicleId = vehicleId;
668
+
669
+ if (!loadedRoutePlan) return;
670
+
671
+ const vehicle = loadedRoutePlan.vehicles.find(v => v.id === vehicleId);
672
+ if (!vehicle) return;
673
+
674
+ const vehicleColor = colorByVehicle(vehicle);
675
+
676
+ // Highlight the vehicle's home marker
677
+ const homeMarker = homeLocationMarkerByIdMap.get(vehicleId);
678
+ if (homeMarker) {
679
+ homeMarker.setIcon(createVehicleHomeIcon(vehicle, true));
680
+ }
681
+
682
+ // Dim other vehicles
683
+ loadedRoutePlan.vehicles.forEach(v => {
684
+ if (v.id !== vehicleId) {
685
+ const marker = homeLocationMarkerByIdMap.get(v.id);
686
+ if (marker) {
687
+ marker.setIcon(createVehicleHomeIcon(v, false));
688
+ marker.setOpacity(0.3);
689
+ }
690
+ }
691
+ });
692
+
693
+ // Get visit order for this vehicle
694
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(v => [v.id, v]));
695
+ const vehicleVisits = vehicle.visits.map(visitId => visitByIdMap.get(visitId)).filter(v => v);
696
+
697
+ // Highlight and number the visits on this route
698
+ let stopNumber = 1;
699
+ vehicleVisits.forEach(visit => {
700
+ const marker = visitMarkerByIdMap.get(visit.id);
701
+ if (marker) {
702
+ const customerType = getCustomerType(visit);
703
+ marker.setIcon(createCustomerTypeIcon(customerType, true, true, vehicleColor));
704
+ marker.setOpacity(1);
705
+
706
+ // Add number marker
707
+ const numberMarker = L.marker(visit.location, {
708
+ icon: createRouteNumberIcon(stopNumber, vehicleColor),
709
+ interactive: false,
710
+ zIndexOffset: 1000
711
+ });
712
+ numberMarker.addTo(map);
713
+ routeNumberMarkers.push(numberMarker);
714
+ stopNumber++;
715
+ }
716
+ });
717
+
718
+ // Dim visits not on this route
719
+ loadedRoutePlan.visits.forEach(visit => {
720
+ if (!vehicle.visits.includes(visit.id)) {
721
+ const marker = visitMarkerByIdMap.get(visit.id);
722
+ if (marker) {
723
+ marker.setOpacity(0.25);
724
+ }
725
+ }
726
+ });
727
+
728
+ // Highlight just this route, dim others
729
+ renderRouteLines(vehicleId);
730
+
731
+ // Highlight the row in the vehicle table
732
+ $("#vehicles tr").removeClass("table-active");
733
+ $(`#vehicle-row-${vehicleId}`).addClass("table-active");
734
+
735
+ // Add start marker (S) at depot
736
+ const startMarker = L.marker(vehicle.homeLocation, {
737
+ icon: createRouteNumberIcon("S", vehicleColor),
738
+ interactive: false,
739
+ zIndexOffset: 1000
740
+ });
741
+ startMarker.addTo(map);
742
+ routeNumberMarkers.push(startMarker);
743
+ }
744
+
745
+ function createRouteNumberIcon(number, color) {
746
+ return L.divIcon({
747
+ className: 'route-number-marker',
748
+ html: `<div style="
749
+ background-color: ${color};
750
+ color: white;
751
+ font-weight: bold;
752
+ font-size: 12px;
753
+ width: 22px;
754
+ height: 22px;
755
+ border-radius: 50%;
756
+ border: 2px solid white;
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: center;
760
+ box-shadow: 0 2px 4px rgba(0,0,0,0.4);
761
+ margin-left: 16px;
762
+ margin-top: -28px;
763
+ ">${number}</div>`,
764
+ iconSize: [22, 22],
765
+ iconAnchor: [0, 0]
766
+ });
767
+ }
768
+
769
+ async function renderRouteLines(highlightedId = null) {
770
+ routeGroup.clearLayers();
771
+
772
+ if (!loadedRoutePlan) return;
773
+
774
+ // Fetch geometries during solving (routes change)
775
+ if (scheduleId) {
776
+ routeGeometries = await fetchRouteGeometries();
777
+ }
778
+
779
+ const visitByIdMap = new Map(loadedRoutePlan.visits.map(visit => [visit.id, visit]));
780
+
781
+ for (let vehicle of loadedRoutePlan.vehicles) {
782
+ const homeLocation = vehicle.homeLocation;
783
+ const locations = vehicle.visits.map(visitId => visitByIdMap.get(visitId)?.location).filter(l => l);
784
+
785
+ const isHighlighted = highlightedId === null || vehicle.id === highlightedId;
786
+ const color = colorByVehicle(vehicle);
787
+ const weight = isHighlighted && highlightedId !== null ? 5 : 3;
788
+ const opacity = isHighlighted ? 1 : 0.2;
789
+
790
+ const vehicleGeometry = routeGeometries?.[vehicle.id];
791
+
792
+ if (vehicleGeometry && vehicleGeometry.length > 0) {
793
+ // Draw real road routes using decoded polylines
794
+ for (const encodedSegment of vehicleGeometry) {
795
+ if (encodedSegment) {
796
+ const points = decodePolyline(encodedSegment);
797
+ if (points.length > 0) {
798
+ L.polyline(points, {
799
+ color: color,
800
+ weight: weight,
801
+ opacity: opacity
802
+ }).addTo(routeGroup);
803
+ }
804
+ }
805
+ }
806
+ } else if (locations.length > 0) {
807
+ // Fallback to straight lines if no geometry available
808
+ L.polyline([homeLocation, ...locations, homeLocation], {
809
+ color: color,
810
+ weight: weight,
811
+ opacity: opacity
812
+ }).addTo(routeGroup);
813
+ }
814
+ }
815
+ }
816
+
817
+ function colorByVehicle(vehicle) {
818
+ return vehicle === null ? null : pickColor("vehicle" + vehicle.id);
819
+ }
820
+
821
+ // Customer type definitions matching demo_data.py
822
+ const CUSTOMER_TYPES = {
823
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minService: 20, maxService: 40 },
824
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minService: 15, maxService: 30 },
825
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minService: 5, maxService: 10 },
826
+ };
827
+
828
+ function getCustomerType(visit) {
829
+ const startTime = showTimeOnly(visit.minStartTime).toString();
830
+ const endTime = showTimeOnly(visit.maxEndTime).toString();
831
+
832
+ for (const [type, config] of Object.entries(CUSTOMER_TYPES)) {
833
+ if (startTime === config.windowStart && endTime === config.windowEnd) {
834
+ return { type, ...config };
835
+ }
836
+ }
837
+ return { type: "UNKNOWN", label: "Custom", icon: "fa-question", color: "#6b7280", windowStart: startTime, windowEnd: endTime };
838
+ }
839
+
840
+ function formatDrivingTime(drivingTimeInSeconds) {
841
+ return `${Math.floor(drivingTimeInSeconds / 3600)}h ${Math.round((drivingTimeInSeconds % 3600) / 60)}m`;
842
+ }
843
+
844
+ function homeLocationPopupContent(vehicle) {
845
+ const color = colorByVehicle(vehicle);
846
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
847
+ const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
848
+ return `<div style="min-width: 150px;">
849
+ <h5 style="color: ${color};"><i class="fas fa-truck"></i> ${vehicleName}</h5>
850
+ <p class="mb-1"><strong>Depot Location</strong></p>
851
+ <p class="mb-1"><i class="fas fa-box"></i> Capacity: ${vehicle.capacity}</p>
852
+ <p class="mb-1"><i class="fas fa-route"></i> Visits: ${visitCount}</p>
853
+ <p class="mb-0"><i class="fas fa-clock"></i> Departs: ${showTimeOnly(vehicle.departureTime)}</p>
854
+ </div>`;
855
+ }
856
+
857
+ function visitPopupContent(visit) {
858
+ const customerType = getCustomerType(visit);
859
+ const serviceDurationMinutes = Math.round(visit.serviceDuration / 60);
860
+ const arrival = visit.arrivalTime
861
+ ? `<h6>Arrival at ${showTimeOnly(visit.arrivalTime)}.</h6>`
862
+ : "";
863
+ return `<h5><i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}</h5>
864
+ <h6><span class="badge" style="background-color: ${customerType.color}">${customerType.label}</span></h6>
865
+ <h6>Cargo: ${visit.demand} units</h6>
866
+ <h6>Service time: ${serviceDurationMinutes} min</h6>
867
+ <h6>Window: ${showTimeOnly(visit.minStartTime)} - ${showTimeOnly(visit.maxEndTime)}</h6>
868
+ ${arrival}`;
869
+ }
870
+
871
+ function showTimeOnly(localDateTimeString) {
872
+ return JSJoda.LocalDateTime.parse(localDateTimeString).toLocalTime();
873
+ }
874
+
875
+ function createVehicleHomeIcon(vehicle, isHighlighted = false) {
876
+ const color = colorByVehicle(vehicle);
877
+ const size = isHighlighted ? 36 : 28;
878
+ const fontSize = isHighlighted ? 14 : 11;
879
+ const borderWidth = isHighlighted ? 4 : 3;
880
+ const shadow = isHighlighted
881
+ ? `0 0 0 4px ${color}40, 0 4px 8px rgba(0,0,0,0.5)`
882
+ : '0 2px 4px rgba(0,0,0,0.4)';
883
+
884
+ return L.divIcon({
885
+ className: 'vehicle-home-marker',
886
+ html: `<div style="
887
+ background-color: ${color};
888
+ border: ${borderWidth}px solid white;
889
+ border-radius: 50%;
890
+ width: ${size}px;
891
+ height: ${size}px;
892
+ display: flex;
893
+ align-items: center;
894
+ justify-content: center;
895
+ box-shadow: ${shadow};
896
+ transition: all 0.2s ease;
897
+ "><i class="fas fa-truck" style="color: white; font-size: ${fontSize}px;"></i></div>`,
898
+ iconSize: [size, size],
899
+ iconAnchor: [size/2, size/2],
900
+ popupAnchor: [0, -size/2]
901
+ });
902
+ }
903
+
904
+ function getHomeLocationMarker(vehicle) {
905
+ let marker = homeLocationMarkerByIdMap.get(vehicle.id);
906
+ if (marker) {
907
+ marker.setIcon(createVehicleHomeIcon(vehicle));
908
+ return marker;
909
+ }
910
+ marker = L.marker(vehicle.homeLocation, {
911
+ icon: createVehicleHomeIcon(vehicle)
912
+ });
913
+ marker.addTo(homeLocationGroup).bindPopup();
914
+ homeLocationMarkerByIdMap.set(vehicle.id, marker);
915
+ return marker;
916
+ }
917
+
918
+ function createCustomerTypeIcon(customerType, isAssigned = false, isHighlighted = false, highlightColor = null) {
919
+ const borderColor = isHighlighted && highlightColor
920
+ ? highlightColor
921
+ : (isAssigned ? customerType.color : '#6b7280');
922
+ const size = isHighlighted ? 38 : 32;
923
+ const fontSize = isHighlighted ? 16 : 14;
924
+ const borderWidth = isHighlighted ? 4 : 3;
925
+ const shadow = isHighlighted
926
+ ? `0 0 0 4px ${highlightColor}40, 0 4px 8px rgba(0,0,0,0.4)`
927
+ : '0 2px 4px rgba(0,0,0,0.3)';
928
+
929
+ return L.divIcon({
930
+ className: 'customer-marker',
931
+ html: `<div style="
932
+ background-color: white;
933
+ border: ${borderWidth}px solid ${borderColor};
934
+ border-radius: 50%;
935
+ width: ${size}px;
936
+ height: ${size}px;
937
+ display: flex;
938
+ align-items: center;
939
+ justify-content: center;
940
+ box-shadow: ${shadow};
941
+ transition: all 0.2s ease;
942
+ "><i class="fas ${customerType.icon}" style="color: ${customerType.color}; font-size: ${fontSize}px;"></i></div>`,
943
+ iconSize: [size, size],
944
+ iconAnchor: [size/2, size/2],
945
+ popupAnchor: [0, -size/2]
946
+ });
947
+ }
948
+
949
+ function getVisitMarker(visit) {
950
+ let marker = visitMarkerByIdMap.get(visit.id);
951
+ const customerType = getCustomerType(visit);
952
+ const isAssigned = visit.vehicle != null;
953
+
954
+ if (marker) {
955
+ // Update icon if assignment status changed
956
+ marker.setIcon(createCustomerTypeIcon(customerType, isAssigned));
957
+ return marker;
958
+ }
959
+
960
+ marker = L.marker(visit.location, {
961
+ icon: createCustomerTypeIcon(customerType, isAssigned)
962
+ });
963
+ marker.addTo(visitGroup).bindPopup();
964
+ visitMarkerByIdMap.set(visit.id, marker);
965
+ return marker;
966
+ }
967
+
968
+ async function renderRoutes(solution) {
969
+ if (!initialized) {
970
+ const bounds = [solution.southWestCorner, solution.northEastCorner];
971
+ map.fitBounds(bounds);
972
+ }
973
+ // Vehicles
974
+ vehiclesTable.children().remove();
975
+ const canRemove = solution.vehicles.length > 1;
976
+ solution.vehicles.forEach(function (vehicle) {
977
+ getHomeLocationMarker(vehicle).setPopupContent(
978
+ homeLocationPopupContent(vehicle),
979
+ );
980
+ const { id, capacity, totalDemand, totalDrivingTimeSeconds } = vehicle;
981
+ const percentage = Math.min((totalDemand / capacity) * 100, 100);
982
+ const overCapacity = totalDemand > capacity;
983
+ const color = colorByVehicle(vehicle);
984
+ const progressBarColor = overCapacity ? 'bg-danger' : '';
985
+ const isHighlighted = highlightedVehicleId === id;
986
+ const visitCount = vehicle.visits ? vehicle.visits.length : 0;
987
+ const vehicleName = vehicle.name || `Vehicle ${id}`;
988
+
989
+ vehiclesTable.append(`
990
+ <tr id="vehicle-row-${id}" class="vehicle-row ${isHighlighted ? 'table-active' : ''}" style="cursor: pointer;">
991
+ <td onclick="toggleVehicleHighlight('${id}')">
992
+ <div style="background-color: ${color}; width: 1.5rem; height: 1.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; ${isHighlighted ? 'box-shadow: 0 0 0 3px ' + color + '40;' : ''}">
993
+ <i class="fas fa-truck" style="color: white; font-size: 0.65rem;"></i>
994
+ </div>
995
+ </td>
996
+ <td onclick="toggleVehicleHighlight('${id}')">
997
+ <strong>${vehicleName}</strong>
998
+ <br><small class="text-muted">${visitCount} stops</small>
999
+ </td>
1000
+ <td onclick="toggleVehicleHighlight('${id}')">
1001
+ <div class="progress" style="height: 18px;" data-bs-toggle="tooltip" data-bs-placement="left"
1002
+ title="Cargo: ${totalDemand} / Capacity: ${capacity}${overCapacity ? ' (OVER CAPACITY!)' : ''}">
1003
+ <div class="progress-bar ${progressBarColor}" role="progressbar" style="width: ${percentage}%; font-size: 0.7rem; transition: width 0.3s ease;">
1004
+ ${totalDemand}/${capacity}
1005
+ </div>
1006
+ </div>
1007
+ </td>
1008
+ <td onclick="toggleVehicleHighlight('${id}')" style="font-size: 0.85rem;">
1009
+ ${formatDrivingTime(totalDrivingTimeSeconds)}
1010
+ </td>
1011
+ <td>
1012
+ ${canRemove ? `<button class="btn btn-sm btn-outline-danger p-0 px-1" onclick="event.stopPropagation(); removeVehicle('${id}')" title="Remove vehicle ${vehicleName}">
1013
+ <i class="fas fa-times" style="font-size: 0.7rem;"></i>
1014
+ </button>` : ''}
1015
+ </td>
1016
+ </tr>`);
1017
+ });
1018
+ // Visits
1019
+ solution.visits.forEach(function (visit) {
1020
+ getVisitMarker(visit).setPopupContent(visitPopupContent(visit));
1021
+ });
1022
+ // Route - use the dedicated function which handles highlighting (await to ensure geometries load)
1023
+ await renderRouteLines(highlightedVehicleId);
1024
+
1025
+ // Summary
1026
+ $("#score").text(solution.score ? `Score: ${solution.score}` : "Score: ?");
1027
+ $("#drivingTime").text(formatDrivingTime(solution.totalDrivingTimeSeconds));
1028
+ }
1029
+
1030
+ function renderTimelines(routePlan) {
1031
+ byVehicleGroupData.clear();
1032
+ byVisitGroupData.clear();
1033
+ byVehicleItemData.clear();
1034
+ byVisitItemData.clear();
1035
+
1036
+ // Build lookup maps for O(1) access
1037
+ const vehicleById = new Map(routePlan.vehicles.map(v => [v.id, v]));
1038
+ const visitById = new Map(routePlan.visits.map(v => [v.id, v]));
1039
+ const visitOrderMap = new Map();
1040
+
1041
+ // Build stop order for each visit
1042
+ routePlan.vehicles.forEach(vehicle => {
1043
+ vehicle.visits.forEach((visitId, index) => {
1044
+ visitOrderMap.set(visitId, index + 1);
1045
+ });
1046
+ });
1047
+
1048
+ // Vehicle groups with names and status summary
1049
+ $.each(routePlan.vehicles, function (index, vehicle) {
1050
+ const vehicleName = vehicle.name || `Vehicle ${vehicle.id}`;
1051
+ const { totalDemand, capacity } = vehicle;
1052
+ const percentage = Math.min((totalDemand / capacity) * 100, 100);
1053
+ const overCapacity = totalDemand > capacity;
1054
+
1055
+ // Count late visits for this vehicle
1056
+ const vehicleVisits = vehicle.visits.map(id => visitById.get(id)).filter(v => v);
1057
+ const lateCount = vehicleVisits.filter(v => {
1058
+ if (!v.departureTime) return false;
1059
+ const departure = JSJoda.LocalDateTime.parse(v.departureTime);
1060
+ const maxEnd = JSJoda.LocalDateTime.parse(v.maxEndTime);
1061
+ return departure.isAfter(maxEnd);
1062
+ }).length;
1063
+
1064
+ const statusIcon = lateCount > 0
1065
+ ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="${lateCount} late"></i>`
1066
+ : vehicle.visits.length > 0
1067
+ ? `<i class="fas fa-check-circle timeline-status-ontime timeline-status-icon" title="All on-time"></i>`
1068
+ : '';
1069
+
1070
+ const progressBarClass = overCapacity ? 'bg-danger' : '';
1071
+
1072
+ const vehicleWithLoad = `
1073
+ <h5 class="card-title mb-1">${vehicleName}${statusIcon}</h5>
1074
+ <div class="progress" style="height: 16px;" title="Cargo: ${totalDemand} / ${capacity}">
1075
+ <div class="progress-bar ${progressBarClass}" role="progressbar" style="width: ${percentage}%">
1076
+ ${totalDemand}/${capacity}
1077
+ </div>
1078
+ </div>`;
1079
+ byVehicleGroupData.add({ id: vehicle.id, content: vehicleWithLoad });
1080
+ });
1081
+
1082
+ $.each(routePlan.visits, function (index, visit) {
1083
+ const minStartTime = JSJoda.LocalDateTime.parse(visit.minStartTime);
1084
+ const maxEndTime = JSJoda.LocalDateTime.parse(visit.maxEndTime);
1085
+ const serviceDuration = JSJoda.Duration.ofSeconds(visit.serviceDuration);
1086
+ const customerType = getCustomerType(visit);
1087
+ const stopNumber = visitOrderMap.get(visit.id);
1088
+
1089
+ const visitGroupElement = $(`<div/>`).append(
1090
+ $(`<h5 class="card-title mb-1"/>`).html(
1091
+ `<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}`
1092
+ ),
1093
+ ).append(
1094
+ $(`<small class="text-muted"/>`).text(customerType.label)
1095
+ );
1096
+ byVisitGroupData.add({
1097
+ id: visit.id,
1098
+ content: visitGroupElement.html(),
1099
+ });
1100
+
1101
+ // Time window per visit.
1102
+ byVisitItemData.add({
1103
+ id: visit.id + "_readyToDue",
1104
+ group: visit.id,
1105
+ start: visit.minStartTime,
1106
+ end: visit.maxEndTime,
1107
+ type: "background",
1108
+ style: "background-color: #8AE23433",
1109
+ });
1110
+
1111
+ if (visit.vehicle == null) {
1112
+ const byJobJobElement = $(`<div/>`).append(
1113
+ $(`<span/>`).html(`<i class="fas fa-exclamation-circle text-danger me-1"></i>Unassigned`),
1114
+ );
1115
+
1116
+ // Unassigned are shown at the beginning of the visit's time window; the length is the service duration.
1117
+ byVisitItemData.add({
1118
+ id: visit.id + "_unassigned",
1119
+ group: visit.id,
1120
+ content: byJobJobElement.html(),
1121
+ start: minStartTime.toString(),
1122
+ end: minStartTime.plus(serviceDuration).toString(),
1123
+ style: "background-color: #EF292999",
1124
+ });
1125
+ } else {
1126
+ const arrivalTime = JSJoda.LocalDateTime.parse(visit.arrivalTime);
1127
+ const beforeReady = arrivalTime.isBefore(minStartTime);
1128
+ const departureTime = JSJoda.LocalDateTime.parse(visit.departureTime);
1129
+ const afterDue = departureTime.isAfter(maxEndTime);
1130
+
1131
+ // Get vehicle info for display
1132
+ const vehicleInfo = vehicleById.get(visit.vehicle);
1133
+ const vehicleName = vehicleInfo ? (vehicleInfo.name || `Vehicle ${visit.vehicle}`) : `Vehicle ${visit.vehicle}`;
1134
+
1135
+ // Stop badge for service segment
1136
+ const stopBadge = stopNumber ? `<span class="timeline-stop-badge">${stopNumber}</span>` : '';
1137
+
1138
+ // Status icon based on timing
1139
+ const statusIcon = afterDue
1140
+ ? `<i class="fas fa-exclamation-triangle timeline-status-late timeline-status-icon" title="Late"></i>`
1141
+ : `<i class="fas fa-check timeline-status-ontime timeline-status-icon" title="On-time"></i>`;
1142
+
1143
+ const byVehicleElement = $(`<div/>`)
1144
+ .append($(`<span/>`).html(
1145
+ `${stopBadge}<i class="fas ${customerType.icon}" style="color: ${customerType.color}"></i> ${visit.name}${statusIcon}`
1146
+ ));
1147
+
1148
+ const byVisitElement = $(`<div/>`)
1149
+ .append(
1150
+ $(`<span/>`).html(
1151
+ `${stopBadge}${vehicleName}${statusIcon}`
1152
+ ),
1153
+ );
1154
+
1155
+ const byVehicleTravelElement = $(`<div/>`).append(
1156
+ $(`<span/>`).html(`<i class="fas fa-route text-warning me-1"></i>Travel`),
1157
+ );
1158
+
1159
+ const previousDeparture = arrivalTime.minusSeconds(
1160
+ visit.drivingTimeSecondsFromPreviousStandstill,
1161
+ );
1162
+ byVehicleItemData.add({
1163
+ id: visit.id + "_travel",
1164
+ group: visit.vehicle,
1165
+ subgroup: visit.vehicle,
1166
+ content: byVehicleTravelElement.html(),
1167
+ start: previousDeparture.toString(),
1168
+ end: visit.arrivalTime,
1169
+ style: "background-color: #f7dd8f90",
1170
+ });
1171
+
1172
+ if (beforeReady) {
1173
+ const byVehicleWaitElement = $(`<div/>`).append(
1174
+ $(`<span/>`).html(`<i class="fas fa-clock timeline-status-early me-1"></i>Wait`),
1175
+ );
1176
+
1177
+ byVehicleItemData.add({
1178
+ id: visit.id + "_wait",
1179
+ group: visit.vehicle,
1180
+ subgroup: visit.vehicle,
1181
+ content: byVehicleWaitElement.html(),
1182
+ start: visit.arrivalTime,
1183
+ end: visit.minStartTime,
1184
+ style: "background-color: #93c5fd80",
1185
+ });
1186
+ }
1187
+
1188
+ let serviceElementBackground = afterDue ? "#EF292999" : "#83C15955";
1189
+
1190
+ byVehicleItemData.add({
1191
+ id: visit.id + "_service",
1192
+ group: visit.vehicle,
1193
+ subgroup: visit.vehicle,
1194
+ content: byVehicleElement.html(),
1195
+ start: visit.startServiceTime,
1196
+ end: visit.departureTime,
1197
+ style: "background-color: " + serviceElementBackground,
1198
+ });
1199
+ byVisitItemData.add({
1200
+ id: visit.id,
1201
+ group: visit.id,
1202
+ content: byVisitElement.html(),
1203
+ start: visit.startServiceTime,
1204
+ end: visit.departureTime,
1205
+ style: "background-color: " + serviceElementBackground,
1206
+ });
1207
+ }
1208
+ });
1209
+
1210
+ $.each(routePlan.vehicles, function (index, vehicle) {
1211
+ if (vehicle.visits.length > 0) {
1212
+ let lastVisit = routePlan.visits
1213
+ .filter(
1214
+ (visit) => visit.id == vehicle.visits[vehicle.visits.length - 1],
1215
+ )
1216
+ .pop();
1217
+ if (lastVisit) {
1218
+ byVehicleItemData.add({
1219
+ id: vehicle.id + "_travelBackToHomeLocation",
1220
+ group: vehicle.id,
1221
+ subgroup: vehicle.id,
1222
+ content: $(`<div/>`)
1223
+ .append($(`<span/>`).html(`<i class="fas fa-home text-secondary me-1"></i>Return`))
1224
+ .html(),
1225
+ start: lastVisit.departureTime,
1226
+ end: vehicle.arrivalTime,
1227
+ style: "background-color: #f7dd8f90",
1228
+ });
1229
+ }
1230
+ }
1231
+ });
1232
+
1233
+ if (!initialized) {
1234
+ if (byVehicleTimeline) {
1235
+ byVehicleTimeline.setWindow(
1236
+ routePlan.startDateTime,
1237
+ routePlan.endDateTime,
1238
+ );
1239
+ }
1240
+ if (byVisitTimeline) {
1241
+ byVisitTimeline.setWindow(routePlan.startDateTime, routePlan.endDateTime);
1242
+ }
1243
+ }
1244
+ }
1245
+
1246
+ function analyze() {
1247
+ // see score-analysis.js
1248
+ analyzeScore(loadedRoutePlan, "/route-plans/analyze");
1249
+ }
1250
+
1251
+ function openRecommendationModal(lat, lng) {
1252
+ if (!('score' in loadedRoutePlan) || optimizing) {
1253
+ map.removeLayer(visitMarker);
1254
+ visitMarker = null;
1255
+ let message = "Please click the Solve button before adding new visits.";
1256
+ if (optimizing) {
1257
+ message = "Please wait for the solving process to finish.";
1258
+ }
1259
+ alert(message);
1260
+ return;
1261
+ }
1262
+ // see recommended-fit.js
1263
+ const visitId = Math.max(...loadedRoutePlan.visits.map(c => parseInt(c.id))) + 1;
1264
+ newVisit = {id: visitId, location: [lat, lng]};
1265
+ addNewVisit(visitId, lat, lng, map, visitMarker);
1266
+ }
1267
+
1268
+ function getRecommendationsModal() {
1269
+ let formValid = true;
1270
+ formValid = validateFormField(newVisit, 'name', '#inputName') && formValid;
1271
+ formValid = validateFormField(newVisit, 'demand', '#inputDemand') && formValid;
1272
+ formValid = validateFormField(newVisit, 'minStartTime', '#inputMinStartTime') && formValid;
1273
+ formValid = validateFormField(newVisit, 'maxEndTime', '#inputMaxStartTime') && formValid;
1274
+ formValid = validateFormField(newVisit, 'serviceDuration', '#inputDuration') && formValid;
1275
+
1276
+ if (formValid) {
1277
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1278
+ newVisit['minStartTime'],
1279
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1280
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1281
+
1282
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1283
+ newVisit['maxEndTime'],
1284
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1285
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1286
+
1287
+ const updatedVisit = {
1288
+ ...newVisit,
1289
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1290
+ minStartTime: updatedMinStartTime,
1291
+ maxEndTime: updatedMaxEndTime
1292
+ };
1293
+
1294
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1295
+ updatedVisitList.push(updatedVisit);
1296
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1297
+
1298
+ // see recommended-fit.js
1299
+ requestRecommendations(updatedVisit.id, updatedSolution, "/route-plans/recommendation");
1300
+ }
1301
+ }
1302
+
1303
+ function validateFormField(target, fieldName, inputName) {
1304
+ target[fieldName] = $(inputName).val();
1305
+ if ($(inputName).val() == "") {
1306
+ $(inputName).addClass("is-invalid");
1307
+ } else {
1308
+ $(inputName).removeClass("is-invalid");
1309
+ }
1310
+ return $(inputName).val() != "";
1311
+ }
1312
+
1313
+ function applyRecommendationModal(recommendations) {
1314
+ let checkedRecommendation = null;
1315
+ recommendations.forEach((recommendation, index) => {
1316
+ if ($('#option' + index).is(":checked")) {
1317
+ checkedRecommendation = recommendations[index];
1318
+ }
1319
+ });
1320
+
1321
+ if (!checkedRecommendation) {
1322
+ alert("Please select a recommendation.");
1323
+ return;
1324
+ }
1325
+
1326
+ const updatedMinStartTime = JSJoda.LocalDateTime.parse(
1327
+ newVisit['minStartTime'],
1328
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1329
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1330
+
1331
+ const updatedMaxEndTime = JSJoda.LocalDateTime.parse(
1332
+ newVisit['maxEndTime'],
1333
+ JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')
1334
+ ).format(JSJoda.DateTimeFormatter.ISO_LOCAL_DATE_TIME);
1335
+
1336
+ const updatedVisit = {
1337
+ ...newVisit,
1338
+ serviceDuration: parseInt(newVisit['serviceDuration']) * 60, // Convert minutes to seconds
1339
+ minStartTime: updatedMinStartTime,
1340
+ maxEndTime: updatedMaxEndTime
1341
+ };
1342
+
1343
+ let updatedVisitList = [...loadedRoutePlan['visits']];
1344
+ updatedVisitList.push(updatedVisit);
1345
+ let updatedSolution = {...loadedRoutePlan, visits: updatedVisitList};
1346
+
1347
+ // see recommended-fit.js
1348
+ applyRecommendation(
1349
+ updatedSolution,
1350
+ newVisit.id,
1351
+ checkedRecommendation.proposition.vehicleId,
1352
+ checkedRecommendation.proposition.index,
1353
+ "/route-plans/recommendation/apply"
1354
+ );
1355
+ }
1356
+
1357
+ async function updateSolutionWithNewVisit(newSolution) {
1358
+ loadedRoutePlan = newSolution;
1359
+ await renderRoutes(newSolution);
1360
+ renderTimelines(newSolution);
1361
+ $('#newVisitModal').modal('hide');
1362
+ }
1363
+
1364
+ // TODO: move the general functionality to the webjar.
1365
+
1366
+ function setupAjax() {
1367
+ $.ajaxSetup({
1368
+ headers: {
1369
+ "Content-Type": "application/json",
1370
+ Accept: "application/json,text/plain", // plain text is required by solve() returning UUID of the solver job
1371
+ },
1372
+ });
1373
+
1374
+ // Extend jQuery to support $.put() and $.delete()
1375
+ jQuery.each(["put", "delete"], function (i, method) {
1376
+ jQuery[method] = function (url, data, callback, type) {
1377
+ if (jQuery.isFunction(data)) {
1378
+ type = type || callback;
1379
+ callback = data;
1380
+ data = undefined;
1381
+ }
1382
+ return jQuery.ajax({
1383
+ url: url,
1384
+ type: method,
1385
+ dataType: type,
1386
+ data: data,
1387
+ success: callback,
1388
+ });
1389
+ };
1390
+ });
1391
+ }
1392
+
1393
+ function solve() {
1394
+ // Clear geometry cache - will be refreshed when solution updates
1395
+ routeGeometries = null;
1396
+
1397
+ // Disable button immediately to prevent double-clicks
1398
+ $("#solveButton").prop("disabled", true).addClass("disabled");
1399
+
1400
+ $.ajax({
1401
+ url: "/route-plans",
1402
+ type: "POST",
1403
+ data: JSON.stringify(loadedRoutePlan),
1404
+ contentType: "application/json",
1405
+ dataType: "text",
1406
+ success: function (data) {
1407
+ scheduleId = data.replace(/"/g, ""); // Remove quotes from UUID
1408
+ $("#solveButton").prop("disabled", false).removeClass("disabled");
1409
+ refreshSolvingButtons(true);
1410
+ },
1411
+ error: function (xhr, ajaxOptions, thrownError) {
1412
+ showError("Start solving failed.", xhr);
1413
+ $("#solveButton").prop("disabled", false).removeClass("disabled");
1414
+ refreshSolvingButtons(false);
1415
+ },
1416
+ });
1417
+ }
1418
+
1419
+ function refreshSolvingButtons(solving) {
1420
+ optimizing = solving;
1421
+ if (solving) {
1422
+ $("#solveButton").hide();
1423
+ $("#visitButton").hide();
1424
+ $("#stopSolvingButton").show();
1425
+ $("#solvingSpinner").addClass("active");
1426
+ $("#mapHint").addClass("hidden");
1427
+ if (autoRefreshIntervalId == null) {
1428
+ autoRefreshIntervalId = setInterval(refreshRoutePlan, 2000);
1429
+ }
1430
+ } else {
1431
+ $("#solveButton").show();
1432
+ $("#visitButton").show();
1433
+ $("#stopSolvingButton").hide();
1434
+ $("#solvingSpinner").removeClass("active");
1435
+ $("#mapHint").removeClass("hidden");
1436
+ if (autoRefreshIntervalId != null) {
1437
+ clearInterval(autoRefreshIntervalId);
1438
+ autoRefreshIntervalId = null;
1439
+ }
1440
+ }
1441
+ }
1442
+
1443
+ async function refreshRoutePlan() {
1444
+ let path = "/route-plans/" + scheduleId;
1445
+ let isLoadingDemoData = scheduleId === null;
1446
+
1447
+ if (isLoadingDemoData) {
1448
+ if (demoDataId === null) {
1449
+ alert("Please select a test data set.");
1450
+ return;
1451
+ }
1452
+
1453
+ // Clear geometry cache when loading new demo data
1454
+ routeGeometries = null;
1455
+
1456
+ try {
1457
+ let routePlan;
1458
+ if (useRealRoads) {
1459
+ // Use SSE streaming for real roads to show progress
1460
+ routePlan = await loadDemoDataWithProgress(demoDataId);
1461
+ } else {
1462
+ // Use simple GET for haversine (instant, no loading overlay)
1463
+ routePlan = await $.getJSON(`/demo-data/${demoDataId}`);
1464
+ }
1465
+ loadedRoutePlan = routePlan;
1466
+ refreshSolvingButtons(
1467
+ routePlan.solverStatus != null &&
1468
+ routePlan.solverStatus !== "NOT_SOLVING",
1469
+ );
1470
+ await renderRoutes(routePlan);
1471
+ renderTimelines(routePlan);
1472
+ initialized = true;
1473
+ } catch (error) {
1474
+ showError("Getting demo data has failed: " + error.message, {});
1475
+ refreshSolvingButtons(false);
1476
+ }
1477
+ return;
1478
+ }
1479
+
1480
+ // Loading existing route plan (during solving)
1481
+ try {
1482
+ const routePlan = await $.getJSON(path);
1483
+ loadedRoutePlan = routePlan;
1484
+ refreshSolvingButtons(
1485
+ routePlan.solverStatus != null &&
1486
+ routePlan.solverStatus !== "NOT_SOLVING",
1487
+ );
1488
+ await renderRoutes(routePlan);
1489
+ renderTimelines(routePlan);
1490
+ initialized = true;
1491
+ } catch (error) {
1492
+ showError("Getting route plan has failed.", error);
1493
+ refreshSolvingButtons(false);
1494
+ }
1495
+ }
1496
+
1497
+ function stopSolving() {
1498
+ $.delete("/route-plans/" + scheduleId, function () {
1499
+ refreshSolvingButtons(false);
1500
+ refreshRoutePlan();
1501
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1502
+ showError("Stop solving failed.", xhr);
1503
+ });
1504
+ }
1505
+
1506
+ function fetchDemoData() {
1507
+ $.get("/demo-data", function (data) {
1508
+ data.forEach(function (item) {
1509
+ $("#testDataButton").append(
1510
+ $(
1511
+ '<a id="' +
1512
+ item +
1513
+ 'TestData" class="dropdown-item" href="#">' +
1514
+ item +
1515
+ "</a>",
1516
+ ),
1517
+ );
1518
+
1519
+ $("#" + item + "TestData").click(function () {
1520
+ switchDataDropDownItemActive(item);
1521
+ scheduleId = null;
1522
+ demoDataId = item;
1523
+ initialized = false;
1524
+ homeLocationGroup.clearLayers();
1525
+ homeLocationMarkerByIdMap.clear();
1526
+ visitGroup.clearLayers();
1527
+ visitMarkerByIdMap.clear();
1528
+ refreshRoutePlan();
1529
+ });
1530
+ });
1531
+
1532
+ demoDataId = data[0];
1533
+ switchDataDropDownItemActive(demoDataId);
1534
+
1535
+ refreshRoutePlan();
1536
+ }).fail(function (xhr, ajaxOptions, thrownError) {
1537
+ // disable this page as there is no data
1538
+ $("#demo").empty();
1539
+ $("#demo").html(
1540
+ '<h1><p style="justify-content: center">No test data available</p></h1>',
1541
+ );
1542
+ });
1543
+ }
1544
+
1545
+ function switchDataDropDownItemActive(newItem) {
1546
+ activeCssClass = "active";
1547
+ $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
1548
+ $("#" + newItem + "TestData").addClass(activeCssClass);
1549
+ }
1550
+
1551
+ function copyTextToClipboard(id) {
1552
+ var text = $("#" + id)
1553
+ .text()
1554
+ .trim();
1555
+
1556
+ var dummy = document.createElement("textarea");
1557
+ document.body.appendChild(dummy);
1558
+ dummy.value = text;
1559
+ dummy.select();
1560
+ document.execCommand("copy");
1561
+ document.body.removeChild(dummy);
1562
+ }
1563
+
1564
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
1565
+ const solverforgeHeader = $("header#solverforge-auto-header");
1566
+ if (solverforgeHeader != null) {
1567
+ solverforgeHeader.css("background-color", "#ffffff");
1568
+ solverforgeHeader.append(
1569
+ $(`<div class="container-fluid">
1570
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
1571
+ <a class="navbar-brand" href="https://www.solverforge.org">
1572
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
1573
+ </a>
1574
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
1575
+ <span class="navbar-toggler-icon"></span>
1576
+ </button>
1577
+ <div class="collapse navbar-collapse" id="navbarNav">
1578
+ <ul class="nav nav-pills">
1579
+ <li class="nav-item active" id="navUIItem">
1580
+ <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
1581
+ </li>
1582
+ <li class="nav-item" id="navRestItem">
1583
+ <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
1584
+ </li>
1585
+ <li class="nav-item" id="navOpenApiItem">
1586
+ <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
1587
+ </li>
1588
+ </ul>
1589
+ </div>
1590
+ <div class="ms-auto d-flex align-items-center gap-3">
1591
+ <div class="form-check form-switch d-flex align-items-center" data-bs-toggle="tooltip" data-bs-placement="bottom" title="Enable real road routing using OpenStreetMap data. Slower initial load (~5-15s for download), but shows accurate road routes instead of straight lines.">
1592
+ <input class="form-check-input" type="checkbox" id="realRoadRouting" checked style="width: 2.5em; height: 1.25em; cursor: pointer;">
1593
+ <label class="form-check-label ms-2" for="realRoadRouting" style="white-space: nowrap; cursor: pointer;">
1594
+ <i class="fas fa-road"></i> Real Roads
1595
+ </label>
1596
+ </div>
1597
+ <div class="dropdown">
1598
+ <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
1599
+ Data
1600
+ </button>
1601
+ <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
1602
+ </div>
1603
+ </div>
1604
+ </nav>
1605
+ </div>`),
1606
+ );
1607
+ }
1608
+
1609
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
1610
+ if (solverforgeFooter != null) {
1611
+ solverforgeFooter.append(
1612
+ $(`<footer class="bg-black text-white-50">
1613
+ <div class="container">
1614
+ <div class="hstack gap-3 p-4">
1615
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
1616
+ <div class="vr"></div>
1617
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
1618
+ <div class="vr"></div>
1619
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
1620
+ <div class="vr"></div>
1621
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
1622
+ </div>
1623
+ </div>
1624
+ </footer>`),
1625
+ );
1626
+ }
1627
+ }
static/index.html ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7
+ <title>Vehicle Routing - SolverForge for Rust</title>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css">
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
11
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
12
+ integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
13
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
14
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
15
+ <style>
16
+ /* Customer marker icons */
17
+ .customer-marker, .vehicle-home-marker, .temp-vehicle-marker {
18
+ background: transparent !important;
19
+ border: none !important;
20
+ }
21
+
22
+ /* Pulse animation for new vehicle placement */
23
+ @keyframes pulse {
24
+ 0% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
25
+ 50% { transform: scale(1.1); box-shadow: 0 4px 8px rgba(99, 102, 241, 0.6); }
26
+ 100% { transform: scale(1); box-shadow: 0 2px 4px rgba(0,0,0,0.4); }
27
+ }
28
+
29
+ /* Customer type buttons in modal */
30
+ .customer-type-btn {
31
+ transition: all 0.2s ease;
32
+ padding: 0.75rem 0.5rem;
33
+ }
34
+ .customer-type-btn:hover {
35
+ transform: translateY(-2px);
36
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
37
+ }
38
+
39
+ /* Vehicle table styling */
40
+ #vehicles tr.vehicle-row {
41
+ transition: background-color 0.2s ease;
42
+ }
43
+ #vehicles tr.vehicle-row:hover {
44
+ background-color: rgba(99, 102, 241, 0.1);
45
+ }
46
+ #vehicles tr.vehicle-row.table-active {
47
+ background-color: rgba(99, 102, 241, 0.15) !important;
48
+ }
49
+
50
+ /* Route number markers */
51
+ .route-number-marker {
52
+ background: transparent !important;
53
+ border: none !important;
54
+ }
55
+
56
+ /* Notification panel */
57
+ #notificationPanel {
58
+ z-index: 1050;
59
+ }
60
+
61
+ /* Click hint for vehicle rows */
62
+ #vehicles tr.vehicle-row td:not(:last-child) {
63
+ cursor: pointer;
64
+ }
65
+
66
+ /* Solving spinner */
67
+ #solvingSpinner {
68
+ display: none;
69
+ width: 1.25rem;
70
+ height: 1.25rem;
71
+ border: 2px solid #10b981;
72
+ border-top-color: transparent;
73
+ border-radius: 50%;
74
+ animation: spin 0.75s linear infinite;
75
+ vertical-align: middle;
76
+ }
77
+ #solvingSpinner.active {
78
+ display: inline-block;
79
+ }
80
+ @keyframes spin {
81
+ to { transform: rotate(360deg); }
82
+ }
83
+
84
+ /* Progress bar text should stay horizontal and inside */
85
+ .progress-bar {
86
+ overflow: visible;
87
+ white-space: nowrap;
88
+ }
89
+
90
+ /* Map hint overlay */
91
+ .map-hint {
92
+ position: absolute;
93
+ bottom: 20px;
94
+ left: 50%;
95
+ transform: translateX(-50%);
96
+ background-color: rgba(255, 255, 255, 0.95);
97
+ padding: 8px 16px;
98
+ border-radius: 20px;
99
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
100
+ font-size: 0.9rem;
101
+ color: #374151;
102
+ z-index: 1000;
103
+ pointer-events: none;
104
+ transition: opacity 0.3s ease;
105
+ }
106
+ .map-hint i {
107
+ color: #10b981;
108
+ margin-right: 6px;
109
+ }
110
+ .map-hint.hidden {
111
+ opacity: 0;
112
+ }
113
+
114
+ /* Timeline stop badges */
115
+ .timeline-stop-badge {
116
+ background-color: #6366f1;
117
+ color: white;
118
+ padding: 1px 6px;
119
+ border-radius: 10px;
120
+ font-size: 0.7rem;
121
+ font-weight: bold;
122
+ margin-right: 4px;
123
+ }
124
+ .timeline-status-icon {
125
+ margin-left: 4px;
126
+ font-size: 0.85rem;
127
+ }
128
+ .timeline-status-ontime { color: #10b981; }
129
+ .timeline-status-late { color: #ef4444; }
130
+ .timeline-status-early { color: #3b82f6; }
131
+ .vis-item .vis-item-content {
132
+ font-size: 0.85rem;
133
+ padding: 2px 4px;
134
+ }
135
+ .vis-labelset .vis-label {
136
+ padding: 4px 8px;
137
+ }
138
+
139
+ /* Loading overlay */
140
+ .loading-overlay {
141
+ position: fixed;
142
+ top: 0;
143
+ left: 0;
144
+ right: 0;
145
+ bottom: 0;
146
+ background: rgba(255, 255, 255, 0.95);
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: center;
150
+ z-index: 2000;
151
+ transition: opacity 0.3s ease;
152
+ }
153
+ .loading-overlay.hidden {
154
+ opacity: 0;
155
+ pointer-events: none;
156
+ }
157
+ .loading-content {
158
+ text-align: center;
159
+ padding: 2rem;
160
+ }
161
+ .loading-spinner {
162
+ width: 60px;
163
+ height: 60px;
164
+ border: 4px solid #e5e7eb;
165
+ border-top-color: #10b981;
166
+ border-radius: 50%;
167
+ animation: spin 1s linear infinite;
168
+ margin: 0 auto 1.5rem;
169
+ }
170
+ @keyframes spin {
171
+ to { transform: rotate(360deg); }
172
+ }
173
+
174
+ /* Real Roads toggle styling */
175
+ #realRoadRouting:checked {
176
+ background-color: #10b981;
177
+ border-color: #10b981;
178
+ }
179
+ </style>
180
+ </head>
181
+ <body>
182
+
183
+ <header id="solverforge-auto-header">
184
+ <!-- Filled in by app.js -->
185
+ </header>
186
+ <div class="tab-content">
187
+ <div id="demo" class="tab-pane fade show active container-fluid">
188
+ <div class="sticky-top d-flex justify-content-center align-items-center">
189
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
190
+ </div>
191
+ <h1>Vehicle routing with capacity and time windows</h1>
192
+ <p>Generate optimal route plan of a vehicle fleet with limited vehicle capacity and time windows.</p>
193
+ <div class="container-fluid mb-2">
194
+ <div class="row justify-content-start">
195
+ <div class="col-9">
196
+ <ul class="nav nav-pills col" role="tablist">
197
+ <li class="nav-item" role="presentation">
198
+ <button class="nav-link active" id="mapTab" data-bs-toggle="tab" data-bs-target="#mapPanel"
199
+ type="button"
200
+ role="tab" aria-controls="mapPanel" aria-selected="false">Map
201
+ </button>
202
+ </li>
203
+ <li class="nav-item" role="presentation">
204
+ <button class="nav-link" id="byVehicleTab" data-bs-toggle="tab" data-bs-target="#byVehiclePanel"
205
+ type="button" role="tab" aria-controls="byVehiclePanel" aria-selected="false">By vehicle
206
+ </button>
207
+ </li>
208
+ <li class="nav-item" role="presentation">
209
+ <button class="nav-link" id="byVisitTab" data-bs-toggle="tab" data-bs-target="#byVisitPanel"
210
+ type="button" role="tab" aria-controls="byVisitPanel" aria-selected="false">By visit
211
+ </button>
212
+ </li>
213
+ </ul>
214
+ </div>
215
+ <div class="col-3">
216
+ <button id="solveButton" type="button" class="btn btn-success">
217
+ <i class="fas fa-play"></i> Solve
218
+ </button>
219
+ <button id="stopSolvingButton" type="button" class="btn btn-danger p-2">
220
+ <i class="fas fa-stop"></i> Stop solving
221
+ </button>
222
+ <span id="solvingSpinner" class="ms-2"></span>
223
+ <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
224
+ <button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
225
+ <span class="fas fa-question"></span>
226
+ </button>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <div class="tab-content">
232
+
233
+ <div class="tab-pane fade show active" id="mapPanel" role="tabpanel" aria-labelledby="mapTab">
234
+ <div class="row">
235
+ <div class="col-7 col-lg-8 col-xl-9 position-relative">
236
+ <div id="map" style="width: 100%; height: 100vh;"></div>
237
+ <div id="mapHint" class="map-hint">
238
+ <i class="fas fa-mouse-pointer"></i> Click on the map to add a new visit
239
+ </div>
240
+ </div>
241
+ <div class="col-5 col-lg-4 col-xl-3" style="height: 100vh; overflow-y: scroll;">
242
+ <div class="row pt-2 row-cols-1">
243
+ <div class="col">
244
+ <h5>
245
+ Solution summary
246
+ </h5>
247
+ <table class="table">
248
+ <tr>
249
+ <td>Total driving time:</td>
250
+ <td><span id="drivingTime">unknown</span></td>
251
+ </tr>
252
+ </table>
253
+ </div>
254
+ <div class="col mb-3">
255
+ <h5>Time Windows</h5>
256
+ <div class="d-flex flex-column gap-1">
257
+ <div><i class="fas fa-utensils" style="color: #f59e0b; width: 20px;"></i> <strong>Restaurant</strong> <small class="text-muted">06:00-10:00 · 20-40 min</small></div>
258
+ <div><i class="fas fa-building" style="color: #3b82f6; width: 20px;"></i> <strong>Business</strong> <small class="text-muted">09:00-17:00 · 15-30 min</small></div>
259
+ <div><i class="fas fa-home" style="color: #10b981; width: 20px;"></i> <strong>Residential</strong> <small class="text-muted">17:00-20:00 · 5-10 min</small></div>
260
+ </div>
261
+ </div>
262
+ <div class="col">
263
+ <div class="d-flex justify-content-between align-items-center mb-2">
264
+ <div>
265
+ <h5 class="mb-0">Vehicles</h5>
266
+ <small class="text-muted"><i class="fas fa-hand-pointer"></i> Click to highlight route</small>
267
+ </div>
268
+ <div class="btn-group btn-group-sm" role="group" aria-label="Vehicle management">
269
+ <button type="button" class="btn btn-outline-danger" id="removeVehicleBtn" title="Remove last vehicle">
270
+ <i class="fas fa-minus"></i>
271
+ </button>
272
+ <button type="button" class="btn btn-outline-success" id="addVehicleBtn" title="Add new vehicle">
273
+ <i class="fas fa-plus"></i>
274
+ </button>
275
+ </div>
276
+ </div>
277
+ <table class="table-sm w-100">
278
+ <thead>
279
+ <tr>
280
+ <th class="col-1"></th>
281
+ <th class="col-3">Name</th>
282
+ <th class="col-3">
283
+ Cargo
284
+ <i class="fas fa-info-circle" data-bs-toggle="tooltip" data-bs-placement="top"
285
+ data-html="true"
286
+ title="Units to deliver on this route. Each customer requires cargo units (e.g., packages, crates). Bar shows current load vs. vehicle capacity."></i>
287
+ </th>
288
+ <th class="col-2">Drive</th>
289
+ <th class="col-1"></th>
290
+ </tr>
291
+ </thead>
292
+ <tbody id="vehicles"></tbody>
293
+ </table>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+
300
+
301
+ <div class="tab-pane fade" id="byVehiclePanel" role="tabpanel" aria-labelledby="byVehicleTab">
302
+ </div>
303
+ <div class="tab-pane fade" id="byVisitPanel" role="tabpanel" aria-labelledby="byVisitTab">
304
+ </div>
305
+ </div>
306
+ </div>
307
+
308
+ <div id="rest" class="tab-pane fade container-fluid">
309
+ <h1>REST API Guide</h1>
310
+
311
+ <h2>Vehicle routing with vehicle capacity and time windows - integration via cURL</h2>
312
+
313
+ <h3>1. Download demo data</h3>
314
+ <pre>
315
+ <button class="btn btn-outline-dark btn-sm float-end"
316
+ onclick="copyTextToClipboard('curl1')">Copy</button>
317
+ <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8082/demo-data/FIRENZE -o sample.json</code>
318
+ </pre>
319
+
320
+ <h3>2. Post the sample data for solving</h3>
321
+ <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
322
+ <pre>
323
+ <button class="btn btn-outline-dark btn-sm float-end"
324
+ onclick="copyTextToClipboard('curl2')">Copy</button>
325
+ <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8082/route-plans -d@sample.json</code>
326
+ </pre>
327
+
328
+ <h3>3. Get the current status and score</h3>
329
+ <pre>
330
+ <button class="btn btn-outline-dark btn-sm float-end"
331
+ onclick="copyTextToClipboard('curl3')">Copy</button>
332
+ <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}/status</code>
333
+ </pre>
334
+
335
+ <h3>4. Get the complete route plan</h3>
336
+ <pre>
337
+ <button class="btn btn-outline-dark btn-sm float-end"
338
+ onclick="copyTextToClipboard('curl4')">Copy</button>
339
+ <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}</code>
340
+ </pre>
341
+
342
+ <h3>5. Terminate solving early</h3>
343
+ <pre>
344
+ <button class="btn btn-outline-dark btn-sm float-end"
345
+ onclick="copyTextToClipboard('curl5')">Copy</button>
346
+ <code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8082/route-plans/{jobId}</code>
347
+ </pre>
348
+ </div>
349
+
350
+ <div id="openapi" class="tab-pane fade container-fluid">
351
+ <h1>REST API Reference</h1>
352
+ <div class="ratio ratio-1x1">
353
+ <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
354
+ <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
355
+ </div>
356
+ </div>
357
+ </div>
358
+ <div class="modal fadebd-example-modal-lg" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
359
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
360
+ <div class="modal-content">
361
+ <div class="modal-header">
362
+ <h1 class="modal-title fs-5" id="scoreAnalysisModalLabel">Score analysis <span id="scoreAnalysisScoreLabel"></span></h1>
363
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
364
+ </div>
365
+ <div class="modal-body" id="scoreAnalysisModalContent">
366
+ <!-- Filled in by app.js -->
367
+ </div>
368
+ <div class="modal-footer">
369
+ <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
370
+ </div>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ <form id='visitForm' class='needs-validation' novalidate>
375
+ <div class="modal fadebd-example-modal-lg" id="newVisitModal" tabindex="-1"
376
+ aria-labelledby="newVisitModalLabel"
377
+ aria-hidden="true">
378
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
379
+ <div class="modal-content">
380
+ <div class="modal-header">
381
+ <h1 class="modal-title fs-5" id="newVisitModalLabel">Add New Visit</h1>
382
+ <button type="button" class="btn-close" data-bs-dismiss="modal"
383
+ aria-label="Close"></button>
384
+ </div>
385
+ <div class="modal-body" id="newVisitModalContent">
386
+ <!-- Filled in by app.js -->
387
+ </div>
388
+ <div class="modal-footer" id="newVisitModalFooter">
389
+ </div>
390
+ </div>
391
+ </div>
392
+ </div>
393
+ </form>
394
+ <!-- Add Vehicle Modal -->
395
+ <div class="modal fade" id="addVehicleModal" tabindex="-1" aria-labelledby="addVehicleModalLabel" aria-hidden="true">
396
+ <div class="modal-dialog">
397
+ <div class="modal-content">
398
+ <div class="modal-header">
399
+ <h5 class="modal-title" id="addVehicleModalLabel"><i class="fas fa-truck"></i> Add New Vehicle</h5>
400
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
401
+ </div>
402
+ <div class="modal-body">
403
+ <div class="mb-3">
404
+ <label for="vehicleName" class="form-label">Name</label>
405
+ <input type="text" class="form-control" id="vehicleName" placeholder="e.g., Kilo">
406
+ <div class="form-text">Unique name for the vehicle</div>
407
+ </div>
408
+ <div class="mb-3">
409
+ <label for="vehicleCapacity" class="form-label">Capacity</label>
410
+ <input type="number" class="form-control" id="vehicleCapacity" value="25" min="1">
411
+ <div class="form-text">Maximum cargo the vehicle can carry</div>
412
+ </div>
413
+ <div class="mb-3">
414
+ <label for="vehicleDepartureTime" class="form-label">Departure Time</label>
415
+ <input type="text" class="form-control" id="vehicleDepartureTime">
416
+ </div>
417
+ <div class="mb-3">
418
+ <label class="form-label">Home Location</label>
419
+ <div class="d-flex gap-2 mb-2">
420
+ <button type="button" class="btn btn-outline-primary btn-sm" id="pickLocationBtn">
421
+ <i class="fas fa-map-marker-alt"></i> Pick on Map
422
+ </button>
423
+ <span class="text-muted small align-self-center">or enter coordinates:</span>
424
+ </div>
425
+ <div class="row g-2">
426
+ <div class="col-6">
427
+ <input type="number" step="any" class="form-control" id="vehicleHomeLat" placeholder="Latitude">
428
+ </div>
429
+ <div class="col-6">
430
+ <input type="number" step="any" class="form-control" id="vehicleHomeLng" placeholder="Longitude">
431
+ </div>
432
+ </div>
433
+ <div id="vehicleLocationPreview" class="mt-2 text-muted small"></div>
434
+ </div>
435
+ </div>
436
+ <div class="modal-footer">
437
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
438
+ <button type="button" class="btn btn-success" id="confirmAddVehicle"><i class="fas fa-plus"></i> Add Vehicle</button>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+
444
+ <!-- Loading/Progress Overlay -->
445
+ <div id="loadingOverlay" class="loading-overlay hidden">
446
+ <div class="loading-content">
447
+ <div class="loading-spinner"></div>
448
+ <h5 id="loadingTitle">Loading Demo Data</h5>
449
+ <p id="loadingMessage" class="text-muted mb-2">Initializing...</p>
450
+ <div class="progress" style="width: 300px; height: 8px;">
451
+ <div id="loadingProgress" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div>
452
+ </div>
453
+ <small id="loadingDetail" class="text-muted mt-2 d-block"></small>
454
+ </div>
455
+ </div>
456
+
457
+ <footer id="solverforge-auto-footer"></footer>
458
+
459
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
460
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
461
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"></script>
462
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
463
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
464
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
465
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
466
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
467
+ integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
468
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
469
+ <script src="/score-analysis.js"></script>
470
+ <script src="/recommended-fit.js"></script>
471
+ <script src="/app.js"></script>
472
+ </body>
473
+ </html>
static/recommended-fit.js ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Recommended Fit functionality for adding new visits with recommendations.
3
+ *
4
+ * This module provides:
5
+ * - Modal form for adding new visits
6
+ * - Integration with the recommendation API
7
+ * - Application of selected recommendations
8
+ */
9
+
10
+ // Customer type configurations (must match CUSTOMER_TYPES in app.js and demo_data.py)
11
+ const VISIT_CUSTOMER_TYPES = {
12
+ RESIDENTIAL: { label: "Residential", icon: "fa-home", color: "#10b981", windowStart: "17:00", windowEnd: "20:00", minDemand: 1, maxDemand: 2, minService: 5, maxService: 10 },
13
+ BUSINESS: { label: "Business", icon: "fa-building", color: "#3b82f6", windowStart: "09:00", windowEnd: "17:00", minDemand: 3, maxDemand: 6, minService: 15, maxService: 30 },
14
+ RESTAURANT: { label: "Restaurant", icon: "fa-utensils", color: "#f59e0b", windowStart: "06:00", windowEnd: "10:00", minDemand: 5, maxDemand: 10, minService: 20, maxService: 40 },
15
+ };
16
+
17
+ function addNewVisit(id, lat, lng, map, marker) {
18
+ $('#newVisitModal').modal('show');
19
+ const visitModalContent = $("#newVisitModalContent");
20
+ visitModalContent.children().remove();
21
+
22
+ let visitForm = "";
23
+
24
+ // Customer Type Selection (prominent at the top)
25
+ visitForm += "<div class='form-group mb-3'>" +
26
+ " <label class='form-label fw-bold'>Customer Type</label>" +
27
+ " <div class='row g-2' id='customerTypeButtons'>";
28
+
29
+ Object.entries(VISIT_CUSTOMER_TYPES).forEach(([type, config]) => {
30
+ const isDefault = type === 'RESIDENTIAL';
31
+ visitForm += `
32
+ <div class='col-4'>
33
+ <button type='button' class='btn w-100 customer-type-btn ${isDefault ? 'active' : ''}'
34
+ data-type='${type}'
35
+ style='border: 2px solid ${config.color}; ${isDefault ? `background-color: ${config.color}; color: white;` : `color: ${config.color};`}'>
36
+ <i class='fas ${config.icon}'></i><br>
37
+ <span class='fw-bold'>${config.label}</span><br>
38
+ <small>${config.windowStart}-${config.windowEnd}</small>
39
+ </button>
40
+ </div>`;
41
+ });
42
+
43
+ visitForm += " </div>" +
44
+ "</div>";
45
+
46
+ // Name and Location row
47
+ visitForm += "<div class='form-group mb-3'>" +
48
+ " <div class='row g-2'>" +
49
+ " <div class='col-4'>" +
50
+ " <label for='inputName' class='form-label'>Name</label>" +
51
+ ` <input type='text' class='form-control' id='inputName' value='visit${id}' required>` +
52
+ " <div class='invalid-feedback'>Field is required</div>" +
53
+ " </div>" +
54
+ " <div class='col-4'>" +
55
+ " <label for='inputLatitude' class='form-label'>Latitude</label>" +
56
+ ` <input type='text' disabled class='form-control' id='inputLatitude' value='${lat.toFixed(6)}'>` +
57
+ " </div>" +
58
+ " <div class='col-4'>" +
59
+ " <label for='inputLongitude' class='form-label'>Longitude</label>" +
60
+ ` <input type='text' disabled class='form-control' id='inputLongitude' value='${lng.toFixed(6)}'>` +
61
+ " </div>" +
62
+ " </div>" +
63
+ "</div>";
64
+
65
+ // Cargo and Duration row
66
+ visitForm += "<div class='form-group mb-3'>" +
67
+ " <div class='row g-2'>" +
68
+ " <div class='col-6'>" +
69
+ " <label for='inputDemand' class='form-label'>Cargo (units) <small class='text-muted' id='demandHint'>(1-2 typical)</small></label>" +
70
+ " <input type='number' class='form-control' id='inputDemand' value='1' min='1' required>" +
71
+ " <div class='invalid-feedback'>Field is required</div>" +
72
+ " </div>" +
73
+ " <div class='col-6'>" +
74
+ " <label for='inputDuration' class='form-label'>Service Duration <small class='text-muted' id='durationHint'>(5-10 min typical)</small></label>" +
75
+ " <input type='number' class='form-control' id='inputDuration' value='7' min='1' required>" +
76
+ " <div class='invalid-feedback'>Field is required</div>" +
77
+ " </div>" +
78
+ " </div>" +
79
+ "</div>";
80
+
81
+ // Time window row
82
+ visitForm += "<div class='form-group mb-3'>" +
83
+ " <div class='row g-2'>" +
84
+ " <div class='col-6'>" +
85
+ " <label for='inputMinStartTime' class='form-label'>Time Window Start</label>" +
86
+ " <input class='form-control' id='inputMinStartTime' required>" +
87
+ " <div class='invalid-feedback'>Field is required</div>" +
88
+ " </div>" +
89
+ " <div class='col-6'>" +
90
+ " <label for='inputMaxStartTime' class='form-label'>Time Window End</label>" +
91
+ " <input class='form-control' id='inputMaxStartTime' required>" +
92
+ " <div class='invalid-feedback'>Field is required</div>" +
93
+ " </div>" +
94
+ " </div>" +
95
+ "</div>";
96
+
97
+ visitModalContent.append(visitForm);
98
+
99
+ // Initialize with Residential defaults
100
+ const defaultType = VISIT_CUSTOMER_TYPES.RESIDENTIAL;
101
+ const tomorrow = JSJoda.LocalDate.now().plusDays(1);
102
+
103
+ function parseTimeToDateTime(timeStr) {
104
+ const [hours, minutes] = timeStr.split(':').map(Number);
105
+ return tomorrow.atTime(JSJoda.LocalTime.of(hours, minutes));
106
+ }
107
+
108
+ let minStartPicker = flatpickr("#inputMinStartTime", {
109
+ enableTime: true,
110
+ dateFormat: "Y-m-d H:i",
111
+ defaultDate: parseTimeToDateTime(defaultType.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
112
+ });
113
+
114
+ let maxEndPicker = flatpickr("#inputMaxStartTime", {
115
+ enableTime: true,
116
+ dateFormat: "Y-m-d H:i",
117
+ defaultDate: parseTimeToDateTime(defaultType.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm'))
118
+ });
119
+
120
+ // Customer type button click handler
121
+ $(".customer-type-btn").click(function() {
122
+ const selectedType = $(this).data('type');
123
+ const config = VISIT_CUSTOMER_TYPES[selectedType];
124
+
125
+ // Update button styles
126
+ $(".customer-type-btn").each(function() {
127
+ const btnType = $(this).data('type');
128
+ const btnConfig = VISIT_CUSTOMER_TYPES[btnType];
129
+ $(this).removeClass('active');
130
+ $(this).css({
131
+ 'background-color': 'transparent',
132
+ 'color': btnConfig.color
133
+ });
134
+ });
135
+ $(this).addClass('active');
136
+ $(this).css({
137
+ 'background-color': config.color,
138
+ 'color': 'white'
139
+ });
140
+
141
+ // Update time windows
142
+ minStartPicker.setDate(parseTimeToDateTime(config.windowStart).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
143
+ maxEndPicker.setDate(parseTimeToDateTime(config.windowEnd).format(JSJoda.DateTimeFormatter.ofPattern('yyyy-M-d HH:mm')));
144
+
145
+ // Update demand hint and value
146
+ $("#demandHint").text(`(${config.minDemand}-${config.maxDemand} typical)`);
147
+ $("#inputDemand").val(config.minDemand);
148
+
149
+ // Update service duration hint and value (use midpoint of range)
150
+ const avgService = Math.round((config.minService + config.maxService) / 2);
151
+ $("#durationHint").text(`(${config.minService}-${config.maxService} min typical)`);
152
+ $("#inputDuration").val(avgService);
153
+ });
154
+
155
+ const visitModalFooter = $("#newVisitModalFooter");
156
+ visitModalFooter.children().remove();
157
+ visitModalFooter.append("<button id='recommendationButton' type='button' class='btn btn-success'><i class='fas fa-arrow-right'></i> Get Recommendations</button>");
158
+ $("#recommendationButton").click(getRecommendationsModal);
159
+ }
160
+
161
+ function requestRecommendations(visitId, solution, endpointPath) {
162
+ $.post(endpointPath, JSON.stringify({solution, visitId}), function (recommendations) {
163
+ const visitModalContent = $("#newVisitModalContent");
164
+ visitModalContent.children().remove();
165
+
166
+ if (!recommendations || recommendations.length === 0) {
167
+ visitModalContent.append("<div class='alert alert-warning'>No recommendations available. The recommendation API may not be fully implemented.</div>");
168
+ const visitModalFooter = $("#newVisitModalFooter");
169
+ visitModalFooter.children().remove();
170
+ visitModalFooter.append("<button type='button' class='btn btn-secondary' data-bs-dismiss='modal'>Close</button>");
171
+ return;
172
+ }
173
+
174
+ let visitOptions = "";
175
+ const visit = solution.visits.find(c => c.id === visitId);
176
+
177
+ recommendations.forEach((recommendation, index) => {
178
+ const scoreDiffDisplay = recommendation.scoreDiff || "N/A";
179
+ visitOptions += "<div class='form-check'>" +
180
+ ` <input class='form-check-input' type='radio' name='recommendationOptions' id='option${index}' value='option${index}' ${index === 0 ? 'checked=true' : ''}>` +
181
+ ` <label class='form-check-label' for='option${index}'>` +
182
+ ` Add <b>${visit.name}</b> to vehicle <b>${recommendation.proposition.vehicleId}</b> at position <b>${recommendation.proposition.index + 1}</b> (${scoreDiffDisplay})${index === 0 ? ' - <b>Best Solution</b>': ''}` +
183
+ " </label>" +
184
+ "</div>";
185
+ });
186
+
187
+ visitModalContent.append(visitOptions);
188
+
189
+ const visitModalFooter = $("#newVisitModalFooter");
190
+ visitModalFooter.children().remove();
191
+ visitModalFooter.append("<button id='applyRecommendationButton' type='button' class='btn btn-success'><i class='fas fa-check'></i> Accept</button>");
192
+ $("#applyRecommendationButton").click(_ => applyRecommendationModal(recommendations));
193
+ }).fail(function (xhr, ajaxOptions, thrownError) {
194
+ showError("Recommendations request failed.", xhr);
195
+ $('#newVisitModal').modal('hide');
196
+ });
197
+ }
198
+
199
+ function applyRecommendation(solution, visitId, vehicleId, index, endpointPath) {
200
+ $.post(endpointPath, JSON.stringify({solution, visitId, vehicleId, index}), function (updatedSolution) {
201
+ updateSolutionWithNewVisit(updatedSolution);
202
+ }).fail(function (xhr, ajaxOptions, thrownError) {
203
+ showError("Apply recommendation request failed.", xhr);
204
+ $('#newVisitModal').modal('hide');
205
+ });
206
+ }
static/score-analysis.js ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function analyzeScore(solution, endpointPath) {
2
+ new bootstrap.Modal("#scoreAnalysisModal").show()
3
+ const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
4
+ scoreAnalysisModalContent.children().remove();
5
+ scoreAnalysisModalContent.text("");
6
+
7
+ if (solution.score == null) {
8
+ scoreAnalysisModalContent.text("Score not ready for analysis, try to run the solver first or wait until it advances.");
9
+ } else {
10
+ visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath)
11
+ }
12
+ }
13
+
14
+ function visualizeScoreAnalysis(scoreAnalysisModalContent, solution, endpointPath) {
15
+ $('#scoreAnalysisScoreLabel').text(`(${solution.score})`);
16
+ $.put(endpointPath, JSON.stringify(solution), function (scoreAnalysis) {
17
+ let constraints = scoreAnalysis.constraints;
18
+ constraints.sort(compareConstraintsBySeverity);
19
+ constraints.map(addDerivedScoreAttributes);
20
+ scoreAnalysis.constraints = constraints;
21
+
22
+ const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
23
+ const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
24
+ .append($(`<th></th>`))
25
+ .append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
26
+ .append($(`<th>Type</th>`))
27
+ .append($(`<th># Matches</th>`))
28
+ .append($(`<th>Weight</th>`))
29
+ .append($(`<th>Score</th>`))
30
+ .append($(`<th></th>`)));
31
+ analysisTable.append(analysisTHead);
32
+ const analysisTBody = $(`<tbody/>`)
33
+ $.each(scoreAnalysis.constraints, function (index, constraintAnalysis) {
34
+ visualizeConstraintAnalysis(analysisTBody, index, constraintAnalysis)
35
+ });
36
+ analysisTable.append(analysisTBody);
37
+ scoreAnalysisModalContent.append(analysisTable);
38
+ }).fail(function (xhr, ajaxOptions, thrownError) {
39
+ showError("Score analysis failed.", xhr);
40
+ },
41
+ "text");
42
+ }
43
+
44
+ function compareConstraintsBySeverity(a, b) {
45
+ let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
46
+ if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
47
+ if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
48
+ if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
49
+ return -1;
50
+ } else {
51
+ if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
52
+ if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
53
+ if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
54
+ return -1;
55
+ } else {
56
+ if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
57
+ if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
58
+
59
+ return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
60
+ }
61
+ }
62
+ }
63
+
64
+ function addDerivedScoreAttributes(constraint) {
65
+ let components = getScoreComponents(constraint.weight);
66
+ constraint.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
67
+ constraint.weight = components[constraint.type];
68
+ let scores = getScoreComponents(constraint.score);
69
+ constraint.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
70
+ }
71
+
72
+ function getScoreComponents(score) {
73
+ let components = {hard: 0, medium: 0, soft: 0};
74
+
75
+ $.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], function (i, parts) {
76
+ components[parts[2]] = parseInt(parts[1], 10);
77
+ });
78
+
79
+ return components;
80
+ }
81
+
82
+ function visualizeConstraintAnalysis(analysisTBody, constraintIndex, constraintAnalysis, recommendation = false, recommendationIndex = null) {
83
+ let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
84
+ if (!icon) icon = constraintAnalysis.weight < 0 && constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
85
+
86
+ let row = $(`<tr/>`);
87
+ row.append($(`<td/>`).html(icon))
88
+ .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
89
+ .append($(`<td/>`).text(constraintAnalysis.type))
90
+ .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
91
+ .append($(`<td/>`).text(constraintAnalysis.weight))
92
+ .append($(`<td/>`).text(recommendation ? constraintAnalysis.score : constraintAnalysis.implicitScore));
93
+
94
+ analysisTBody.append(row);
95
+ row.append($(`<td/>`));
96
+ }
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }