fsanyoto commited on
Commit
7251a78
·
verified ·
1 Parent(s): 7f0579e

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "v45 (0b2b501)",
3
  "releases": [
 
 
 
 
 
 
4
  {
5
  "version": "v45",
6
  "sha": "0b2b501",
 
1
  {
2
+ "current": "v46 (a8dc531)",
3
  "releases": [
4
+ {
5
+ "version": "v46",
6
+ "sha": "a8dc531",
7
+ "date": "2026-08-23",
8
+ "subject": "release v46"
9
+ },
10
  {
11
  "version": "v45",
12
  "sha": "0b2b501",
VERSION CHANGED
@@ -1 +1 @@
1
- v45 (0b2b501)
 
1
+ v46 (a8dc531)
api/routes_customers.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_geo.py CHANGED
@@ -1,348 +1,407 @@
1
- """routes_geo.py — the MAP PROVIDER SEAM (wave 37, R3/R14, tickets T35/T36/T37).
2
-
3
- ⭐ WHY THIS FILE EXISTS AT ALL, and why the map does not simply call a vendor.
4
-
5
- R14 (owner, 2026-08-19): no Google Maps API key this wave. The map work is not dropped, it is
6
- retargeted to providers that need no key: OpenStreetMap tiles, OSRM routing, Nominatim geocoding.
7
- The deferral only holds if swapping a paid vendor back in is a CONFIG change rather than a rewrite,
8
- so every provider is a (base URL + attribution) pair read from the environment and served from ONE
9
- door. The renderer receives a URL template and a credit line; it knows nothing about who is behind
10
- them. That is the seam.
11
-
12
- tiles AIOS_MAP_TILE_URL default https://tile.openstreetmap.org/{z}/{x}/{y}.png
13
- geocode AIOS_GEOCODE_URL default https://nominatim.openstreetmap.org/search
14
- routing AIOS_ROUTE_URL default https://router.project-osrm.org/route/v1/driving/
15
-
16
- ⛔ R3'S ON-DEMAND RULE, AND WHY IT IS STRUCTURAL HERE RATHER THAN A COMMENT.
17
- R3 forbids geocoding a whole table automatically. Under R14 the reason moves from money to manners
18
- and gets sharper: Nominatim's usage policy allows roughly one request per second and explicitly
19
- forbids bulk harvesting, so a loop over 3,636 customers does not produce a bill, it produces a
20
- BLOCKED tenant. Three mechanisms enforce it, none of them advisory:
21
-
22
- 1. `/geo/geocode` takes an explicit LIST OF ADDRESSES from the caller. It cannot read a table, so
23
- there is no code path from "a database exists" to "its rows were geocoded". Somebody had to
24
- choose the records.
25
- 2. `MAX_BATCH` refuses an oversized list with a named error rather than truncating it. The client
26
- drives the loop and can therefore SHOW the wait, which is the half of the ticket a comment
27
- cannot satisfy.
28
- 3. `_throttle()` blocks in-process until the minimum interval has elapsed, so even a caller that
29
- ignores everything above cannot exceed the published rate.
30
-
31
- ⭐ AND A CACHE, because the policy asks for one: an address geocoded once is answered from memory
32
- for the rest of the container's life. It is the cheapest way to be a good citizen and it makes a
33
- re-run of the same records free rather than merely legal.
34
-
35
- ⚠ NOTHING HERE PROXIES TILES. Tiles are fetched by the BROWSER, straight from the provider, which
36
- is what every OSM client does and what the tile policy expects. Proxying them through this app
37
- would put a free tier in the path of every pan and would breach the same policy it looks like it is
38
- respecting. This door serves the tile URL, never the tile.
39
- """
40
- import os
41
- import re
42
- import threading
43
- import time
44
- from collections import OrderedDict
45
-
46
- import requests
47
- from fastapi import APIRouter, Body, Depends
48
-
49
- from deps import Session, err, require_session
50
-
51
- router = APIRouter(prefix="/api/v1")
52
-
53
- # --------------------------------------------------------------------- the seam
54
- #
55
- # Every value below is an environment override with a keyless default. Pointing the map at a paid
56
- # vendor is three variables on the Space and no code change, which is what makes R14 a deferral.
57
-
58
- _TILE_URL_DEFAULT = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
59
- _TILE_CREDIT_DEFAULT = "© OpenStreetMap contributors"
60
- _TILE_CREDIT_HREF_DEFAULT = "https://www.openstreetmap.org/copyright"
61
- _GEOCODE_URL_DEFAULT = "https://nominatim.openstreetmap.org/search"
62
- _ROUTE_URL_DEFAULT = "https://router.project-osrm.org/route/v1/driving/"
63
-
64
- #: Both policies require a real, identifying User-Agent. A generic one is how a shared service
65
- #: decides an application is a scraper, so this carries the product name and a contact URL.
66
- _UA_DEFAULT = "AIOS-Loopable/1.0 (+https://runloopable.com)"
67
-
68
- #: Nominatim publishes one request per second. 1100 ms leaves room for clock jitter rather than
69
- #: sitting exactly on the published edge.
70
- _MIN_INTERVAL_MS_DEFAULT = 1100
71
-
72
- #: The most addresses one call may carry. Small on purpose: the client loops and shows progress,
73
- #: and no single request can sit on the connection for a minute waiting out the throttle.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  MAX_BATCH = 25
75
  # OSRM accepts substantially more route coordinates than the geocoder batch. Keep routing useful
76
  # for a normal customer day (including the 32-stop route that exposed the old failure) without
77
  # allowing an unbounded request to occupy the provider.
78
  MAX_ROUTE_STOPS = 100
79
-
80
- #: Tile zoom the provider actually serves. OSM stops at 19.
81
- _TILE_MAX_Z_DEFAULT = 19
82
-
83
- _CACHE_MAX = 4096
84
-
85
-
86
- def _env(name, default):
87
- return (os.environ.get(name) or "").strip() or default
88
-
89
-
90
- def _env_int(name, default):
91
- try:
92
- n = int((os.environ.get(name) or "").strip())
93
- return n if n > 0 else default
94
- except (TypeError, ValueError):
95
- return default
96
-
97
-
98
- def provider_config():
99
- """The whole seam as one dict. `GET /geo/providers` serves it and the gate reads it."""
100
- return {
101
- "tiles": {
102
- "url": _env("AIOS_MAP_TILE_URL", _TILE_URL_DEFAULT),
103
- "attribution": _env("AIOS_MAP_TILE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
104
- "attributionUrl": _env("AIOS_MAP_TILE_ATTRIBUTION_URL", _TILE_CREDIT_HREF_DEFAULT),
105
- "maxZoom": _env_int("AIOS_MAP_TILE_MAX_Z", _TILE_MAX_Z_DEFAULT),
106
- },
107
- "geocode": {
108
- "available": True,
109
- "maxBatch": MAX_BATCH,
110
- "minIntervalMs": _env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT),
111
- # ⭐⭐ THE NUMBER THE SURFACE MUST QUOTE, AND IT IS NOT THE RATE LIMIT.
112
- # T37 asks for the estimated wait to be shown BEFORE a run. The obvious source is
113
- # `minIntervalMs`, and it is wrong: 1.1 s is a floor on POLITENESS, not a prediction of
114
- # LATENCY. One real lookup was MEASURED at 3.28 s end to end on 2026-08-19. Quoting the
115
- # limit would promise a minute and take three, and a progress estimate that runs out
116
- # before the work does reads as a hang rather than as a wait.
117
- "secondsPerAddress": float(_env("AIOS_GEOCODE_SECONDS_EACH", "3.3")),
118
- "attribution": _env("AIOS_GEOCODE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
119
- },
120
- "route": {
121
- "available": True,
122
- "attribution": _env("AIOS_ROUTE_ATTRIBUTION", "Routing by OSRM"),
123
- },
124
- }
125
-
126
-
127
- # ------------------------------------------------------------------ the throttle
128
- #
129
- # ONE lock and ONE timestamp for the whole process, shared by geocoding and routing, because the
130
- # rate limit belongs to the SERVICE and not to the endpoint. Two independent throttles would each
131
- # stay honest and together double the published rate.
132
-
133
- _gate = threading.Lock()
134
- _last_call = [0.0]
135
-
136
-
137
- def _throttle(min_interval_ms):
138
- """Block until the minimum interval since the last outbound call has elapsed.
139
-
140
- Returns the seconds actually waited, so a caller can report the wait rather than hide it."""
141
- wait = 0.0
142
- with _gate:
143
- gap = min_interval_ms / 1000.0
144
- now = time.monotonic()
145
- due = _last_call[0] + gap
146
- if now < due:
147
- wait = due - now
148
- time.sleep(wait)
149
- _last_call[0] = time.monotonic()
150
- return wait
151
-
152
-
153
- # --------------------------------------------------------------------- the cache
154
-
155
- _cache = OrderedDict()
156
- _cache_lock = threading.Lock()
157
-
158
-
159
- def _cache_key(q, country):
160
- return (re.sub(r"\s+", " ", str(q or "")).strip().lower(), str(country or "").strip().lower())
161
-
162
-
163
- def _cache_get(key):
164
- with _cache_lock:
165
- if key not in _cache:
166
- return None
167
- _cache.move_to_end(key)
168
- return _cache[key]
169
-
170
-
171
- def _cache_put(key, value):
172
- with _cache_lock:
173
- _cache[key] = value
174
- _cache.move_to_end(key)
175
- while len(_cache) > _CACHE_MAX:
176
- _cache.popitem(last=False)
177
-
178
-
179
- def geocode_one(address, country=None, timeout=12):
180
- """One address to {lat, lon, label} or None.
181
-
182
- THE ONLY PLACE AN OUTBOUND GEOCODE HAPPENS. `ai_enrich.py`'s geocode field calls this rather
183
- than reaching for `requests` itself, so the throttle and the cache cannot be walked around by a
184
- second caller. A cache hit costs no request and no wait."""
185
- q = re.sub(r"\s+", " ", str(address or "")).strip()
186
- if not q:
187
- return None
188
- key = _cache_key(q, country)
189
- hit = _cache_get(key)
190
- if hit is not None:
191
- return dict(hit) if hit else None
192
-
193
- cfg = provider_config()["geocode"]
194
- _throttle(cfg["minIntervalMs"])
195
- params = {"q": q, "format": "jsonv2", "limit": 1}
196
- if country:
197
- params["countrycodes"] = str(country).strip().lower()
 
 
 
 
 
 
 
 
 
 
 
198
  try:
199
  r = requests.get(
200
- _env("AIOS_GEOCODE_URL", _GEOCODE_URL_DEFAULT),
201
- params=params,
202
- headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT),
203
- "Accept": "application/json"},
204
- timeout=timeout,
205
- )
206
- r.raise_for_status()
207
- rows = r.json()
208
- except Exception:
209
- # ⚠ A transport failure is NOT cached. Caching it would turn one bad minute into a
210
- # permanently empty column that no re-run could ever repair.
211
- return None
212
- if not isinstance(rows, list) or not rows:
213
- # A genuine "no such place" IS cached, as a negative: asking again gets the same answer and
214
- # spends another second of a shared service's budget to hear it.
215
- _cache_put(key, {})
216
- return None
217
- top = rows[0] or {}
218
- try:
219
- lat = float(top.get("lat"))
220
- lon = float(top.get("lon"))
221
- except (TypeError, ValueError):
222
- _cache_put(key, {})
223
- return None
224
- if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
225
- _cache_put(key, {})
226
- return None
227
- out = {"lat": lat, "lon": lon, "label": str(top.get("display_name") or q)}
228
- _cache_put(key, out)
229
- return dict(out)
230
-
231
-
232
- # ---------------------------------------------------------------------- the doors
233
-
234
-
235
- @router.get("/geo/providers")
236
- def geo_providers(session: Session = Depends(require_session)):
237
- """What the map should draw with, and who to credit for it.
238
-
239
- Session gated like every other door here. The values are not secret, but an unauthenticated
240
- endpoint on this app is a door somebody eventually hangs something else on."""
241
- return provider_config()
242
-
243
-
244
- @router.post("/geo/geocode")
245
- def geo_geocode(body: dict = Body(...), session: Session = Depends(require_session)):
246
- """Turn a bounded list of addresses into coordinates.
247
-
248
- ⛔ Takes ADDRESSES, never a table key, a view id or a filter. There is deliberately no shape of
249
- request that means "geocode everything", which is R3 expressed as an API rather than as a
250
- warning."""
251
- items = body.get("addresses")
252
- # ⭐ THE SECOND SHAPE, AND IT EXISTS TO KEEP ONE COMPOSER. A caller may send whole RECORDS plus
253
- # the geocode field's config instead of finished address strings, and the server builds the
254
- # query with `ai_enrich.geocode_query`. The alternative was a TypeScript twin of that function
255
- # on the client, and an address composer that exists twice will disagree the first time either
256
- # copy learns about a new column ([[one-question-two-normalizers]]) -- which is exactly the trap
257
- # B's measured `New York (US)` suffix would spring, silently, in whichever copy forgot.
258
- if items is None:
259
- records = body.get("records")
260
- cfg = body.get("config") or {}
261
- if isinstance(records, list) and records:
262
- import ai_enrich
263
- items = []
264
- for rec in records:
265
- if not isinstance(rec, dict):
266
- continue
267
- q = ai_enrich.geocode_query(rec.get("row") or {}, cfg)
268
- # ⛔ A record with no usable address is REPORTED, not dropped: it comes back
269
- # `found: false` with an empty address, so the caller can name it on screen instead
270
- # of quietly returning fewer answers than it asked questions.
271
- items.append({"key": rec.get("key"), "address": q})
272
- if not body.get("country") and cfg.get("country"):
273
- body = {**body, "country": cfg.get("country")}
274
- if not isinstance(items, list) or not items:
275
- raise err(400, "no_addresses", "Send at least one address to look up.")
276
- if len(items) > MAX_BATCH:
277
- raise err(
278
- 400,
279
- "batch_too_large",
280
- f"Look up at most {MAX_BATCH} addresses per request. "
281
- f"The map sends them in batches of {MAX_BATCH} so the wait stays visible.",
282
- )
283
- country = body.get("country")
284
- out = []
285
- started = time.monotonic()
286
- for raw in items:
287
- if isinstance(raw, dict):
288
- key, addr = raw.get("key"), raw.get("address")
289
- else:
290
- key, addr = None, raw
291
- # ⛔ AN EMPTY QUERY COSTS NO REQUEST AND CARRIES ITS OWN REASON. Nominatim's policy is a
292
- # budget shared with everyone else using it, and asking it to place "" would spend a second
293
- # of that budget to be told nothing. `reason` is what lets the surface say "no address on
294
- # file" rather than "not found", which are different facts and want different actions.
295
- if not str(addr or "").strip():
296
- out.append({"key": key, "address": "", "found": False, "lat": None, "lon": None,
297
- "label": None, "reason": "no_address"})
298
- continue
299
- hit = geocode_one(addr, country=country)
300
- out.append({"key": key, "address": str(addr or ""), "found": bool(hit),
301
- "reason": None if hit else "not_found",
302
- "lat": hit["lat"] if hit else None,
303
- "lon": hit["lon"] if hit else None,
304
- "label": hit["label"] if hit else None})
305
- elapsed = time.monotonic() - started
306
- return {"results": out,
307
- "found": sum(1 for r in out if r["found"]),
308
- "asked": len(out),
309
- "secondsElapsed": round(elapsed, 2),
310
- "attribution": provider_config()["geocode"]["attribution"]}
311
-
312
-
313
- @router.post("/geo/route")
314
- def geo_route(body: dict = Body(...), session: Session = Depends(require_session)):
315
- """Road distance, duration and the drawn line, for stops ALREADY put in order.
316
-
317
- ⭐ THE ORDERING IS NOT DONE HERE. `mapProjection.planRoute` sequences the stops on the client,
318
- for free, and keeps working when this service does not answer. This door adds the half that
319
- arithmetic cannot produce: what the ROADS actually cost. Splitting it that way is why the Start
320
- picker and the round-trip toggle keep working with no network at all."""
321
- stops = body.get("stops")
322
- if not isinstance(stops, list) or len(stops) < 2:
323
- raise err(400, "too_few_stops", "A route needs at least two stops with a location.")
324
  if len(stops) > MAX_ROUTE_STOPS:
325
  raise err(400, "too_many_stops",
326
  f"Route at most {MAX_ROUTE_STOPS} stops at once. Narrow the selection and try again.")
327
- pairs = []
328
- for s in stops:
329
- try:
330
- lat, lon = float(s["lat"]), float(s["lon"])
331
- except (TypeError, ValueError, KeyError, IndexError):
332
- raise err(400, "bad_stop", "Every stop needs a numeric latitude and longitude.")
333
- if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
334
- raise err(400, "bad_stop", "Every stop needs a latitude and longitude on the globe.")
335
- pairs.append(f"{lon:.6f},{lat:.6f}")
336
-
337
- base = _env("AIOS_ROUTE_URL", _ROUTE_URL_DEFAULT)
338
- _throttle(_env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT))
339
- try:
340
- r = requests.get(
341
- base.rstrip("/") + "/" + ";".join(pairs),
342
- params={"overview": "simplified", "geometries": "geojson", "steps": "false"},
343
- headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT)},
344
- timeout=20,
345
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  r.raise_for_status()
347
  data = r.json()
348
  except requests.Timeout:
@@ -363,25 +422,28 @@ def geo_route(body: dict = Body(...), session: Session = Depends(require_session
363
  raise err(502, "route_service_unavailable",
364
  "OSRM routing could not be reached. The stop order and the straight-line "
365
  "estimate are still on the map; try again shortly.")
366
- routes = (data or {}).get("routes") or []
367
- if not routes:
368
- raise err(
369
- 502,
370
- "no_route",
371
- "No road route connects these stops. They may be on different land masses, or one of "
372
- "them may be far from any road.",
373
- )
374
- top = routes[0]
375
- geom = ((top.get("geometry") or {}).get("coordinates")) or []
376
- line = []
377
- for c in geom:
378
- try:
379
- line.append([float(c[0]), float(c[1])])
380
- except (TypeError, ValueError, IndexError):
381
- continue
382
- return {
383
- "km": round(float(top.get("distance") or 0.0) / 1000.0, 1),
384
- "minutes": int(round(float(top.get("duration") or 0.0) / 60.0)),
385
- "line": line,
386
- "attribution": provider_config()["route"]["attribution"],
387
- }
 
 
 
 
1
+ """routes_geo.py — the MAP PROVIDER SEAM (wave 37, R3/R14, tickets T35/T36/T37).
2
+
3
+ ⭐ WHY THIS FILE EXISTS AT ALL, and why the map does not simply call a vendor.
4
+
5
+ R14 (owner, 2026-08-19): no Google Maps API key this wave. The map work is not dropped, it is
6
+ retargeted to providers that need no key: OpenStreetMap tiles, OSRM routing, Nominatim geocoding.
7
+ The deferral only holds if swapping a paid vendor back in is a CONFIG change rather than a rewrite,
8
+ so every provider is a (base URL + attribution) pair read from the environment and served from ONE
9
+ door. The renderer receives a URL template and a credit line; it knows nothing about who is behind
10
+ them. That is the seam.
11
+
12
+ tiles AIOS_MAP_TILE_URL default https://tile.openstreetmap.org/{z}/{x}/{y}.png
13
+ geocode AIOS_GEOCODE_URL default https://nominatim.openstreetmap.org/search
14
+ routing AIOS_ROUTE_URL default https://router.project-osrm.org/route/v1/driving/
15
+ walking AIOS_ROUTE_URL_WALKING default https://routing.openstreetmap.de/routed-foot/route/v1/driving/
16
+
17
+ R3'S ON-DEMAND RULE, AND WHY IT IS STRUCTURAL HERE RATHER THAN A COMMENT.
18
+ R3 forbids geocoding a whole table automatically. Under R14 the reason moves from money to manners
19
+ and gets sharper: Nominatim's usage policy allows roughly one request per second and explicitly
20
+ forbids bulk harvesting, so a loop over 3,636 customers does not produce a bill, it produces a
21
+ BLOCKED tenant. Three mechanisms enforce it, none of them advisory:
22
+
23
+ 1. `/geo/geocode` takes an explicit LIST OF ADDRESSES from the caller. It cannot read a table, so
24
+ there is no code path from "a database exists" to "its rows were geocoded". Somebody had to
25
+ choose the records.
26
+ 2. `MAX_BATCH` refuses an oversized list with a named error rather than truncating it. The client
27
+ drives the loop and can therefore SHOW the wait, which is the half of the ticket a comment
28
+ cannot satisfy.
29
+ 3. `_throttle()` blocks in-process until the minimum interval has elapsed, so even a caller that
30
+ ignores everything above cannot exceed the published rate.
31
+
32
+ AND A CACHE, because the policy asks for one: an address geocoded once is answered from memory
33
+ for the rest of the container's life. It is the cheapest way to be a good citizen and it makes a
34
+ re-run of the same records free rather than merely legal.
35
+
36
+ NOTHING HERE PROXIES TILES. Tiles are fetched by the BROWSER, straight from the provider, which
37
+ is what every OSM client does and what the tile policy expects. Proxying them through this app
38
+ would put a free tier in the path of every pan and would breach the same policy it looks like it is
39
+ respecting. This door serves the tile URL, never the tile.
40
+ """
41
+ import os
42
+ import re
43
+ import threading
44
+ import time
45
+ from collections import OrderedDict
46
+
47
+ import requests
48
+ from fastapi import APIRouter, Body, Depends
49
+
50
+ from deps import Session, err, require_session
51
+
52
+ router = APIRouter(prefix="/api/v1")
53
+
54
+ # --------------------------------------------------------------------- the seam
55
+ #
56
+ # Every value below is an environment override with a keyless default. Pointing the map at a paid
57
+ # vendor is three variables on the Space and no code change, which is what makes R14 a deferral.
58
+
59
+ _TILE_URL_DEFAULT = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
60
+ _TILE_CREDIT_DEFAULT = "© OpenStreetMap contributors"
61
+ _TILE_CREDIT_HREF_DEFAULT = "https://www.openstreetmap.org/copyright"
62
+ _GEOCODE_URL_DEFAULT = "https://nominatim.openstreetmap.org/search"
63
+ _ROUTE_URL_DEFAULT = "https://router.project-osrm.org/route/v1/driving/"
64
+
65
+ #: ⭐⭐ WALKING IS A DIFFERENT SERVER, NOT A DIFFERENT PATH SEGMENT, and that was MEASURED rather
66
+ #: than assumed. The obvious implementation is to swap `driving` for `foot` in the URL above,
67
+ #: because that is what the OSRM API signature says. It returns HTTP 200 and it is a LIE: the
68
+ #: public demo server has only the car profile loaded and serves it for every profile in the path.
69
+ #: Probed 2026-08-23 over the same two New York points, `/driving/` and `/foot/` both answered
70
+ #: 10571.1 m in 969.1 s — 39 km/h, which is nobody walking. A walking toggle built that way would
71
+ #: have shipped driving minutes under a walking label, on a screen a person plans a day from.
72
+ #: FOSSGIS runs a genuinely separate foot-profile instance: the same two points come back 8429.7 m
73
+ #: in 6745.8 s = 4.5 km/h. So the mode selects a BASE URL, and each one is overridable on its own.
74
+ _ROUTE_URL_WALKING_DEFAULT = "https://routing.openstreetmap.de/routed-foot/route/v1/driving/"
75
+
76
+ #: The travel modes this door answers, each mapped to the env var holding its base URL and the
77
+ #: env var holding its credit line. A mode whose base URL resolves empty is REPORTED as
78
+ #: unavailable rather than silently falling back to another profile's numbers.
79
+ _ROUTE_MODES = {
80
+ "driving": ("AIOS_ROUTE_URL", _ROUTE_URL_DEFAULT,
81
+ "AIOS_ROUTE_ATTRIBUTION", "Routing by OSRM"),
82
+ "walking": ("AIOS_ROUTE_URL_WALKING", _ROUTE_URL_WALKING_DEFAULT,
83
+ "AIOS_ROUTE_ATTRIBUTION_WALKING", "Walking routes by OSRM / FOSSGIS"),
84
+ }
85
+
86
+
87
+ def _route_mode(raw):
88
+ """Normalise the requested travel mode, defaulting to driving."""
89
+ mode = str(raw or "driving").strip().lower()
90
+ return mode if mode in _ROUTE_MODES else "driving"
91
+
92
+
93
+ def route_mode_config(mode):
94
+ """`(base_url, attribution)` for a travel mode, both environment-overridable."""
95
+ url_var, url_default, credit_var, credit_default = _ROUTE_MODES[mode]
96
+ return _env(url_var, url_default).strip(), _env(credit_var, credit_default)
97
+
98
+ #: Both policies require a real, identifying User-Agent. A generic one is how a shared service
99
+ #: decides an application is a scraper, so this carries the product name and a contact URL.
100
+ _UA_DEFAULT = "AIOS-Loopable/1.0 (+https://runloopable.com)"
101
+
102
+ #: Nominatim publishes one request per second. 1100 ms leaves room for clock jitter rather than
103
+ #: sitting exactly on the published edge.
104
+ _MIN_INTERVAL_MS_DEFAULT = 1100
105
+
106
+ #: The most addresses one call may carry. Small on purpose: the client loops and shows progress,
107
+ #: and no single request can sit on the connection for a minute waiting out the throttle.
108
  MAX_BATCH = 25
109
  # OSRM accepts substantially more route coordinates than the geocoder batch. Keep routing useful
110
  # for a normal customer day (including the 32-stop route that exposed the old failure) without
111
  # allowing an unbounded request to occupy the provider.
112
  MAX_ROUTE_STOPS = 100
113
+
114
+ #: Tile zoom the provider actually serves. OSM stops at 19.
115
+ _TILE_MAX_Z_DEFAULT = 19
116
+
117
+ _CACHE_MAX = 4096
118
+
119
+
120
+ def _env(name, default):
121
+ return (os.environ.get(name) or "").strip() or default
122
+
123
+
124
+ def _env_int(name, default):
125
+ try:
126
+ n = int((os.environ.get(name) or "").strip())
127
+ return n if n > 0 else default
128
+ except (TypeError, ValueError):
129
+ return default
130
+
131
+
132
+ def provider_config():
133
+ """The whole seam as one dict. `GET /geo/providers` serves it and the gate reads it."""
134
+ return {
135
+ "tiles": {
136
+ "url": _env("AIOS_MAP_TILE_URL", _TILE_URL_DEFAULT),
137
+ "attribution": _env("AIOS_MAP_TILE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
138
+ "attributionUrl": _env("AIOS_MAP_TILE_ATTRIBUTION_URL", _TILE_CREDIT_HREF_DEFAULT),
139
+ "maxZoom": _env_int("AIOS_MAP_TILE_MAX_Z", _TILE_MAX_Z_DEFAULT),
140
+ },
141
+ "geocode": {
142
+ "available": True,
143
+ "maxBatch": MAX_BATCH,
144
+ "minIntervalMs": _env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT),
145
+ # ⭐⭐ THE NUMBER THE SURFACE MUST QUOTE, AND IT IS NOT THE RATE LIMIT.
146
+ # T37 asks for the estimated wait to be shown BEFORE a run. The obvious source is
147
+ # `minIntervalMs`, and it is wrong: 1.1 s is a floor on POLITENESS, not a prediction of
148
+ # LATENCY. One real lookup was MEASURED at 3.28 s end to end on 2026-08-19. Quoting the
149
+ # limit would promise a minute and take three, and a progress estimate that runs out
150
+ # before the work does reads as a hang rather than as a wait.
151
+ "secondsPerAddress": float(_env("AIOS_GEOCODE_SECONDS_EACH", "3.3")),
152
+ "attribution": _env("AIOS_GEOCODE_ATTRIBUTION", _TILE_CREDIT_DEFAULT),
153
+ },
154
+ "route": {
155
+ "available": True,
156
+ "attribution": _env("AIOS_ROUTE_ATTRIBUTION", "Routing by OSRM"),
157
+ # ⭐ THE CLIENT ASKS WHAT IS ANSWERABLE RATHER THAN OFFERING EVERY MODE AND HOPING.
158
+ # A picker that lists a mode this deployment has no router for spends a click to earn
159
+ # an error ([[permitted-is-not-answerable]]), so only the modes whose base URL is
160
+ # actually configured are named here, and the map disables the rest in place.
161
+ "modes": [
162
+ {"key": mode,
163
+ "label": "Walking" if mode == "walking" else "Driving",
164
+ "available": bool(route_mode_config(mode)[0]),
165
+ "attribution": route_mode_config(mode)[1]}
166
+ for mode in _ROUTE_MODES
167
+ ],
168
+ },
169
+ }
170
+
171
+
172
+ # ------------------------------------------------------------------ the throttle
173
+ #
174
+ # ONE lock and ONE timestamp for the whole process, shared by geocoding and routing, because the
175
+ # rate limit belongs to the SERVICE and not to the endpoint. Two independent throttles would each
176
+ # stay honest and together double the published rate.
177
+
178
+ _gate = threading.Lock()
179
+ _last_call = [0.0]
180
+
181
+
182
+ def _throttle(min_interval_ms):
183
+ """Block until the minimum interval since the last outbound call has elapsed.
184
+
185
+ Returns the seconds actually waited, so a caller can report the wait rather than hide it."""
186
+ wait = 0.0
187
+ with _gate:
188
+ gap = min_interval_ms / 1000.0
189
+ now = time.monotonic()
190
+ due = _last_call[0] + gap
191
+ if now < due:
192
+ wait = due - now
193
+ time.sleep(wait)
194
+ _last_call[0] = time.monotonic()
195
+ return wait
196
+
197
+
198
+ # --------------------------------------------------------------------- the cache
199
+
200
+ _cache = OrderedDict()
201
+ _cache_lock = threading.Lock()
202
+
203
+
204
+ def _cache_key(q, country):
205
+ return (re.sub(r"\s+", " ", str(q or "")).strip().lower(), str(country or "").strip().lower())
206
+
207
+
208
+ def _cache_get(key):
209
+ with _cache_lock:
210
+ if key not in _cache:
211
+ return None
212
+ _cache.move_to_end(key)
213
+ return _cache[key]
214
+
215
+
216
+ def _cache_put(key, value):
217
+ with _cache_lock:
218
+ _cache[key] = value
219
+ _cache.move_to_end(key)
220
+ while len(_cache) > _CACHE_MAX:
221
+ _cache.popitem(last=False)
222
+
223
+
224
+ def geocode_one(address, country=None, timeout=12):
225
+ """One address to {lat, lon, label} or None.
226
+
227
+ THE ONLY PLACE AN OUTBOUND GEOCODE HAPPENS. `ai_enrich.py`'s geocode field calls this rather
228
+ than reaching for `requests` itself, so the throttle and the cache cannot be walked around by a
229
+ second caller. A cache hit costs no request and no wait."""
230
+ q = re.sub(r"\s+", " ", str(address or "")).strip()
231
+ if not q:
232
+ return None
233
+ key = _cache_key(q, country)
234
+ hit = _cache_get(key)
235
+ if hit is not None:
236
+ return dict(hit) if hit else None
237
+
238
+ cfg = provider_config()["geocode"]
239
+ _throttle(cfg["minIntervalMs"])
240
+ params = {"q": q, "format": "jsonv2", "limit": 1}
241
+ if country:
242
+ params["countrycodes"] = str(country).strip().lower()
243
  try:
244
  r = requests.get(
245
+ _env("AIOS_GEOCODE_URL", _GEOCODE_URL_DEFAULT),
246
+ params=params,
247
+ headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT),
248
+ "Accept": "application/json"},
249
+ timeout=timeout,
250
+ )
251
+ r.raise_for_status()
252
+ rows = r.json()
253
+ except Exception:
254
+ # ⚠ A transport failure is NOT cached. Caching it would turn one bad minute into a
255
+ # permanently empty column that no re-run could ever repair.
256
+ return None
257
+ if not isinstance(rows, list) or not rows:
258
+ # A genuine "no such place" IS cached, as a negative: asking again gets the same answer and
259
+ # spends another second of a shared service's budget to hear it.
260
+ _cache_put(key, {})
261
+ return None
262
+ top = rows[0] or {}
263
+ try:
264
+ lat = float(top.get("lat"))
265
+ lon = float(top.get("lon"))
266
+ except (TypeError, ValueError):
267
+ _cache_put(key, {})
268
+ return None
269
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
270
+ _cache_put(key, {})
271
+ return None
272
+ out = {"lat": lat, "lon": lon, "label": str(top.get("display_name") or q)}
273
+ _cache_put(key, out)
274
+ return dict(out)
275
+
276
+
277
+ # ---------------------------------------------------------------------- the doors
278
+
279
+
280
+ @router.get("/geo/providers")
281
+ def geo_providers(session: Session = Depends(require_session)):
282
+ """What the map should draw with, and who to credit for it.
283
+
284
+ Session gated like every other door here. The values are not secret, but an unauthenticated
285
+ endpoint on this app is a door somebody eventually hangs something else on."""
286
+ return provider_config()
287
+
288
+
289
+ @router.post("/geo/geocode")
290
+ def geo_geocode(body: dict = Body(...), session: Session = Depends(require_session)):
291
+ """Turn a bounded list of addresses into coordinates.
292
+
293
+ ⛔ Takes ADDRESSES, never a table key, a view id or a filter. There is deliberately no shape of
294
+ request that means "geocode everything", which is R3 expressed as an API rather than as a
295
+ warning."""
296
+ items = body.get("addresses")
297
+ # ⭐ THE SECOND SHAPE, AND IT EXISTS TO KEEP ONE COMPOSER. A caller may send whole RECORDS plus
298
+ # the geocode field's config instead of finished address strings, and the server builds the
299
+ # query with `ai_enrich.geocode_query`. The alternative was a TypeScript twin of that function
300
+ # on the client, and an address composer that exists twice will disagree the first time either
301
+ # copy learns about a new column ([[one-question-two-normalizers]]) -- which is exactly the trap
302
+ # B's measured `New York (US)` suffix would spring, silently, in whichever copy forgot.
303
+ if items is None:
304
+ records = body.get("records")
305
+ cfg = body.get("config") or {}
306
+ if isinstance(records, list) and records:
307
+ import ai_enrich
308
+ items = []
309
+ for rec in records:
310
+ if not isinstance(rec, dict):
311
+ continue
312
+ q = ai_enrich.geocode_query(rec.get("row") or {}, cfg)
313
+ # ⛔ A record with no usable address is REPORTED, not dropped: it comes back
314
+ # `found: false` with an empty address, so the caller can name it on screen instead
315
+ # of quietly returning fewer answers than it asked questions.
316
+ items.append({"key": rec.get("key"), "address": q})
317
+ if not body.get("country") and cfg.get("country"):
318
+ body = {**body, "country": cfg.get("country")}
319
+ if not isinstance(items, list) or not items:
320
+ raise err(400, "no_addresses", "Send at least one address to look up.")
321
+ if len(items) > MAX_BATCH:
322
+ raise err(
323
+ 400,
324
+ "batch_too_large",
325
+ f"Look up at most {MAX_BATCH} addresses per request. "
326
+ f"The map sends them in batches of {MAX_BATCH} so the wait stays visible.",
327
+ )
328
+ country = body.get("country")
329
+ out = []
330
+ started = time.monotonic()
331
+ for raw in items:
332
+ if isinstance(raw, dict):
333
+ key, addr = raw.get("key"), raw.get("address")
334
+ else:
335
+ key, addr = None, raw
336
+ # ⛔ AN EMPTY QUERY COSTS NO REQUEST AND CARRIES ITS OWN REASON. Nominatim's policy is a
337
+ # budget shared with everyone else using it, and asking it to place "" would spend a second
338
+ # of that budget to be told nothing. `reason` is what lets the surface say "no address on
339
+ # file" rather than "not found", which are different facts and want different actions.
340
+ if not str(addr or "").strip():
341
+ out.append({"key": key, "address": "", "found": False, "lat": None, "lon": None,
342
+ "label": None, "reason": "no_address"})
343
+ continue
344
+ hit = geocode_one(addr, country=country)
345
+ out.append({"key": key, "address": str(addr or ""), "found": bool(hit),
346
+ "reason": None if hit else "not_found",
347
+ "lat": hit["lat"] if hit else None,
348
+ "lon": hit["lon"] if hit else None,
349
+ "label": hit["label"] if hit else None})
350
+ elapsed = time.monotonic() - started
351
+ return {"results": out,
352
+ "found": sum(1 for r in out if r["found"]),
353
+ "asked": len(out),
354
+ "secondsElapsed": round(elapsed, 2),
355
+ "attribution": provider_config()["geocode"]["attribution"]}
356
+
357
+
358
+ @router.post("/geo/route")
359
+ def geo_route(body: dict = Body(...), session: Session = Depends(require_session)):
360
+ """Road distance, duration and the drawn line, for stops ALREADY put in order.
361
+
362
+ ⭐ THE ORDERING IS NOT DONE HERE. `mapProjection.planRoute` sequences the stops on the client,
363
+ for free, and keeps working when this service does not answer. This door adds the half that
364
+ arithmetic cannot produce: what the ROADS actually cost. Splitting it that way is why the Start
365
+ picker and the round-trip toggle keep working with no network at all."""
366
+ stops = body.get("stops")
367
+ if not isinstance(stops, list) or len(stops) < 2:
368
+ raise err(400, "too_few_stops", "A route needs at least two stops with a location.")
369
  if len(stops) > MAX_ROUTE_STOPS:
370
  raise err(400, "too_many_stops",
371
  f"Route at most {MAX_ROUTE_STOPS} stops at once. Narrow the selection and try again.")
372
+ pairs = []
373
+ for s in stops:
374
+ try:
375
+ lat, lon = float(s["lat"]), float(s["lon"])
376
+ except (TypeError, ValueError, KeyError, IndexError):
377
+ raise err(400, "bad_stop", "Every stop needs a numeric latitude and longitude.")
378
+ if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
379
+ raise err(400, "bad_stop", "Every stop needs a latitude and longitude on the globe.")
380
+ pairs.append(f"{lon:.6f},{lat:.6f}")
381
+
382
+ # THE MODE PICKS THE SERVER. See `_ROUTE_URL_WALKING_DEFAULT` for why it is not a path
383
+ # segment: the demo car server answers `/foot/` with car numbers, so substituting the profile
384
+ # would have shipped a walking label over driving minutes.
385
+ mode = _route_mode(body.get("mode"))
386
+ base, credit = route_mode_config(mode)
387
+ if not base:
388
+ raise err(400, "route_mode_unavailable",
389
+ f"This deployment has no {'walking' if mode == 'walking' else 'driving'} "
390
+ f"routing service configured, so a {'walking' if mode == 'walking' else 'driving'} "
391
+ f"time cannot be worked out. The stop order and the straight-line estimate are "
392
+ f"still on the map.")
393
+ # ⚠ ONE throttle across BOTH routers, deliberately. The lock is process-wide and the two modes
394
+ # are two different operators, so this over-waits rather than under-waits when a person flips
395
+ # the toggle. That is the safe direction for a courtesy limit on a free shared service, and it
396
+ # keeps the single-timestamp invariant the module header argues for.
397
+ _throttle(_env_int("AIOS_GEOCODE_MIN_INTERVAL_MS", _MIN_INTERVAL_MS_DEFAULT))
398
+ try:
399
+ r = requests.get(
400
+ base.rstrip("/") + "/" + ";".join(pairs),
401
+ params={"overview": "simplified", "geometries": "geojson", "steps": "false"},
402
+ headers={"User-Agent": _env("AIOS_GEO_USER_AGENT", _UA_DEFAULT)},
403
+ timeout=20,
404
+ )
405
  r.raise_for_status()
406
  data = r.json()
407
  except requests.Timeout:
 
422
  raise err(502, "route_service_unavailable",
423
  "OSRM routing could not be reached. The stop order and the straight-line "
424
  "estimate are still on the map; try again shortly.")
425
+ routes = (data or {}).get("routes") or []
426
+ if not routes:
427
+ raise err(
428
+ 502,
429
+ "no_route",
430
+ "No road route connects these stops. They may be on different land masses, or one of "
431
+ "them may be far from any road.",
432
+ )
433
+ top = routes[0]
434
+ geom = ((top.get("geometry") or {}).get("coordinates")) or []
435
+ line = []
436
+ for c in geom:
437
+ try:
438
+ line.append([float(c[0]), float(c[1])])
439
+ except (TypeError, ValueError, IndexError):
440
+ continue
441
+ return {
442
+ "km": round(float(top.get("distance") or 0.0) / 1000.0, 1),
443
+ "minutes": int(round(float(top.get("duration") or 0.0) / 60.0)),
444
+ "line": line,
445
+ # The mode rides the answer so the client cannot label a driving route as a walk when a
446
+ # request is in flight and the toggle moves underneath it.
447
+ "mode": mode,
448
+ "attribution": credit,
449
+ }
web/src/customer-grid/CustomerGrid.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/MapView.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/apiBridge.ts CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/map.css CHANGED
@@ -1,109 +1,109 @@
1
- /* customer-grid/map.css — W37-T35/T36, the real-tile basemap and its credit line.
2
- *
3
- * ⛔ WHY THIS FILE EXISTS AND NOT A BLOCK IN index.css: contract C8. `index.css` is 12k+ lines and
4
- * belongs to lane A this wave, so every other lane adds styles in its own per-surface stylesheet
5
- * imported by its own component. `scriptView.css` and `navExtras.css` are the precedent.
6
- *
7
- * ⚠ NOTHING HERE MAY ADD `vector-effect: non-scaling-stroke` TO A `.cg-map*` RULE. The map divides
8
- * every stroke by the zoom in the markup (`hairline`), so a CSS rule that also cancels the scale
9
- * double-compensates, and that is the wave-9 "blurry when zoomed in" bug. `map.test.ts` scans
10
- * `index.css` for it and cannot see this file, so `verify_map.py::css_scan` applies the same rule
11
- * here. A new stylesheet that escapes an existing gate is a gate quietly getting smaller.
12
- */
13
-
14
- /* --- the tile layer -------------------------------------------------------
15
- *
16
- * Tiles are <image> elements INSIDE the same transformed <g> the pins live in, so they pan and
17
- * zoom with the geography for free and no second camera can drift out of step with the first.
18
- */
19
-
20
- .cg-map-tiles {
21
- /* A raster basemap is a photograph of a map, so it must never intercept a gesture: every
22
- pointer event belongs to the pins and the marquee above it. */
23
- pointer-events: none;
24
- /* ⭐ THE DARK-MODE SEAM, and it is deliberately INERT today. This app ships no dark theme (there
25
- is not one `prefers-color-scheme` rule in `index.css`), so an inverting media query here would
26
- turn the basemap negative for anyone whose OS is dark while the rest of the product stayed
27
- light, which is a defect rather than a feature. Setting `--cg-tile-filter` is one line on the
28
- day a theme arrives. */
29
- filter: var(--cg-tile-filter, none);
30
- }
31
-
32
- .cg-map-tile {
33
- /* Tile edges are cut to the pixel, so any smoothing at the seam paints a visible grid over the
34
- whole basemap. */
35
- image-rendering: -webkit-optimize-contrast;
36
- }
37
-
38
- /* The tile the browser has not fetched yet. Without this a pan shows the page background through
39
- the gaps, which reads as holes in the map rather than as loading. */
40
- .cg-map-tilebed {
41
- fill: #eef2f7;
42
- }
43
-
44
- /* --- attribution ----------------------------------------------------------
45
- *
46
- * ⛔ THIS IS A LICENCE TERM, NOT DECORATION. OpenStreetMap's terms require visible credit wherever
47
- * its tiles are shown, and the same applies to whatever provider replaces it: the text and the
48
- * link both come from the server's provider config, so a swap carries its own credit with it and
49
- * cannot leave the previous one on screen.
50
- */
51
-
52
- .cg-map-credit {
53
- position: absolute;
54
- right: 6px;
55
- bottom: 4px;
56
- z-index: 2;
57
- padding: 1px 6px;
58
- border-radius: 4px;
59
- background: rgba(255, 255, 255, 0.82);
60
- /* ⛔ A TOKEN, NOT A LITERAL (R5, and A-53 caught the 10.5px this replaced). A hardcoded size
61
- becomes THE size the day the type scale moves, so a stylesheet that invents one is a second
62
- source of truth. `--lp-fs-4xs` is the 10px step, which is what a credit line wants. */
63
- font: 400 var(--lp-fs-4xs)/1.5 Inter, system-ui, sans-serif;
64
- color: #5b6270;
65
- letter-spacing: 0.1px;
66
- }
67
-
68
- .cg-map-credit a {
69
- color: #4a5568;
70
- text-decoration: none;
71
- }
72
-
73
- .cg-map-credit a:hover {
74
- text-decoration: underline;
75
- }
76
-
77
- /* --- the route line (T36) -------------------------------------------------
78
- *
79
- * The ROAD geometry, as distinct from `.cg-map-route-line`, which is the straight-line plan drawn
80
- * from arithmetic alone. Two different claims about the same stops, so two different looks: the
81
- * road route is solid and confident, the straight-line plan stays dashed and provisional.
82
- */
83
-
84
- .cg-map-road {
85
- fill: none;
86
- stroke: #1f4e78;
87
- stroke-opacity: 0.85;
88
- stroke-linecap: round;
89
- stroke-linejoin: round;
90
- }
91
-
92
- .cg-map-road-halo {
93
- fill: none;
94
- stroke: #ffffff;
95
- stroke-opacity: 0.75;
96
- stroke-linecap: round;
97
- stroke-linejoin: round;
98
- }
99
-
100
- /* --- the ordered stop list (T36) ------------------------------------------
101
- *
102
  * The route's stops are a vertical stack of numbered chips with move controls in the route rail
103
  * beside View. The map remains visible beside the rail, so reordering is still done while looking
104
  * at the picture without putting route chrome across its header.
105
- */
106
-
107
  .cg-map-stoplist {
108
  display: flex;
109
  flex-direction: column;
@@ -117,76 +117,99 @@
117
  padding-bottom: 2px;
118
  scrollbar-width: thin;
119
  }
120
-
 
 
 
 
 
121
  .cg-map-stopitem {
122
  display: flex;
123
  align-items: center;
124
  gap: 2px;
125
  min-width: 0;
126
  width: 100%;
127
- padding: 1px 2px 1px 4px;
128
- border: 1px solid #dfe3ea;
129
- border-radius: 11px;
130
- background: #fff;
131
- font: 500 var(--lp-fs-3xs)/1.6 Inter, system-ui, sans-serif;
132
- color: #2c3340;
133
- }
134
-
135
- .cg-map-stopn {
136
- display: inline-grid;
137
- place-items: center;
138
- width: 15px;
139
- height: 15px;
140
- border-radius: 50%;
141
- background: #1f4e78;
142
- color: #fff;
143
- font-size: var(--lp-fs-5xs);
144
- font-weight: 600;
145
- }
146
-
 
 
 
 
147
  .cg-map-stopname {
148
  min-width: 0;
149
  max-width: none;
150
  flex: 1 1 auto;
151
- overflow: hidden;
152
- text-overflow: ellipsis;
153
- white-space: nowrap;
154
- }
155
-
156
- .cg-map-stopmove {
157
- display: inline-grid;
158
- place-items: center;
159
- width: 15px;
160
- height: 15px;
161
- padding: 0;
162
- border: 0;
163
- border-radius: 3px;
164
- background: none;
165
- color: #6b7280;
166
- cursor: pointer;
167
- }
168
-
169
- .cg-map-stopmove:hover:not(:disabled) {
170
- background: #eef2f7;
171
- color: #1f4e78;
172
- }
173
-
174
- .cg-map-stopmove:disabled {
175
- /* Visible but inert at the ends of the list. Removing the control instead would make the row
176
- change width as a stop moves, so the button you are aiming at walks away from the cursor. */
177
- opacity: 0.28;
178
- cursor: default;
179
- }
180
-
181
- .cg-map-stopmove svg {
182
- width: 11px;
183
- height: 11px;
184
- fill: none;
185
- stroke: currentColor;
186
- stroke-width: 1.7;
187
- stroke-linecap: round;
188
- stroke-linejoin: round;
189
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  /* --- the address lookup (T37) ---------------------------------------------
192
  *
 
1
+ /* customer-grid/map.css — W37-T35/T36, the real-tile basemap and its credit line.
2
+ *
3
+ * ⛔ WHY THIS FILE EXISTS AND NOT A BLOCK IN index.css: contract C8. `index.css` is 12k+ lines and
4
+ * belongs to lane A this wave, so every other lane adds styles in its own per-surface stylesheet
5
+ * imported by its own component. `scriptView.css` and `navExtras.css` are the precedent.
6
+ *
7
+ * ⚠ NOTHING HERE MAY ADD `vector-effect: non-scaling-stroke` TO A `.cg-map*` RULE. The map divides
8
+ * every stroke by the zoom in the markup (`hairline`), so a CSS rule that also cancels the scale
9
+ * double-compensates, and that is the wave-9 "blurry when zoomed in" bug. `map.test.ts` scans
10
+ * `index.css` for it and cannot see this file, so `verify_map.py::css_scan` applies the same rule
11
+ * here. A new stylesheet that escapes an existing gate is a gate quietly getting smaller.
12
+ */
13
+
14
+ /* --- the tile layer -------------------------------------------------------
15
+ *
16
+ * Tiles are <image> elements INSIDE the same transformed <g> the pins live in, so they pan and
17
+ * zoom with the geography for free and no second camera can drift out of step with the first.
18
+ */
19
+
20
+ .cg-map-tiles {
21
+ /* A raster basemap is a photograph of a map, so it must never intercept a gesture: every
22
+ pointer event belongs to the pins and the marquee above it. */
23
+ pointer-events: none;
24
+ /* ⭐ THE DARK-MODE SEAM, and it is deliberately INERT today. This app ships no dark theme (there
25
+ is not one `prefers-color-scheme` rule in `index.css`), so an inverting media query here would
26
+ turn the basemap negative for anyone whose OS is dark while the rest of the product stayed
27
+ light, which is a defect rather than a feature. Setting `--cg-tile-filter` is one line on the
28
+ day a theme arrives. */
29
+ filter: var(--cg-tile-filter, none);
30
+ }
31
+
32
+ .cg-map-tile {
33
+ /* Tile edges are cut to the pixel, so any smoothing at the seam paints a visible grid over the
34
+ whole basemap. */
35
+ image-rendering: -webkit-optimize-contrast;
36
+ }
37
+
38
+ /* The tile the browser has not fetched yet. Without this a pan shows the page background through
39
+ the gaps, which reads as holes in the map rather than as loading. */
40
+ .cg-map-tilebed {
41
+ fill: #eef2f7;
42
+ }
43
+
44
+ /* --- attribution ----------------------------------------------------------
45
+ *
46
+ * ⛔ THIS IS A LICENCE TERM, NOT DECORATION. OpenStreetMap's terms require visible credit wherever
47
+ * its tiles are shown, and the same applies to whatever provider replaces it: the text and the
48
+ * link both come from the server's provider config, so a swap carries its own credit with it and
49
+ * cannot leave the previous one on screen.
50
+ */
51
+
52
+ .cg-map-credit {
53
+ position: absolute;
54
+ right: 6px;
55
+ bottom: 4px;
56
+ z-index: 2;
57
+ padding: 1px 6px;
58
+ border-radius: 4px;
59
+ background: rgba(255, 255, 255, 0.82);
60
+ /* ⛔ A TOKEN, NOT A LITERAL (R5, and A-53 caught the 10.5px this replaced). A hardcoded size
61
+ becomes THE size the day the type scale moves, so a stylesheet that invents one is a second
62
+ source of truth. `--lp-fs-4xs` is the 10px step, which is what a credit line wants. */
63
+ font: 400 var(--lp-fs-4xs)/1.5 Inter, system-ui, sans-serif;
64
+ color: #5b6270;
65
+ letter-spacing: 0.1px;
66
+ }
67
+
68
+ .cg-map-credit a {
69
+ color: #4a5568;
70
+ text-decoration: none;
71
+ }
72
+
73
+ .cg-map-credit a:hover {
74
+ text-decoration: underline;
75
+ }
76
+
77
+ /* --- the route line (T36) -------------------------------------------------
78
+ *
79
+ * The ROAD geometry, as distinct from `.cg-map-route-line`, which is the straight-line plan drawn
80
+ * from arithmetic alone. Two different claims about the same stops, so two different looks: the
81
+ * road route is solid and confident, the straight-line plan stays dashed and provisional.
82
+ */
83
+
84
+ .cg-map-road {
85
+ fill: none;
86
+ stroke: #1f4e78;
87
+ stroke-opacity: 0.85;
88
+ stroke-linecap: round;
89
+ stroke-linejoin: round;
90
+ }
91
+
92
+ .cg-map-road-halo {
93
+ fill: none;
94
+ stroke: #ffffff;
95
+ stroke-opacity: 0.75;
96
+ stroke-linecap: round;
97
+ stroke-linejoin: round;
98
+ }
99
+
100
+ /* --- the ordered stop list (T36) ------------------------------------------
101
+ *
102
  * The route's stops are a vertical stack of numbered chips with move controls in the route rail
103
  * beside View. The map remains visible beside the rail, so reordering is still done while looking
104
  * at the picture without putting route chrome across its header.
105
+ */
106
+
107
  .cg-map-stoplist {
108
  display: flex;
109
  flex-direction: column;
 
117
  padding-bottom: 2px;
118
  scrollbar-width: thin;
119
  }
120
+
121
+ /* ⭐ OWNER ITEM 8/9 (2026-08-23) — TOKENS, NOT HEXES. Every colour in this block was a literal
122
+ (`#dfe3ea`, `#2c3340`, `#1f4e78`, `#6b7280`, `#eef2f7`), which /design §8 names as a slop tell
123
+ and DESIGN.md §5 makes concrete: a hex that lives in one file drifts with nothing noticing,
124
+ and `verify_icons.read_tokens` cannot see a value written this way at all. The rows also grew
125
+ a third control this pass, so they are sized for three rather than two. */
126
  .cg-map-stopitem {
127
  display: flex;
128
  align-items: center;
129
  gap: 2px;
130
  min-width: 0;
131
  width: 100%;
132
+ padding: 2px 3px 2px 5px;
133
+ border: 1px solid var(--lp-line);
134
+ border-radius: var(--lp-r-md);
135
+ background: var(--cg-white);
136
+ font: 500 var(--lp-fs-3xs)/1.6 Inter, system-ui, sans-serif;
137
+ color: var(--cg-text);
138
+ }
139
+
140
+ .cg-map-stopitem:hover { background: var(--cg-soft); }
141
+
142
+ .cg-map-stopn {
143
+ display: inline-grid;
144
+ place-items: center;
145
+ flex: 0 0 auto;
146
+ width: 16px;
147
+ height: 16px;
148
+ border-radius: 50%;
149
+ background: var(--lp-primary);
150
+ /* ⚠ The light ink is declared in the SAME rule as the dark fill (/design §8). */
151
+ color: var(--cg-white);
152
+ font-size: var(--lp-fs-5xs);
153
+ font-weight: 600;
154
+ }
155
+
156
  .cg-map-stopname {
157
  min-width: 0;
158
  max-width: none;
159
  flex: 1 1 auto;
160
+ overflow: hidden;
161
+ text-overflow: ellipsis;
162
+ white-space: nowrap;
163
+ }
164
+
165
+ .cg-map-stopmove {
166
+ display: inline-grid;
167
+ place-items: center;
168
+ flex: 0 0 auto;
169
+ width: 17px;
170
+ height: 17px;
171
+ padding: 0;
172
+ border: 0;
173
+ border-radius: 4px;
174
+ background: none;
175
+ color: var(--lp-muted);
176
+ cursor: pointer;
177
+ }
178
+
179
+ .cg-map-stopmove:hover:not(:disabled) {
180
+ background: var(--lp-blue-tint);
181
+ color: var(--lp-blue-deep);
182
+ }
183
+
184
+ .cg-map-stopmove:focus-visible {
185
+ outline: 2px solid var(--lp-blue-deep);
186
+ outline-offset: -1px;
187
+ }
188
+
189
+ /* ⭐ OWNER ITEM 9 — TAKE THIS STOP OFF THE ROUTE. It hovers in the DANGER hue rather than the
190
+ blue the move controls use, because the two arrows rearrange a day and this one shortens it;
191
+ painting all three alike is how a person removes a customer while meaning to reorder one. */
192
+ .cg-map-stopdrop:hover:not(:disabled) {
193
+ background: var(--lp-red-tint);
194
+ color: var(--lp-red-deep);
195
+ }
196
+
197
+ .cg-map-stopmove:disabled {
198
+ /* Visible but inert at the ends of the list. Removing the control instead would make the row
199
+ change width as a stop moves, so the button you are aiming at walks away from the cursor. */
200
+ opacity: 0.28;
201
+ cursor: default;
202
+ }
203
+
204
+ .cg-map-stopmove svg {
205
+ width: 11px;
206
+ height: 11px;
207
+ fill: none;
208
+ stroke: currentColor;
209
+ stroke-width: 1.7;
210
+ stroke-linecap: round;
211
+ stroke-linejoin: round;
212
+ }
213
 
214
  /* --- the address lookup (T37) ---------------------------------------------
215
  *
web/src/customer-grid/types.ts CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/viewModes.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/index.css CHANGED
The diff for this file is too large to render. See raw diff