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
190
tools/citymap/build_geojson.py
Normal file
190
tools/citymap/build_geojson.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the fitted transform and emit the final parking-areas GeoJSON.
|
||||
|
||||
Also verifies the result the only way that matters: every stroked segment should
|
||||
land on an actual road, so measure each one's distance to the nearest OSM road
|
||||
centreline. Lots are skipped in that check — they are off-street by definition.
|
||||
|
||||
Each feature gets a human name from OSM (the street it runs along, plus the two
|
||||
cross streets it lies between) so the app can list areas without the map.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
COS = math.cos(math.radians(48.278))
|
||||
|
||||
fit = json.load(open("fit_raw.json"))
|
||||
SX, TX, SY, TY = fit["sx"], fit["tx"], fit["sy"], fit["ty"]
|
||||
|
||||
|
||||
def to_merc(x, y):
|
||||
return (SX * x + TX, SY * y + TY)
|
||||
|
||||
|
||||
def to_lonlat(x, y):
|
||||
X, Y = to_merc(x, y)
|
||||
return (round(math.degrees(X / R), 7), round(math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), 7))
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
px, py = p
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = dx * dx + dy * dy
|
||||
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- OSM roads
|
||||
osm = json.load(open("osm.json"))
|
||||
SKIP = {"footway", "path", "cycleway", "steps", "track", "service"}
|
||||
roads = [] # (a, b, name) in mercator
|
||||
for w in osm["elements"]:
|
||||
t = w.get("tags", {})
|
||||
if t.get("highway") in SKIP or "geometry" not in w:
|
||||
continue
|
||||
g = [merc(p["lat"], p["lon"]) for p in w["geometry"]]
|
||||
nm = t.get("name")
|
||||
for a, b in zip(g, g[1:]):
|
||||
roads.append((a, b, nm))
|
||||
named = [r for r in roads if r[2]]
|
||||
|
||||
SHORT = [
|
||||
("North ", "N "), ("South ", "S "), ("East ", "E "), ("West ", "W "),
|
||||
(" Street", " St"), (" Avenue", " Ave"), (" Boulevard", " Blvd"),
|
||||
(" Road", " Rd"), (" Drive", " Dr"), (" Lane", " Ln"), (" Bridge", " Brg"),
|
||||
]
|
||||
|
||||
|
||||
def short(n):
|
||||
for a, b in SHORT:
|
||||
n = n.replace(a, b)
|
||||
return n
|
||||
|
||||
|
||||
def nearest_name(p, exclude=None, limit=60.0):
|
||||
best, bestd = None, limit
|
||||
for a, b, nm in named:
|
||||
if nm == exclude:
|
||||
continue
|
||||
d = dist_to_seg(p, a, b)
|
||||
if d < bestd:
|
||||
best, bestd = nm, d
|
||||
return best
|
||||
|
||||
|
||||
def describe(pts_merc, is_line):
|
||||
"""'N 3rd Ave · Cedar St to Oak St' for a segment, or the nearest road for a lot."""
|
||||
mid = pts_merc[len(pts_merc) // 2]
|
||||
on = nearest_name(mid) if is_line else None
|
||||
if not is_line:
|
||||
near = nearest_name(mid, limit=200.0)
|
||||
return f"Lot off {short(near)}" if near else "City lot"
|
||||
ends = [pts_merc[0], pts_merc[-1]]
|
||||
cross = []
|
||||
for e in ends:
|
||||
c = nearest_name(e, exclude=on, limit=45.0)
|
||||
if c and short(c) not in cross:
|
||||
cross.append(short(c))
|
||||
if not on:
|
||||
# Bays along the old rail corridor sit on no named road — describe them
|
||||
# by what they are near rather than inventing a street.
|
||||
near = nearest_name(mid, limit=150.0)
|
||||
return f"Off-street bays near {short(near)}" if near else "Off-street bays"
|
||||
base = short(on)
|
||||
if len(cross) == 2:
|
||||
return f"{base} · {cross[0]} to {cross[1]}"
|
||||
if len(cross) == 1:
|
||||
return f"{base} · at {cross[0]}"
|
||||
return base
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build output
|
||||
page = json.load(open("map_page_coords.json"))
|
||||
|
||||
|
||||
def is_legend(f):
|
||||
"""The five legend swatches: stroke-width 7 sitting in the legend card's x-band."""
|
||||
x0, y0, x1, y1 = f["bbox"]
|
||||
return f["strokeWidth"] > 5 and 25 < x0 < 28 and 60 < y0 < 130
|
||||
|
||||
|
||||
# kind -> (short label, legend text, default tracked hours, colour)
|
||||
KINDS = {
|
||||
"green_lot": ("City lot", "Paid hourly or permit", 2, "#75b259"),
|
||||
"free_2h": ("2-hour free", "Permits not valid", 2, "#d367cc"),
|
||||
"limit_3h": ("3-hour", "3-hour or permit", 3, "#ccc542"),
|
||||
"limit_4h": ("4-hour", "4-hour or permit", 4, "#f78b08"),
|
||||
"no_limit": ("No time limit", "No posted time limit", 0, "#c3c4c2"),
|
||||
}
|
||||
|
||||
features = []
|
||||
n_legend = 0
|
||||
for i, f in enumerate(page["features"]):
|
||||
if is_legend(f):
|
||||
n_legend += 1
|
||||
continue
|
||||
is_line = f["geom"] == "line"
|
||||
pm = [to_merc(x, y) for x, y in f["points"]]
|
||||
coords = [to_lonlat(x, y) for x, y in f["points"]]
|
||||
if is_line:
|
||||
geom = {"type": "LineString", "coordinates": coords}
|
||||
else:
|
||||
if coords[0] != coords[-1]:
|
||||
coords.append(coords[0])
|
||||
geom = {"type": "Polygon", "coordinates": [coords]}
|
||||
label, legend, hours, color = KINDS[f["kind"]]
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"sp-{i:03d}",
|
||||
"geometry": geom,
|
||||
"properties": {
|
||||
"id": f"sp-{i:03d}",
|
||||
"kind": f["kind"],
|
||||
"label": label,
|
||||
"legend": legend,
|
||||
"hours": hours,
|
||||
"color": color,
|
||||
"shape": "line" if is_line else "polygon",
|
||||
"name": describe(pm, is_line),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fc = {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"metadata": {
|
||||
"source": "City of Sandpoint — Downtown & Waterfront Public Parking map",
|
||||
"generated": "from downtown_and_waterfront_public_parking_map.pdf",
|
||||
"georeference": "affine page->WebMercator fitted to OSM street centrelines",
|
||||
},
|
||||
}
|
||||
json.dump(fc, open("parking_areas.geojson", "w"), indent=1)
|
||||
print(f"{len(features)} features written ({n_legend} legend swatches dropped)\n")
|
||||
for f in features:
|
||||
p = f["properties"]
|
||||
print(f" {p['id']} {p['kind']:10s} {p['shape']:7s} {p['name']}")
|
||||
|
||||
# ---- verification: distance from each on-street segment to the nearest road
|
||||
worst = []
|
||||
for f in features:
|
||||
if f["properties"]["shape"] != "line":
|
||||
continue
|
||||
ds = [min(dist_to_seg(merc(lat, lon), a, b) for a, b, _ in roads) * COS
|
||||
for lon, lat in f["geometry"]["coordinates"]]
|
||||
worst.append((max(ds), sum(ds) / len(ds), f["properties"]["id"], f["properties"]["kind"]))
|
||||
|
||||
worst.sort(reverse=True)
|
||||
print(f"\non-street segments: {len(worst)}, mean offset from nearest road = "
|
||||
f"{sum(m for _, m, _, _ in worst)/len(worst):.1f} m")
|
||||
print(f"segments with mean offset > 10 m: {sum(1 for _, m, _, _ in worst if m > 10)}")
|
||||
for mx, mn, fid, kind in worst[:4]:
|
||||
print(f" worst: {fid} {kind:10s} max={mx:6.1f} m mean={mn:6.1f} m")
|
||||
Loading…
Add table
Add a link
Reference in a new issue