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>
195 lines
6.3 KiB
Python
195 lines
6.3 KiB
Python
#!/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()
|