BigBrainParking/tools/citymap
Erik 148e1635d3
All checks were successful
build-apk / build (push) Successful in 10m38s
v0.6.0: city parking-map overlay + local time tracking, no IPS API
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>
2026-08-13 03:28:18 +00:00
..
build_geojson.py v0.6.0: city parking-map overlay + local time tracking, no IPS API 2026-08-13 03:28:18 +00:00
extract_map.py v0.6.0: city parking-map overlay + local time tracking, no IPS API 2026-08-13 03:28:18 +00:00
georef.py v0.6.0: city parking-map overlay + local time tracking, no IPS API 2026-08-13 03:28:18 +00:00
README.md v0.6.0: city parking-map overlay + local time tracking, no IPS API 2026-08-13 03:28:18 +00:00
roads.overpass v0.6.0: city parking-map overlay + local time tracking, no IPS API 2026-08-13 03:28:18 +00:00

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

# 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.