All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
72 lines
3.4 KiB
Markdown
72 lines
3.4 KiB
Markdown
# Georeferencing the city parking map
|
|
|
|
Turns the City of Sandpoint's printed **Downtown & Waterfront Public Parking** PDF into
|
|
`app/src/features/citymap/parkingAreas.json` — the colour-coded overlay the app draws and
|
|
hit-tests against.
|
|
|
|
Run this again when the city publishes a new edition of the map.
|
|
|
|
## Why it needs doing at all
|
|
|
|
The PDF carries **no** georeferencing metadata (no `/Measure`, `/GPTS`, `/LPTS`, `/GEO`,
|
|
`/Viewport`, `/GCS`). It is a north-up Web Mercator screenshot of a slippy map with vector
|
|
parking stripes drawn on top, so page coordinates relate to the world by a plain affine
|
|
transform — which has to be recovered by fitting the drawing to something we already know
|
|
the coordinates of. That something is OpenStreetMap's street centrelines.
|
|
|
|
Two structural details cost the most time, so they are worth knowing up front:
|
|
|
|
- `pdftocairo` writes each **stroked** street segment with its own `matrix()` transform and
|
|
*local* coordinates, while **filled** lots are in absolute page coordinates. Both have to
|
|
be handled or the streets land in a heap near the origin.
|
|
- The legend swatches are drawn in the same five colours as the real geometry. They are
|
|
identified by stroke-width 7 inside the legend card's x-band and dropped.
|
|
|
|
## Pipeline
|
|
|
|
```bash
|
|
# 0. deps: poppler-utils (pdftocairo, pdftotext, pdfimages), python3, curl
|
|
pdftocairo -svg downtown_and_waterfront_public_parking_map.pdf map.svg
|
|
|
|
# 1. vector geometry -> page coordinates, bucketed by the legend's five colours
|
|
python3 extract_map.py map.svg map_page_coords.json
|
|
|
|
# 2. OSM street centrelines for downtown Sandpoint
|
|
curl -s --data-binary @roads.overpass https://overpass-api.de/api/interpreter -o osm.json
|
|
|
|
# 3. fit page -> Web Mercator against named streets; prints per-street residuals
|
|
python3 georef.py # writes fit_raw.json
|
|
|
|
# 4. apply the fit, name each area from OSM, verify, emit the GeoJSON
|
|
python3 build_geojson.py # writes parking_areas.geojson
|
|
|
|
cp parking_areas.geojson ../../app/src/features/citymap/parkingAreas.json
|
|
```
|
|
|
|
## What "good" looks like
|
|
|
|
`georef.py` prints a residual per control street and `build_geojson.py` prints how far each
|
|
on-street segment sits from the nearest OSM road. The current edition fits to:
|
|
|
|
| Check | Result |
|
|
| --- | --- |
|
|
| X control residual (avenues) | **RMS 4.1 m** |
|
|
| Y control residual (streets) | **RMS 3.5 m** |
|
|
| On-street segments vs nearest OSM road | **mean 4.0 m**, 40 of 42 under 10 m |
|
|
|
|
The two segments over 10 m (`sp-039`, `sp-040`) are correct, not errors: they are angled
|
|
bays along the old rail corridor that sit on no named road at all — the nearest way is a
|
|
service alley 19 m off. Anything much worse than the table above means a control street was
|
|
mis-identified; `georef.py`'s per-street residuals will say which.
|
|
|
|
Residual error is also correctable after the fact without re-running any of this — the app's
|
|
**Account → Align city map** screen nudges the whole overlay against a live GPS fix and
|
|
persists the correction.
|
|
|
|
## Control points
|
|
|
|
`georef.py` maps page grid lines to OSM street names by hand (`AVENUES` / `STREETS`). Sandpoint's
|
|
grid **jogs** between its north and south halves — North 2nd Ave and South 2nd Ave are 38 m
|
|
apart — so each control street is measured only over the span its page segment actually
|
|
covers, and both halves are used as independent control points. That jog is a useful sanity
|
|
check: the page shows the same 9.3 pt offset, which at the fitted scale is 38 m.
|