v0.6.0: city parking-map overlay + local time tracking, no IPS API
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>
This commit is contained in:
Erik 2026-08-13 03:28:18 +00:00
parent ad55559f55
commit 148e1635d3
23 changed files with 3027 additions and 21 deletions

View file

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Extract the color-coded parking geometry from the city's parking-map PDF.
pdftocairo emits every street segment as a stroked <path> carrying its own
matrix() transform with local coordinates, and every lot as an absolute filled
<path>. So: parse the path, flatten curves, push through the path's own matrix,
and bucket by the exact colour pdftocairo wrote.
Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the
georeferencing step turns that into lat/lon.
"""
import json
import re
import sys
from xml.etree import ElementTree as ET
SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg"
OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json"
# Colours exactly as pdftocairo writes them, mapped to the map legend.
COLORS = {
"rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit",
"rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h",
"rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h",
"rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h",
"rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot",
}
NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
def parse_matrix(s):
"""matrix(a,b,c,d,e,f) -> tuple. Identity when absent."""
if not s:
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
m = re.search(r"matrix\s*\(([^)]*)\)", s)
if not m:
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
v = [float(x) for x in NUM.findall(m.group(1))]
return tuple(v[:6])
def apply(mtx, x, y):
a, b, c, d, e, f = mtx
return (a * x + c * y + e, b * x + d * y + f)
def bezier(p0, p1, p2, p3, steps=8):
"""Flatten a cubic to points. The map's curves are gentle; 8 is plenty."""
out = []
for i in range(1, steps + 1):
t = i / steps
u = 1 - t
out.append(
(
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
)
)
return out
def parse_path(d):
"""Return a list of subpaths [(points, closed)] in the path's local space."""
tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d)
subpaths, pts = [], []
cur = (0.0, 0.0)
start = (0.0, 0.0)
cmd = None
i = 0
flat = [(c, n) for c, n in tokens]
def nums(k):
nonlocal i
vals = []
while len(vals) < k and i < len(flat) and flat[i][1]:
vals.append(float(flat[i][1]))
i += 1
return vals
while i < len(flat):
c, n = flat[i]
if c:
cmd = c
i += 1
elif cmd is None:
i += 1
continue
if cmd in "Zz":
if pts:
subpaths.append((pts, True))
pts = []
cur = start
cmd = None
continue
if cmd in "Mm":
v = nums(2)
if len(v) < 2:
break
if pts:
subpaths.append((pts, False))
pts = []
cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1])
start = cur
pts = [cur]
cmd = "L" if cmd == "M" else "l"
elif cmd in "Ll":
v = nums(2)
if len(v) < 2:
break
cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1])
pts.append(cur)
elif cmd in "Hh":
v = nums(1)
if not v:
break
cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1])
pts.append(cur)
elif cmd in "Vv":
v = nums(1)
if not v:
break
cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0])
pts.append(cur)
elif cmd in "Cc":
v = nums(6)
if len(v) < 6:
break
if cmd == "C":
p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5])
else:
p1 = (cur[0] + v[0], cur[1] + v[1])
p2 = (cur[0] + v[2], cur[1] + v[3])
p3 = (cur[0] + v[4], cur[1] + v[5])
pts.extend(bezier(cur, p1, p2, p3))
cur = p3
else:
i += 1
if pts:
subpaths.append((pts, False))
return subpaths
def main():
tree = ET.parse(SVG)
root = tree.getroot()
ns = "{http://www.w3.org/2000/svg}"
features = []
for el in root.iter(ns + "path"):
stroke = (el.get("stroke") or "").strip()
fill = (el.get("fill") or "").strip()
kind = COLORS.get(stroke) or COLORS.get(fill)
if not kind:
continue
is_stroke = stroke in COLORS
mtx = parse_matrix(el.get("transform"))
width = float(el.get("stroke-width") or 0)
for local, closed in parse_path(el.get("d") or ""):
world = [apply(mtx, x, y) for x, y in local]
if len(world) < 2:
continue
xs = [p[0] for p in world]
ys = [p[1] for p in world]
features.append(
{
"kind": kind,
"geom": "line" if is_stroke else "polygon",
"closed": closed,
"strokeWidth": width,
"bbox": [min(xs), min(ys), max(xs), max(ys)],
"points": [[round(x, 3), round(y, 3)] for x, y in world],
}
)
with open(OUT, "w") as fh:
json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1)
from collections import Counter
print(f"{len(features)} features -> {OUT}")
for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()):
print(f" {k:10s} {g:8s} {n}")
# Where are they? Legend swatches cluster in one corner; real geometry spreads out.
print("\nbbox spread by kind:")
for k in COLORS.values():
fs = [f for f in features if f["kind"] == k]
if not fs:
continue
print(
f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] "
f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]"
)
main()