v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s
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:
parent
ad55559f55
commit
148e1635d3
23 changed files with 3027 additions and 21 deletions
195
tools/citymap/extract_map.py
Normal file
195
tools/citymap/extract_map.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue