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

@ -39,6 +39,8 @@ bigbrainparking/
| --- | --- | --- |
| Phone + password login | ✅ wired | tokens in OS keystore |
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** |
| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown |
| Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" |
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
@ -48,6 +50,35 @@ bigbrainparking/
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
## The city parking map
The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown &
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
**None of it touches ParkSmarter.** The areas live in the local database (bundled with the
app, refreshed from the zone-labels server, cached on-device), the countdown is the phone's
own clock, and the notification is the same foreground service every other session uses. So
tracking your time on a city spot works with no account, no signal, no payment, and in
Anonymous Mode. Two ways to start:
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
stays on the map until you end the session, because "where did I leave the car" is half
the point.
- **Tap a coloured segment** — pick the block directly, no pin needed.
Either way you choose how long to track, capped at the posted limit (a 2-hour space won't
offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
edits the local timer and says so.
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
Because a few metres is the difference between two sides of a street, **Account → Align city
map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or
published to the server for every device if you hold the admin token.
## Privacy
BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My

View file

@ -3,14 +3,14 @@
"name": "BigBrainParking",
"slug": "bigbrainparking",
"scheme": "bigbrainparking",
"version": "0.5.0",
"version": "0.6.0",
"orientation": "portrait",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"icon": "./assets/icon.png",
"android": {
"package": "top.mowden.bigbrainparking",
"versionCode": 20,
"versionCode": 21,
"edgeToEdgeEnabled": true,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",

View file

@ -8,6 +8,7 @@
"android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test test/*.test.ts",
"ios": "expo run:ios"
},
"dependencies": {
@ -37,6 +38,7 @@
"devDependencies": {
"@types/react": "~19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"tsx": "^4.23.12",
"typescript": "~5.4.0"
}
}

216
app/src/api/parkingAreas.ts Normal file
View file

@ -0,0 +1,216 @@
import Constants from 'expo-constants';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { getAdminToken } from './adminStore';
import bundled from '@/features/citymap/parkingAreas.json';
import {
adjustGeometry,
overlayAnchor,
IDENTITY_OVERLAY,
type AreaGeometry,
type OverlayAdjust,
} from '@/features/citymap/geo';
/**
* The colour-coded areas from the City of Sandpoint's printed Downtown &
* Waterfront parking map, georeferenced.
*
* This is city geography, not ParkSmarter data no call in this file, or in
* anything that uses it, ever reaches the IPS API. That is the whole point:
* tracking your time on one of these spots has to work with no account, no
* network, and no payment.
*
* Three sources, in order: the server (editable without an app release), the
* on-device cache (so it works offline), and a copy bundled with the app (so a
* fresh install works before the server has ever been reached).
*/
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
export interface ParkingArea {
id: string;
kind: AreaKind;
/** Human name, e.g. "N 3rd Ave · Cedar St to Oak St". */
name: string;
/** Short category label, e.g. "3-hour". */
label: string;
/** The map legend's own wording, e.g. "3-hour or permit". */
legend: string;
/** Default tracked duration in hours; 0 for the no-limit category. */
hours: number;
color: string;
shape: 'line' | 'polygon';
geometry: AreaGeometry;
}
export interface AreaData {
areas: ParkingArea[];
overlay: OverlayAdjust;
/** Where the set came from, for the diagnostics screen. */
source: 'server' | 'cache' | 'bundled';
}
const CACHE_KEY = 'ps_parking_areas';
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
export class ParkingAreasError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message);
this.name = 'ParkingAreasError';
}
}
/** Colours match the printed map's legend. */
export const AREA_COLORS: Record<AreaKind, string> = {
green_lot: '#75b259',
free_2h: '#d367cc',
limit_3h: '#ccc542',
limit_4h: '#f78b08',
no_limit: '#c3c4c2',
};
/** Whether parking in this category costs money. Only the city lots do. */
export function areaIsFree(kind: AreaKind): boolean {
return kind !== 'green_lot';
}
/**
* Durations offered when starting tracking, the posted limit first.
*
* Time-limited spots stop at the posted limit offering to track 4 hours in a
* 2-hour space would just be scheduling a ticket. Lots and unlimited spots have
* no posted ceiling, so they get the long options.
*/
export function areaDurationOptions(area: ParkingArea): number[] {
if (area.kind === 'no_limit' || area.kind === 'green_lot') return [1, 2, 3, 4, 8, 12];
const posted = area.hours || 2;
return [...new Set([posted, 3, 2, 1, 0.5].filter((h) => h <= posted))].sort((a, b) => b - a);
}
const BUNDLED: ParkingArea[] = (bundled as any).features.map((f: any) => ({
...f.properties,
geometry: f.geometry,
}));
/* ------------------------------------------------------------------- cache */
let memCache: AreaData | null = null;
async function readCache(): Promise<AreaData | null> {
const raw = await AsyncStorage.getItem(CACHE_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { areas: ParkingArea[]; overlay: OverlayAdjust };
if (!parsed.areas?.length) return null;
return { areas: parsed.areas, overlay: parsed.overlay ?? IDENTITY_OVERLAY, source: 'cache' };
} catch {
return null;
}
}
async function writeCache(areas: ParkingArea[], overlay: OverlayAdjust): Promise<void> {
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ areas, overlay }));
}
/* -------------------------------------------------------------------- read */
/**
* The area set, without the overlay correction applied. Never throws and never
* blocks on the network the bundled copy is always a valid answer.
*/
export async function loadAreaData(): Promise<AreaData> {
if (memCache) return memCache;
const cached = await readCache();
memCache = cached ?? { areas: BUNDLED, overlay: IDENTITY_OVERLAY, source: 'bundled' };
return memCache;
}
/**
* The area set ready to draw and hit-test, with the alignment correction baked
* in. Display and hit-testing must use the same geometry or tapping a segment
* would select a different one than the one under your finger.
*/
export async function getAdjustedAreas(): Promise<AreaData> {
const data = await loadAreaData();
return { ...data, areas: applyOverlay(data.areas, data.overlay) };
}
/** Apply an overlay correction to a set of areas (also used for live preview). */
export function applyOverlay(areas: ParkingArea[], overlay: OverlayAdjust): ParkingArea[] {
const anchor = overlayAnchor(areas.map((a) => a.geometry));
return areas.map((a) => ({ ...a, geometry: adjustGeometry(a.geometry, overlay, anchor) }));
}
/** Pull the areas + overlay from the server and cache them. */
export async function refreshAreas(): Promise<AreaData> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas`);
if (!res.ok) throw new ParkingAreasError('areas fetch failed', res.status);
const body = (await res.json()) as { areas?: ParkingArea[]; overlay?: OverlayAdjust };
// An empty server (not yet seeded) must not wipe a working local map.
if (!body.areas?.length) return loadAreaData();
const overlay = body.overlay ?? IDENTITY_OVERLAY;
await writeCache(body.areas, overlay);
memCache = { areas: body.areas, overlay, source: 'server' };
return memCache;
}
/* ------------------------------------------------------------------- admin */
async function authHeaders(): Promise<Record<string, string>> {
const token = await getAdminToken();
if (!token) throw new ParkingAreasError('not authenticated as admin');
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** Push the app's bundled area set to the server, seeding or resetting it. */
export async function publishBundledAreas(): Promise<number> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify({ areas: BUNDLED }),
});
if (!res.ok) throw new ParkingAreasError('publish failed', res.status);
const body = (await res.json()) as { replaced?: number };
memCache = null;
await refreshAreas().catch(() => {});
return body.replaced ?? 0;
}
/** Persist an alignment correction so every device picks it up. */
export async function saveOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<OverlayAdjust> {
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
const res = await fetch(`${BASE_URL}/api/areas/overlay`, {
method: 'PUT',
headers: await authHeaders(),
body: JSON.stringify(o),
});
if (!res.ok) throw new ParkingAreasError('overlay save failed', res.status);
const saved = (await res.json()) as OverlayAdjust;
const data = await loadAreaData();
await writeCache(data.areas, saved);
memCache = { ...data, overlay: saved };
return saved;
}
/**
* Store an overlay correction on this device only. Lets the alignment be fixed
* without the admin token the nudge is useful to anyone standing on the street.
*/
export async function saveOverlayLocally(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<void> {
const data = await loadAreaData();
const overlay = { ...o, updatedAt: Date.now() };
await writeCache(data.areas, overlay);
memCache = { ...data, overlay };
}
/** Drop the cache so the next read falls back to bundled/server. */
export async function resetAreaCache(): Promise<void> {
memCache = null;
await AsyncStorage.removeItem(CACHE_KEY);
}

View file

@ -0,0 +1,147 @@
/**
* Geometry for the city parking-map overlay.
*
* Everything here is plain arithmetic on lon/lat no turf, no geo library. The
* covered area is ten blocks of downtown Sandpoint, so a local flat-earth
* approximation is accurate to well under a metre, and hit-testing has to run on
* every map tap.
*/
export type LonLat = [number, number];
/** GeoJSON geometry as it comes from the server or the bundled map. */
export type AreaGeometry =
| { type: 'LineString'; coordinates: LonLat[] }
| { type: 'Polygon'; coordinates: LonLat[][] };
export interface OverlayAdjust {
/** Ground metres east. */
dxMeters: number;
/** Ground metres north. */
dyMeters: number;
/** Multiplier about the overlay centroid. */
scale: number;
/** Degrees counter-clockwise about the overlay centroid. */
rotationDeg: number;
updatedAt: number;
}
export const IDENTITY_OVERLAY: OverlayAdjust = {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
};
export function isIdentity(o: OverlayAdjust): boolean {
return o.dxMeters === 0 && o.dyMeters === 0 && o.scale === 1 && o.rotationDeg === 0;
}
const M_PER_DEG_LAT = 110574;
const metresPerDegLon = (lat: number) => 111320 * Math.cos((lat * Math.PI) / 180);
/* ------------------------------------------------------------------ distance */
/** Metres between two lon/lat points (flat-earth; exact enough downtown). */
export function distanceMeters(a: LonLat, b: LonLat): number {
const mx = metresPerDegLon((a[1] + b[1]) / 2);
const dx = (a[0] - b[0]) * mx;
const dy = (a[1] - b[1]) * M_PER_DEG_LAT;
return Math.hypot(dx, dy);
}
/** Metres from `p` to the segment a→b. */
function distToSegment(p: LonLat, a: LonLat, b: LonLat): number {
const mx = metresPerDegLon(p[1]);
const px = p[0] * mx;
const py = p[1] * M_PER_DEG_LAT;
const ax = a[0] * mx;
const ay = a[1] * M_PER_DEG_LAT;
const bx = b[0] * mx;
const by = b[1] * M_PER_DEG_LAT;
const dx = bx - ax;
const dy = by - ay;
const len2 = dx * dx + dy * dy;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}
function ringContains(p: LonLat, ring: LonLat[]): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
inside = !inside;
}
}
return inside;
}
/**
* Metres from a point to a geometry 0 when the point is inside a polygon, so
* "which area am I in" and "which area is nearest" are the same question.
*/
export function distanceToGeometry(p: LonLat, g: AreaGeometry): number {
if (g.type === 'Polygon') {
const [outer, ...holes] = g.coordinates;
if (!outer?.length) return Infinity;
if (ringContains(p, outer) && !holes.some((h) => ringContains(p, h))) return 0;
let best = Infinity;
for (const ring of g.coordinates) {
for (let i = 1; i < ring.length; i++) best = Math.min(best, distToSegment(p, ring[i - 1], ring[i]));
}
return best;
}
const line = g.coordinates;
if (line.length === 1) return distanceMeters(p, line[0]);
let best = Infinity;
for (let i = 1; i < line.length; i++) best = Math.min(best, distToSegment(p, line[i - 1], line[i]));
return best;
}
/* ------------------------------------------------------------------ overlay */
/**
* Apply the whole-overlay correction: scale and rotate about `anchor`, then shift.
*
* The georeference was fitted to OpenStreetMap and is good to a few metres, but
* "a few metres" is the difference between two sides of a street. This is what
* lets that be corrected from the phone against a live GPS fix, with no rebuild.
*/
export function adjustGeometry(g: AreaGeometry, o: OverlayAdjust, anchor: LonLat): AreaGeometry {
if (isIdentity(o)) return g;
const mx = metresPerDegLon(anchor[1]);
const cosT = Math.cos((o.rotationDeg * Math.PI) / 180);
const sinT = Math.sin((o.rotationDeg * Math.PI) / 180);
const move = (p: LonLat): LonLat => {
// Into local metres relative to the anchor, transform, and back out.
const ex = (p[0] - anchor[0]) * mx;
const ny = (p[1] - anchor[1]) * M_PER_DEG_LAT;
const rx = (ex * cosT - ny * sinT) * o.scale + o.dxMeters;
const ry = (ex * sinT + ny * cosT) * o.scale + o.dyMeters;
return [anchor[0] + rx / mx, anchor[1] + ry / M_PER_DEG_LAT];
};
return g.type === 'Polygon'
? { type: 'Polygon', coordinates: g.coordinates.map((r) => r.map(move)) }
: { type: 'LineString', coordinates: g.coordinates.map(move) };
}
/** Centroid of every vertex in the set — the anchor scale and rotation turn about. */
export function overlayAnchor(geoms: AreaGeometry[]): LonLat {
let x = 0;
let y = 0;
let n = 0;
for (const g of geoms) {
for (const [lon, lat] of g.type === 'Polygon' ? g.coordinates.flat() : g.coordinates) {
x += lon;
y += lat;
n++;
}
}
return n ? [x / n, y / n] : [0, 0];
}

File diff suppressed because one or more lines are too long

View file

@ -18,11 +18,14 @@ import {
getRemindersEnabled,
} from '@/features/notifications/reminderPrefs';
import { logLine } from '@/features/diagnostics/fileLogger';
import type { ParkingArea } from '@/api/parkingAreas';
import {
clearActiveParking,
getActiveParking,
setActiveParking,
setParkedPin,
type ActiveParking,
type ParkedSpot,
} from './activeParkingStore';
import {
clearSession,
@ -48,6 +51,9 @@ import {
const EXPIRY_REMINDER_ID = 'parking-expiry';
/** Fallback ongoing notification for Expo Go, where the native module is absent. */
const FALLBACK_NOTIF_ID = 'parking-status';
/** How much a city-map session's "extend" button adds, and what it's labelled. */
const EXTEND_MINUTES = 60;
const EXTEND_LABEL = '+1 hr';
function fmtTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
@ -68,9 +74,10 @@ async function postNotification(p: ActiveParking): Promise<void> {
const free = p.kind === 'free';
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
// Free time isn't bought, so the button that buys time reads "Pay"; on a paid
// session it genuinely extends what you already have.
const extendLabel = free ? 'Pay' : 'Extend';
// What the second button does depends on what it *can* do. City-map parking has
// no ParkSmarter zone to buy time in, so there it adds an hour to the local
// timer and says so; a real zone gets the purchase screen.
const extendLabel = p.area ? EXTEND_LABEL : free ? 'Pay' : 'Extend';
if (hasNativeCountdown) {
const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
@ -176,6 +183,69 @@ export async function startFreeCheckin(
await scheduleExpiryReminder(state);
}
/**
* Start tracking time on an area from the city parking map.
*
* Deliberately the whole story: no ParkSmarter call, no account, no network. The
* area came from the local database, the clock is the phone's, and the countdown
* is the same foreground service every other session uses. Works in Anonymous
* Mode, offline, and with the IPS API down.
*/
export async function startAreaParking(args: {
area: ParkingArea;
hours: number;
spot?: ParkedSpot;
}): Promise<void> {
const now = Date.now();
const state: ActiveParking = {
// Only the city lots cost money; everything else on the map is free parking
// that merely has a posted time limit.
kind: args.area.kind === 'green_lot' ? 'paid' : 'free',
area: {
id: args.area.id,
kind: args.area.kind,
name: args.area.name,
legend: args.area.legend,
color: args.area.color,
},
spot: args.spot,
zoneName: args.area.name,
startMs: now,
endMs: now + Math.round(args.hours * 3_600_000),
leadMinutes: await getReminderLeadMinutes(),
};
await setActiveParking(state);
if (args.spot) await setParkedPin(args.spot);
await postNotification(state);
await scheduleExpiryReminder(state);
logLine(`[PARKING] city area ${args.area.id} (${args.area.kind}) for ${args.hours}h`);
}
/**
* Drop the "where is my car" pin. Deliberately independent of any session you
* can pin the car without starting a timer, and the pin has to survive that.
*/
export async function pinParkedSpot(spot: ParkedSpot): Promise<void> {
await setParkedPin(spot);
const current = await getActiveParking();
if (current) await setActiveParking({ ...current, spot });
}
/**
* Add time to a city-map session's local timer. There is nothing to buy here
* the app is only tracking a clock so extending is a local edit, not a purchase.
*/
export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void> {
const current = await getActiveParking();
if (!current) return;
// Extend from now if it already lapsed, so "+1 hr" always means a full hour.
const from = Math.max(current.endMs, Date.now());
const next = { ...current, endMs: from + minutes * 60_000 };
await setActiveParking(next);
await postNotification(next);
await scheduleExpiryReminder(next);
}
/**
* Stop tracking the active session and take its notification down.
*
@ -247,6 +317,16 @@ export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promis
current = null;
}
// "+1 hr" on a city-map session is a local edit, not a purchase — handle it here
// and stay put rather than sending the user to a payment screen for a free spot.
if (action === 'extend' && current?.area) {
logLine(`[PARKING] notification "${EXTEND_LABEL}" pressed on city area ${current.area.id}`);
await extendAreaParking();
return;
}
// A city-map session is never on the ParkSmarter server, so don't let a stale
// server session overwrite it.
if (!current) current = await discoverPaidSession();
if (current) {

View file

@ -1,6 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Zone } from 'parksmarter-client';
import type { LabelKind } from '@/api/zoneLabels';
import type { AreaKind } from '@/api/parkingAreas';
/**
* The one parking session the app is currently tracking paid or a local free
@ -14,16 +15,49 @@ import type { LabelKind } from '@/api/zoneLabels';
const KEY = 'ps_active_parking';
/** Pre-0.5 free check-ins lived here; read once so an in-flight check-in survives the upgrade. */
const LEGACY_CHECKIN_KEY = 'ps_checkin';
/**
* The parked pin lives outside the session on purpose: "where is my car" outlives
* "am I tracking time". You can drop a pin without starting a timer, and it has to
* still be there when you come back to the map.
*/
const PIN_KEY = 'ps_parked_pin';
export type ParkingKind = 'paid' | 'free';
/**
* Where you parked, when the spot came from the city map rather than ParkSmarter.
* Held by value the whole point is that the countdown keeps working with no
* network, no account and no IPS call, so it can't depend on a lookup.
*/
export interface ParkedArea {
id: string;
kind: AreaKind;
name: string;
/** The map legend's own wording, shown on the session screen. */
legend: string;
color: string;
}
/** The pin for "where is my car", dropped from GPS or placed by hand. */
export interface ParkedSpot {
latitude: number;
longitude: number;
/** True when the user placed it themselves because GPS wasn't usable. */
manual: boolean;
}
export interface ActiveParking {
kind: ParkingKind;
/**
* Absent only for a paid session discovered from the server (the active-session
* API returns no zone), in which case "Extend" falls back to the Sessions tab.
* Absent for a paid session discovered from the server (the active-session API
* returns no zone) and for city-map parking, which has no ParkSmarter zone at
* all. "Extend" falls back accordingly.
*/
zone?: Zone;
/** Set instead of `zone` when parked on a city-map area. */
area?: ParkedArea;
/** Where the car actually is. Independent of `area` — you can pin without one. */
spot?: ParkedSpot;
zoneName: string;
startMs: number;
endMs: number;
@ -53,5 +87,18 @@ export async function setActiveParking(state: ActiveParking): Promise<void> {
}
export async function clearActiveParking(): Promise<void> {
await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY]);
// The pin goes with it: ending a session means you drove away, and a pin left
// behind would point at a space you no longer occupy.
await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY, PIN_KEY]);
}
/** Where the car is, whether or not a timer is running. */
export async function getParkedPin(): Promise<ParkedSpot | null> {
const raw = await AsyncStorage.getItem(PIN_KEY);
return raw ? (JSON.parse(raw) as ParkedSpot) : null;
}
export async function setParkedPin(spot: ParkedSpot | null): Promise<void> {
if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot));
else await AsyncStorage.removeItem(PIN_KEY);
}

View file

@ -24,14 +24,21 @@ import { StartSessionScreen } from '@/screens/StartSessionScreen';
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
import { AdminScreen } from '@/screens/AdminScreen';
import { CityAreaScreen } from '@/screens/CityAreaScreen';
import { MapAlignScreen } from '@/screens/MapAlignScreen';
import { useTheme } from '@/theme/ThemeContext';
import { useActiveParkingSync } from '@/features/session/activeParking';
import type { ParkingArea } from '@/api/parkingAreas';
import type { ParkedSpot } from '@/features/session/activeParkingStore';
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
export type RootStackParamList = {
Tabs: undefined;
MeterDetail: { zone: Zone };
StartSession: { zone: Zone };
/** An area from the city's printed parking map — no ParkSmarter zone involved. */
CityArea: { area: ParkingArea; spot?: ParkedSpot };
MapAlign: undefined;
SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' };
About: undefined;
Profile: undefined;
@ -151,7 +158,17 @@ export function RootNavigator() {
component={DiagnosticsScreen}
options={{ title: 'Diagnostics' }}
/>
<Stack.Screen
name="CityArea"
component={CityAreaScreen}
options={{ title: 'Parking area' }}
/>
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
<Stack.Screen
name="MapAlign"
component={MapAlignScreen}
options={{ title: 'Align city map' }}
/>
</Stack.Navigator>
) : (
<LoginScreen />

View file

@ -71,6 +71,11 @@ export function AccountScreen() {
<Switch value={mode === 'dark'} onValueChange={toggle} />
</View>
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
<Item
icon="git-compare"
label="Align city map"
onPress={() => navigation.navigate('MapAlign')}
/>
<Item
icon="shield-checkmark"
label="Admin (zone labeling)"

View file

@ -0,0 +1,230 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { RouteProp } from '@react-navigation/native';
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { useTheme } from '@/theme/ThemeContext';
import { areaDurationOptions, areaIsFree } from '@/api/parkingAreas';
import {
endActiveParking,
extendAreaParking,
startAreaParking,
} from '@/features/session/activeParking';
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
type AreaRoute = RouteProp<RootStackParamList, 'CityArea'>;
type Nav = NativeStackNavigationProp<RootStackParamList>;
function fmtHours(h: number): string {
if (h < 1) return `${Math.round(h * 60)} min`;
return h === 1 ? '1 hour' : `${h} hours`;
}
function fmtClock(ms: number): string {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}
function fmtRemaining(ms: number): string {
const mins = Math.max(0, Math.round(ms / 60_000));
const h = Math.floor(mins / 60);
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
}
/**
* One area from the city's printed parking map: what the sign says, and a timer
* for it.
*
* Nothing on this screen talks to ParkSmarter. The area came from the local
* database and the countdown is the phone's own clock, so this works with no
* account, no signal, and in Anonymous Mode.
*/
export function CityAreaScreen() {
const { area, spot } = useRoute<AreaRoute>().params;
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [active, setActive] = useState<ActiveParking | null>(null);
const options = areaDurationOptions(area);
const [hours, setHours] = useState<number>(options[0]);
const free = areaIsFree(area.kind);
const parkedHere = active?.area?.id === area.id;
const reload = useCallback(() => {
void getActiveParking().then(setActive);
}, []);
useFocusEffect(reload);
// Re-read while the screen is open so "time left" doesn't sit frozen at whatever
// it was when you opened it. The notification is the real live countdown; this
// just keeps the screen from lying.
useEffect(() => {
if (!active) return;
const id = setInterval(reload, 30_000);
return () => clearInterval(id);
}, [active, reload]);
useEffect(() => {
navigation.setOptions({ title: area.label });
}, [navigation, area.label]);
const start = async () => {
if (active && !parkedHere) {
const where = active.zoneName;
const ok = await new Promise<boolean>((resolve) =>
Alert.alert(
'Already tracking',
`You're tracking parking at ${where}. Replace it with this spot?`,
[
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
{ text: 'Replace', style: 'destructive', onPress: () => resolve(true) },
],
),
);
if (!ok) return;
}
await startAreaParking({ area, hours, spot });
navigation.navigate('Tabs');
};
const stop = async () => {
await endActiveParking();
reload();
};
const s = styles(colors);
return (
<ScrollView style={s.screen} contentContainerStyle={s.content}>
<View style={s.card}>
<View style={s.chipRow}>
<View style={[s.swatch, { backgroundColor: area.color }]} />
<Text style={s.chipText}>{area.legend}</Text>
</View>
<Text style={s.name}>{area.name}</Text>
<Text style={s.sub}>
{free ? 'Free parking' : 'Paid — pay at the kiosk or by permit'}
{area.hours > 0 ? ` · ${fmtHours(area.hours)} posted limit` : ' · no posted time limit'}
</Text>
</View>
{spot ? (
<View style={s.card}>
<Text style={s.sectionTitle}>Your car</Text>
<Text style={s.sub}>
Pinned at {spot.latitude.toFixed(5)}, {spot.longitude.toFixed(5)}
</Text>
<Text style={s.hint}>
{spot.manual ? 'Placed by hand.' : 'From GPS.'} The pin stays on the map until you end
the session.
</Text>
</View>
) : null}
{parkedHere && active ? (
<View style={s.card}>
<Text style={s.sectionTitle}>Tracking now</Text>
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
<Text style={s.sub}>Until {fmtClock(active.endMs)}</Text>
<View style={s.row}>
<TouchableOpacity
style={s.secondary}
onPress={async () => {
await extendAreaParking();
reload();
}}
>
<Text style={s.secondaryText}>+1 hour</Text>
</TouchableOpacity>
<TouchableOpacity style={s.danger} onPress={stop}>
<Text style={s.primaryText}>End</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View style={s.card}>
<Text style={s.sectionTitle}>Track my time here</Text>
<View style={s.row}>
{options.map((h) => (
<TouchableOpacity
key={h}
style={[s.pick, hours === h && { borderColor: colors.primary, borderWidth: 2 }]}
onPress={() => setHours(h)}
>
<Text style={[s.pickText, hours === h && { color: colors.primary }]}>
{h < 1 ? `${Math.round(h * 60)}m` : `${h}h`}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={s.hint}>
{area.hours > 0
? `The sign says ${fmtHours(area.hours)}. The countdown runs on this phone only — it doesn't buy or reserve anything.`
: "No posted limit here — pick however long you'll be. The countdown runs on this phone only."}
</Text>
<TouchableOpacity style={s.primary} onPress={start}>
<Text style={s.primaryText}>Start {fmtHours(hours)} timer</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
);
}
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
StyleSheet.create({
screen: { flex: 1, backgroundColor: c.bg },
content: { padding: 16, gap: 12 },
card: {
backgroundColor: c.card,
borderRadius: 12,
padding: 16,
gap: 8,
borderWidth: 1,
borderColor: c.border,
},
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
swatch: { width: 22, height: 12, borderRadius: 3 },
chipText: { color: c.subtext, fontSize: 13, fontWeight: '600' },
name: { color: c.text, fontSize: 20, fontWeight: '700' },
sub: { color: c.subtext, fontSize: 14 },
hint: { color: c.subtext, fontSize: 12, lineHeight: 17 },
sectionTitle: { color: c.text, fontSize: 15, fontWeight: '700' },
big: { color: c.text, fontSize: 28, fontWeight: '700' },
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
pick: {
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.bg,
borderRadius: 10,
paddingHorizontal: 16,
paddingVertical: 10,
minWidth: 56,
alignItems: 'center',
},
pickText: { color: c.text, fontWeight: '600' },
primary: {
backgroundColor: c.primary,
borderRadius: 10,
paddingVertical: 14,
alignItems: 'center',
marginTop: 4,
},
primaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
secondary: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
secondaryText: { color: c.text, fontWeight: '700' },
danger: {
flex: 1,
backgroundColor: c.danger,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
});

View file

@ -0,0 +1,309 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
ShapeSource,
FillLayer,
LineLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
import {
applyOverlay,
loadAreaData,
saveOverlay,
saveOverlayLocally,
publishBundledAreas,
type ParkingArea,
} from '@/api/parkingAreas';
import { getAdminToken } from '@/api/adminStore';
import { IDENTITY_OVERLAY, type OverlayAdjust } from '@/features/citymap/geo';
const MAP_STYLE_LIGHT =
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
'https://tiles.openfreemap.org/styles/liberty';
const MAP_STYLE_DARK =
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
const SANDPOINT: [number, number] = [-116.5533, 48.2766];
/**
* Align the city parking map against reality.
*
* The overlay was georeferenced by fitting the printed map to OpenStreetMap street
* centrelines, which lands within a few metres but a few metres is the difference
* between one side of a street and the other. This is the fix for that: stand on a
* marked block, watch the blue dot against the coloured stripe, and nudge until
* they agree. No rebuild, and no need to re-derive the fit.
*
* "Save on this phone" is deliberately available without the admin token the
* person who can see the misalignment is the person standing on the street.
*/
export function MapAlignScreen() {
const { mode, colors } = useTheme();
const insets = useSafeAreaInsets();
const cameraRef = useRef<any>(null);
const [base, setBase] = useState<ParkingArea[]>([]);
const [adj, setAdj] = useState<Omit<OverlayAdjust, 'updatedAt'>>(IDENTITY_OVERLAY);
const [step, setStep] = useState(2); // metres per nudge
const [isAdmin, setIsAdmin] = useState(false);
const [status, setStatus] = useState('Loading the city map…');
useEffect(() => {
void (async () => {
const data = await loadAreaData();
setBase(data.areas);
const { updatedAt, ...rest } = data.overlay;
setAdj(rest);
setStatus(
`${data.areas.length} areas (${data.source})` +
(updatedAt ? ` · adjusted ${new Date(updatedAt).toLocaleDateString()}` : ''),
);
})();
void getAdminToken().then((t) => setIsAdmin(!!t));
}, []);
const preview = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: applyOverlay(base, { ...adj, updatedAt: 0 }).map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { color: a.color },
})),
}),
[base, adj],
);
const nudge = useCallback(
(dx: number, dy: number) => setAdj((a) => ({ ...a, dxMeters: a.dxMeters + dx, dyMeters: a.dyMeters + dy })),
[],
);
const bump = useCallback(
(key: 'scale' | 'rotationDeg', by: number) =>
setAdj((a) => ({ ...a, [key]: Number((a[key] + by).toFixed(4)) })),
[],
);
const saveLocal = async () => {
await saveOverlayLocally(adj);
setStatus('Saved on this phone.');
};
const savePublished = async () => {
try {
await saveOverlay(adj);
setStatus('Published — every device will pick this up.');
} catch (e: any) {
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
}
};
const publishAreas = async () => {
try {
setStatus('Publishing bundled areas…');
const n = await publishBundledAreas();
setStatus(`Published ${n} areas to the server.`);
} catch (e: any) {
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
}
};
const s = styles(colors);
const dirty =
adj.dxMeters !== 0 || adj.dyMeters !== 0 || adj.scale !== 1 || adj.rotationDeg !== 0;
return (
<View style={s.container}>
<MapView
style={s.map}
mapStyle={mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT}
rotateEnabled={false}
>
<Camera ref={cameraRef} defaultSettings={{ centerCoordinate: SANDPOINT, zoomLevel: 16 }} />
<UserLocation visible renderMode="normal" />
<ShapeSource id="align-preview" shape={preview}>
<FillLayer
id="align-fills"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
/>
<LineLayer
id="align-lines"
style={{
lineColor: ['get', 'color'],
lineWidth: ['interpolate', ['linear'], ['zoom'], 14, 3, 18, 11],
lineOpacity: 0.95,
lineCap: 'round',
}}
/>
</ShapeSource>
</MapView>
<View style={[s.status, { top: insets.top + 12 }]}>
<Text style={s.statusText} numberOfLines={2}>
{status}
</Text>
<Text style={s.readout}>
E {adj.dxMeters.toFixed(1)} m · N {adj.dyMeters.toFixed(1)} m · ×
{adj.scale.toFixed(3)} · {adj.rotationDeg.toFixed(2)}°
</Text>
</View>
<View style={[s.panel, { paddingBottom: insets.bottom + 12 }]}>
<View style={s.padRow}>
<View style={s.pad}>
<TouchableOpacity style={s.key} onPress={() => nudge(0, step)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<View style={s.padMid}>
<TouchableOpacity style={s.key} onPress={() => nudge(-step, 0)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.stepKey} onPress={() => setStep(step >= 8 ? 0.5 : step * 2)}>
<Text style={s.stepText}>{step} m</Text>
</TouchableOpacity>
<TouchableOpacity style={s.key} onPress={() => nudge(step, 0)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<TouchableOpacity style={s.key} onPress={() => nudge(0, -step)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<View style={s.fine}>
<Text style={s.fineLabel}>Rotate</Text>
<View style={s.fineRow}>
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', -0.25)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', 0.25)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
</View>
<Text style={s.fineLabel}>Scale</Text>
<View style={s.fineRow}>
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', -0.002)}>
<Text style={s.keyText}></Text>
</TouchableOpacity>
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', 0.002)}>
<Text style={s.keyText}>+</Text>
</TouchableOpacity>
</View>
</View>
</View>
<View style={s.btnRow}>
<TouchableOpacity
style={[s.btn, !dirty && s.btnMuted]}
onPress={() => setAdj(IDENTITY_OVERLAY)}
>
<Text style={s.btnText}>Reset</Text>
</TouchableOpacity>
<TouchableOpacity style={s.btnPrimary} onPress={saveLocal}>
<Text style={s.btnTextOn}>Save on this phone</Text>
</TouchableOpacity>
</View>
{isAdmin ? (
<View style={s.btnRow}>
<TouchableOpacity style={s.btn} onPress={publishAreas}>
<Text style={s.btnText}>Publish areas</Text>
</TouchableOpacity>
<TouchableOpacity style={s.btnPrimary} onPress={savePublished}>
<Text style={s.btnTextOn}>Publish alignment</Text>
</TouchableOpacity>
</View>
) : null}
</View>
</View>
);
}
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
StyleSheet.create({
container: { flex: 1, backgroundColor: c.bg },
map: { flex: 1 },
status: {
position: 'absolute',
left: 12,
right: 12,
backgroundColor: 'rgba(0,0,0,0.7)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 8,
gap: 2,
},
statusText: { color: '#fff', fontSize: 13 },
readout: { color: '#c9d6d2', fontSize: 12, fontVariant: ['tabular-nums'] },
panel: {
backgroundColor: c.card,
borderTopWidth: 1,
borderTopColor: c.border,
padding: 12,
gap: 10,
},
padRow: { flexDirection: 'row', gap: 16, alignItems: 'center' },
pad: { alignItems: 'center', gap: 6 },
padMid: { flexDirection: 'row', alignItems: 'center', gap: 6 },
key: {
width: 52,
height: 44,
borderRadius: 10,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
keyText: { color: c.text, fontSize: 20, fontWeight: '700' },
stepKey: {
width: 52,
height: 44,
borderRadius: 10,
backgroundColor: c.primary,
alignItems: 'center',
justifyContent: 'center',
},
stepText: { color: '#fff', fontWeight: '700', fontSize: 13 },
fine: { flex: 1, gap: 4 },
fineLabel: { color: c.subtext, fontSize: 12, fontWeight: '600' },
fineRow: { flexDirection: 'row', gap: 6 },
fineKey: {
flex: 1,
height: 38,
borderRadius: 10,
backgroundColor: c.bg,
borderWidth: 1,
borderColor: c.border,
alignItems: 'center',
justifyContent: 'center',
},
btnRow: { flexDirection: 'row', gap: 8 },
btn: {
flex: 1,
borderWidth: 1,
borderColor: c.border,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnMuted: { opacity: 0.5 },
btnPrimary: {
flex: 1,
backgroundColor: c.primary,
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
btnText: { color: c.text, fontWeight: '700' },
btnTextOn: { color: '#fff', fontWeight: '700' },
});

View file

@ -1,14 +1,16 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Constants from 'expo-constants';
import {
MapView,
Camera,
ShapeSource,
CircleLayer,
FillLayer,
LineLayer,
UserLocation,
} from '@maplibre/maplibre-react-native';
import { useNavigation } from '@react-navigation/native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '@/theme/ThemeContext';
@ -16,6 +18,14 @@ import { ps } from '@/api/client';
import { useLocation, type Coords } from '@/features/location/useLocation';
import { useAuth } from '@/auth/AuthContext';
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
import { getAdjustedAreas, refreshAreas, type ParkingArea } from '@/api/parkingAreas';
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
import {
getParkedPin,
setParkedPin,
type ParkedSpot,
} from '@/features/session/activeParkingStore';
import { pinParkedSpot } from '@/features/session/activeParking';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import type { Zone } from 'parksmarter-client';
@ -32,6 +42,27 @@ const DEFAULT_ZOOM = 14;
type Nav = NativeStackNavigationProp<RootStackParamList>;
/**
* How far from a city-map area a parked pin can be and still be taken as "that's
* where I am". A block is ~170 m, a street ~12 m wide; 40 m picks the right side
* of the right street without silently matching a spot two blocks away.
*/
const AREA_SNAP_METERS = 40;
/** Nearest city-map area to a point, or null if nothing is close enough. */
function areaAt(point: LonLat, areas: ParkingArea[]): ParkingArea | null {
let best: ParkingArea | null = null;
let bestDist = AREA_SNAP_METERS;
for (const a of areas) {
const d = distanceToGeometry(point, a.geometry);
if (d < bestDist) {
best = a;
bestDist = d;
}
}
return best;
}
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
function normalizeColor(v: unknown): string | null {
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
@ -56,6 +87,14 @@ export function MapScreen() {
const [loading, setLoading] = useState(false);
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
// The city's printed parking map, georeferenced. Local geography — loading and
// tapping these never touches the ParkSmarter API.
const [areas, setAreas] = useState<ParkingArea[]>([]);
const [showAreas, setShowAreas] = useState(true);
// Set while waiting for the user to tap where they parked (the GPS-less path).
const [pinning, setPinning] = useState(false);
const [spot, setSpot] = useState<ParkedSpot | null>(null);
const mapRef = useRef<any>(null);
const cameraRef = useRef<any>(null);
const [mapReady, setMapReady] = useState(false);
@ -148,6 +187,34 @@ export function MapScreen() {
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
}, [isAnonymous, searchAt]);
// City parking map: draw the cached/bundled copy immediately so the overlay is
// there offline, then quietly refresh from the server behind it.
useEffect(() => {
let alive = true;
(async () => {
const local = await getAdjustedAreas();
if (alive) setAreas(local.areas);
try {
await refreshAreas();
const fresh = await getAdjustedAreas();
if (alive) setAreas(fresh.areas);
} catch {
/* offline or unseeded — the local copy is already drawn */
}
})();
return () => {
alive = false;
};
}, []);
// Re-read the pin on every focus: the session may have ended on another screen
// (or from the notification), which clears it.
useFocusEffect(
useCallback(() => {
void getParkedPin().then(setSpot);
}, []),
);
// Search whatever the map is currently centered on. This only ever sends the
// map's center point — never the device GPS. (If you want to search your own
// location, tap "My location" to center there first, then Search this area.)
@ -206,6 +273,122 @@ export function MapScreen() {
await searchAt(lot, 'your last lot');
};
/* -------------------------------------------------- parking-map interaction */
/** Open an area, carrying the pin along if we have one. */
const openArea = useCallback(
(area: ParkingArea, at?: ParkedSpot) => {
navigation.navigate('CityArea', { area, spot: at });
},
[navigation],
);
/** Drop the pin at `c`, work out which area that is, and open it. */
const pinAt = useCallback(
(c: Coords, manual: boolean) => {
const at: ParkedSpot = { latitude: c.latitude, longitude: c.longitude, manual };
setSpot(at);
// Persist immediately — the pin is worth keeping even if you never start a
// timer, and even if you back out of the screen we're about to open.
void pinParkedSpot(at);
const found = areaAt([c.longitude, c.latitude], areas);
if (found) {
setStatus(`Parked at ${found.name}`);
openArea(found, at);
} else {
// Pin still stands — you parked somewhere, it's just not on the city map.
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
}
},
[areas, openArea],
);
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
// garage, indoors, GPS off) fall back to letting the user tap the spot — the
// pin is the point, so it must not depend on the GPS working.
const parkHere = async () => {
if (pinning) {
setPinning(false);
setStatus('Pin cancelled.');
return;
}
const c = nativeFix.current ?? coords ?? (await refresh());
if (!c) {
setPinning(true);
setStatus('No GPS fix — tap the map where you parked.');
return;
}
cameraRef.current?.setCamera?.({
centerCoordinate: [c.longitude, c.latitude],
zoomLevel: 17,
animationDuration: 500,
});
pinAt(c, false);
};
/** A tap on the map: places the manual pin, but only while we asked for one. */
const onMapPress = (e: any) => {
if (!pinning) return;
const c = e?.geometry?.coordinates;
if (!Array.isArray(c) || c.length !== 2) return;
setPinning(false);
pinAt({ latitude: c[1], longitude: c[0] }, true);
};
/** A tap on your own pin: the only way to take it down without ending a session. */
const onSpotPress = () => {
if (!spot) return;
Alert.alert('Your car', 'Remove the parked pin?', [
{ text: 'Keep', style: 'cancel' },
{
text: 'Remove',
style: 'destructive',
onPress: () => {
setSpot(null);
void setParkedPin(null);
setStatus('Pin removed.');
},
},
]);
};
/** A tap on a coloured segment: the other start flow, no pin involved. */
const onAreaPress = (e: any) => {
const id = e?.features?.[0]?.properties?.id;
const found = areas.find((a) => a.id === id);
if (found) openArea(found, spot ?? undefined);
};
const areaFeatures = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: areas.map((a) => ({
type: 'Feature' as const,
id: a.id,
geometry: a.geometry,
properties: { id: a.id, color: a.color, kind: a.kind },
})),
}),
[areas],
);
const spotFeature = useMemo(
() => ({
type: 'FeatureCollection' as const,
features: spot
? [
{
type: 'Feature' as const,
id: 'parked',
geometry: { type: 'Point' as const, coordinates: [spot.longitude, spot.latitude] },
properties: {},
},
]
: [],
}),
[spot],
);
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
// than React MarkerViews, which floated and thrashed the camera.
const meterFeatures = useMemo(
@ -262,6 +445,7 @@ export function MapScreen() {
mapStyle={mapStyle}
rotateEnabled={false}
onDidFinishLoadingMap={() => setMapReady(true)}
onPress={onMapPress}
onRegionDidChange={(f: any) => {
const c = f?.geometry?.coordinates;
const z = f?.properties?.zoomLevel;
@ -273,6 +457,57 @@ export function MapScreen() {
{cameraEl}
{userLocationEl}
{/* The city parking map, under the meter pins so pins stay tappable. */}
{showAreas ? (
<ShapeSource id="city-areas" shape={areaFeatures} onPress={onAreaPress}>
<FillLayer
id="city-area-fills"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
/>
<LineLayer
id="city-area-outlines"
filter={['==', ['geometry-type'], 'Polygon']}
style={{ lineColor: ['get', 'color'], lineWidth: 1.5, lineOpacity: 0.9 }}
/>
{/* Street segments, scaled with zoom so they read as painted kerb. */}
<LineLayer
id="city-area-lines"
filter={['==', ['geometry-type'], 'LineString']}
style={{
lineColor: ['get', 'color'],
lineOpacity: 0.95,
lineCap: 'round',
lineWidth: ['interpolate', ['linear'], ['zoom'], 12, 2, 15, 5, 18, 11],
}}
/>
{/* A fat, near-invisible line purely to make thin segments tappable
a 5 px kerb stripe is far too small a target for a fingertip. */}
<LineLayer
id="city-area-touch"
filter={['==', ['geometry-type'], 'LineString']}
style={{ lineColor: '#000000', lineOpacity: 0.01, lineWidth: 24 }}
/>
</ShapeSource>
) : null}
{/* Where the car is. Drawn above everything — it's the thing you came back for. */}
<ShapeSource id="parked-spot" shape={spotFeature} onPress={onSpotPress}>
<CircleLayer
id="parked-halo"
style={{ circleColor: '#1e6f5c', circleOpacity: 0.25, circleRadius: 18 }}
/>
<CircleLayer
id="parked-dot"
style={{
circleColor: '#1e6f5c',
circleStrokeColor: '#ffffff',
circleStrokeWidth: 3,
circleRadius: 8,
}}
/>
</ShapeSource>
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
<CircleLayer
id="meter-circles"
@ -303,15 +538,31 @@ export function MapScreen() {
</View>
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</Text>
</TouchableOpacity>
<View style={styles.row}>
<TouchableOpacity
style={pinning ? styles.pillActive : styles.pillPrimary}
onPress={parkHere}
>
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
</TouchableOpacity>
<TouchableOpacity
style={showAreas ? styles.pillOn : styles.pill}
onPress={() => setShowAreas((v) => !v)}
>
<Text style={styles.pillText}>City map</Text>
</TouchableOpacity>
</View>
<View style={styles.row}>
<TouchableOpacity style={styles.pill} onPress={searchThisArea}>
<Text style={styles.pillText}>Search this area</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
<Text style={styles.pillText}>My location</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.pill} onPress={searchLastSessionLot}>
<Text style={styles.pillText}>Last lot</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
@ -337,9 +588,10 @@ const styles = StyleSheet.create({
position: 'absolute',
bottom: 24,
alignSelf: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
row: { flexDirection: 'row', gap: 8 },
pill: {
backgroundColor: '#444',
paddingHorizontal: 14,
@ -352,5 +604,18 @@ const styles = StyleSheet.create({
paddingVertical: 10,
borderRadius: 22,
},
/** Waiting for the user to tap where they parked. */
pillActive: {
backgroundColor: '#c07a12',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 22,
},
pillOn: {
backgroundColor: '#2f6f60',
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 22,
},
pillText: { color: '#fff', fontWeight: '600' },
});

152
app/test/geo.test.ts Normal file
View file

@ -0,0 +1,152 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
adjustGeometry,
distanceMeters,
distanceToGeometry,
overlayAnchor,
IDENTITY_OVERLAY,
type AreaGeometry,
type LonLat,
} from '../src/features/citymap/geo';
import bundled from '../src/features/citymap/parkingAreas.json';
/**
* The city-map geometry, checked against the real bundled area set.
*
* This is the maths that decides which street you are tracking time on, so it is
* tested against the actual 49 areas rather than toy shapes the awkward cases
* (an L-shaped run down two streets, a crescent-shaped beach lot) only exist in
* the real data.
*
* Run with: npm test --workspace app
*/
interface Area {
id: string;
shape: 'line' | 'polygon';
geometry: AreaGeometry;
}
const areas: Area[] = (bundled as any).features.map((f: any) => ({
...f.properties,
geometry: f.geometry,
}));
const geoms: AreaGeometry[] = areas.map((a) => a.geometry);
/**
* Points that genuinely lie on an area: edge midpoints. A centroid is no good
* an L-shaped run's lands mid-block and the crescent City Beach lot's lands in
* the water.
*/
function onGeometry(g: AreaGeometry): LonLat[] {
const rings = g.type === 'Polygon' ? g.coordinates : [g.coordinates];
const out: LonLat[] = [];
for (const r of rings) {
for (let i = 1; i < r.length; i++) {
out.push([(r[i - 1][0] + r[i][0]) / 2, (r[i - 1][1] + r[i][1]) / 2]);
}
}
return out;
}
function nearest(p: LonLat): { area: Area; dist: number } {
let best = areas[0];
let bd = Infinity;
for (const a of areas) {
const d = distanceToGeometry(p, a.geometry);
if (d < bd) {
best = a;
bd = d;
}
}
return { area: best, dist: bd };
}
test('the bundled map has the expected shape', () => {
assert.equal(areas.length, 49);
assert.ok(areas.some((a) => a.shape === 'polygon'), 'city lots should be polygons');
assert.ok(areas.some((a) => a.shape === 'line'), 'on-street runs should be lines');
});
test('a point on an area measures zero distance to it', () => {
for (const a of areas) {
for (const p of onGeometry(a.geometry)) {
const d = distanceToGeometry(p, a.geometry);
assert.ok(d < 0.01, `${a.id}: a point on it measured ${d.toFixed(3)} m away`);
}
}
});
test('hit-testing resolves each area from points on it', () => {
for (const a of areas) {
for (const p of onGeometry(a.geometry)) {
const hit = nearest(p);
if (hit.area.id === a.id) continue;
// Categories meet at intersections, so an exact tie is acceptable; silently
// resolving to something FURTHER away is the bug this guards against.
assert.ok(
hit.dist < 0.01,
`${a.id}: a point on it resolved to ${hit.area.id} at ${hit.dist.toFixed(2)} m`,
);
}
}
});
test('a point off the map does not snap to an area', () => {
// East of the highway, across Sand Creek — no mapped parking anywhere near.
assert.ok(nearest([-116.5445, 48.2705]).dist > 40);
});
test('the identity overlay is a no-op', () => {
const anchor = overlayAnchor(geoms);
for (const g of geoms) assert.deepEqual(adjustGeometry(g, IDENTITY_OVERLAY, anchor), g);
});
test('a shift moves every vertex by exactly that distance', () => {
const anchor = overlayAnchor(geoms);
for (const g of geoms) {
const moved = adjustGeometry(g, { ...IDENTITY_OVERLAY, dxMeters: 10 }, anchor);
const a = g.type === 'Polygon' ? g.coordinates[0] : g.coordinates;
const b = moved.type === 'Polygon' ? moved.coordinates[0] : moved.coordinates;
for (let i = 0; i < a.length; i++) {
assert.ok(Math.abs(distanceMeters(a[i], b[i]) - 10) < 0.05, 'shift distance');
assert.ok(b[i][0] > a[i][0], 'positive dxMeters must move east');
}
}
});
test('rotation is rigid about the anchor and 360° returns home', () => {
const anchor = overlayAnchor(geoms);
const g = geoms.find((x) => x.type === 'LineString') as Extract<
AreaGeometry,
{ type: 'LineString' }
>;
const spun = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 360 }, anchor) as typeof g;
for (let i = 0; i < g.coordinates.length; i++) {
assert.ok(distanceMeters(g.coordinates[i], spun.coordinates[i]) < 0.01, '360° round trip');
}
const rot = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 5 }, anchor) as typeof g;
for (let i = 0; i < g.coordinates.length; i++) {
const r0 = distanceMeters(anchor, g.coordinates[i]);
const r1 = distanceMeters(anchor, rot.coordinates[i]);
assert.ok(Math.abs(r0 - r1) < 0.5, `rotation changed radius ${r0.toFixed(1)} -> ${r1.toFixed(1)}`);
}
});
test('scale is anchored and proportional', () => {
const anchor = overlayAnchor(geoms);
const far = (geoms.find((x) => x.type === 'LineString') as any).coordinates[0] as LonLat;
const scaled = adjustGeometry(
{ type: 'LineString', coordinates: [anchor, far] },
{ ...IDENTITY_OVERLAY, scale: 2 },
anchor,
) as Extract<AreaGeometry, { type: 'LineString' }>;
assert.ok(distanceMeters(scaled.coordinates[0], anchor) < 0.01, 'the anchor must not move');
const before = distanceMeters(anchor, far);
const after = distanceMeters(anchor, scaled.coordinates[1]);
assert.ok(Math.abs(after - 2 * before) < 0.5, `×2: ${before.toFixed(1)} -> ${after.toFixed(1)}`);
});

504
package-lock.json generated
View file

@ -40,6 +40,7 @@
"devDependencies": {
"@types/react": "~19.0.0",
"babel-plugin-module-resolver": "^5.0.2",
"tsx": "^4.23.12",
"typescript": "~5.4.0"
}
},
@ -1599,6 +1600,448 @@
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@expo/cli": {
"version": "0.24.24",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
@ -4502,6 +4945,48 @@
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -9151,6 +9636,25 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/type-detect": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",

View file

@ -1,6 +1,14 @@
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
import rateLimit from '@fastify/rate-limit';
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
import {
LabelDb,
LABEL_KINDS,
isLabelKind,
isAreaKind,
AREA_KINDS,
type ZoneLabel,
type ParkingArea,
} from './db.js';
import { AbuseGuard, safeEqual } from './auth.js';
export interface BuildOptions {
@ -143,6 +151,78 @@ export async function buildApp(opts: BuildOptions) {
},
);
// ---- City parking-map areas ---------------------------------------------
// The colour-coded areas from the city's printed Downtown & Waterfront parking
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
// which is the point — tracking time on these spots must work with no IPS call.
const num = (v: unknown, fallback: number): number => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
};
app.get('/api/areas', async () => ({
areas: db.allAreas(),
overlay: db.getOverlay(),
count: db.areaCount(),
updatedAt: db.areasUpdatedAt(),
}));
// Replace-all, not upsert: the areas come from one source document, so a partial
// update would leave a map matching neither the old print nor the new one.
app.put(
'/api/areas',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
if (!input) return reply.code(400).send({ error: 'areas_required' });
const areas: ParkingArea[] = [];
for (const raw of input as Array<Record<string, unknown>>) {
if (raw == null || raw.id == null) continue;
if (!isAreaKind(raw.kind)) {
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
}
const g = raw.geometry as { type?: unknown } | null;
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
}
areas.push({
id: String(raw.id),
kind: raw.kind,
name: String(raw.name ?? raw.id),
label: String(raw.label ?? raw.kind),
legend: String(raw.legend ?? ''),
hours: num(raw.hours, 0),
color: String(raw.color ?? '#888888'),
shape: g.type === 'Polygon' ? 'polygon' : 'line',
geometry: g,
});
}
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
},
);
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
app.put(
'/api/areas/overlay',
{ preHandler: requireAdmin, ...writeLimit },
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
const b = req.body ?? {};
const scale = num(b.scale, 1);
if (scale <= 0.5 || scale >= 2) {
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
return reply.code(400).send({ error: 'scale_out_of_range' });
}
return db.setOverlay({
dxMeters: num(b.dxMeters, 0),
dyMeters: num(b.dyMeters, 0),
scale,
rotationDeg: num(b.rotationDeg, 0),
});
},
);
app.addHook('onClose', async () => db.close());
return app;
}

View file

@ -9,6 +9,56 @@ export function isLabelKind(v: unknown): v is LabelKind {
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
}
/** The five categories on the city's printed Downtown & Waterfront parking map. */
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
export function isAreaKind(v: unknown): v is AreaKind {
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
}
/**
* One coloured area from the city map: an on-street segment (LineString) or a
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones
* nothing here ever reaches the IPS API.
*/
export interface ParkingArea {
id: string;
kind: AreaKind;
name: string;
label: string;
legend: string;
hours: number;
color: string;
shape: 'line' | 'polygon';
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
geometry: unknown;
}
/**
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
* is good to a few metres but not perfect; this lets the alignment be nudged from the
* phone against a live GPS fix and persisted, with no app release.
*
* Offsets are ground metres (east/north); scale and rotation apply about the
* overlay's own centroid.
*/
export interface OverlayAdjust {
dxMeters: number;
dyMeters: number;
scale: number;
rotationDeg: number;
updatedAt: number;
}
export const IDENTITY_OVERLAY: OverlayAdjust = {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
};
export interface ZoneLabel {
zoneId: string;
customerId: string | null;
@ -70,9 +120,109 @@ export class LabelDb {
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS parking_areas (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
label TEXT NOT NULL,
legend TEXT NOT NULL,
hours REAL NOT NULL,
color TEXT NOT NULL,
shape TEXT NOT NULL,
geometry TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS map_overlay (
id TEXT PRIMARY KEY,
dx_meters REAL NOT NULL,
dy_meters REAL NOT NULL,
scale REAL NOT NULL,
rotation REAL NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
/* ------------------------------------------------ city parking-map areas */
allAreas(): ParkingArea[] {
const rows = this.db
.prepare('SELECT * FROM parking_areas ORDER BY id')
.all() as Array<Record<string, any>>;
return rows.map((r) => ({
id: r.id,
kind: r.kind as AreaKind,
name: r.name,
label: r.label,
legend: r.legend,
hours: r.hours,
color: r.color,
shape: r.shape as 'line' | 'polygon',
geometry: JSON.parse(r.geometry),
}));
}
areaCount(): number {
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
}
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
areasUpdatedAt(): number {
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
t: number | null;
};
return r.t ?? 0;
}
/**
* Replace the whole area set in one transaction. The areas come from a single
* source document, so a partial update would leave a map that matches neither
* the old print nor the new one.
*/
replaceAreas(areas: ParkingArea[]): number {
const now = Date.now();
const insert = this.db.prepare(
`INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at)
VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`,
);
this.db.transaction((items: ParkingArea[]) => {
this.db.prepare('DELETE FROM parking_areas').run();
for (const a of items) {
insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now });
}
})(areas);
return areas.length;
}
getOverlay(): OverlayAdjust {
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
| Record<string, any>
| undefined;
if (!r) return IDENTITY_OVERLAY;
return {
dxMeters: r.dx_meters,
dyMeters: r.dy_meters,
scale: r.scale,
rotationDeg: r.rotation,
updatedAt: r.updated_at,
};
}
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
const updatedAt = Date.now();
this.db
.prepare(
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
VALUES ('default', ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
scale = excluded.scale, rotation = excluded.rotation,
updated_at = excluded.updated_at`,
)
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
return { ...o, updatedAt };
}
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
upsertZones(
rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>,

196
server/test/areas.test.ts Normal file
View file

@ -0,0 +1,196 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildApp, type BuildOptions } from '../src/app.ts';
const TOKEN = 'test-admin-token-0123456789';
const auth = { authorization: `Bearer ${TOKEN}` };
const make = (o: Partial<BuildOptions> = {}) =>
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
const line = (id: string, kind = 'free_2h') => ({
id,
kind,
name: `${id} name`,
label: '2-hour free',
legend: 'Permits not valid',
hours: 2,
color: '#d367cc',
geometry: {
type: 'LineString',
coordinates: [
[-116.5535, 48.2766],
[-116.5525, 48.2766],
],
},
});
test('areas are public to read and admin-only to replace', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.statusCode, 200);
assert.deepEqual(r.json().areas, []);
assert.equal(r.json().count, 0);
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 2);
r = await app.inject({ method: 'GET', url: '/api/areas' });
const areas = r.json().areas;
assert.equal(areas.length, 2);
assert.equal(areas[0].id, 'sp-001');
assert.equal(areas[0].kind, 'free_2h');
assert.equal(areas[0].shape, 'line');
// Geometry survives the round-trip as real GeoJSON, not a string.
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
await app.close();
});
test('replace is wholesale — stale areas do not survive', async () => {
const app = await make();
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-001'), line('sp-002')] },
});
await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [line('sp-003')] },
});
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-003');
await app.close();
});
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
const app = await make();
const lot = {
id: 'sp-022',
kind: 'green_lot',
name: 'Lot off Oak St',
label: 'City lot',
legend: 'Paid hourly or permit',
hours: 2,
color: '#75b259',
geometry: {
type: 'Polygon',
coordinates: [
[
[-116.554, 48.2766],
[-116.553, 48.2766],
[-116.553, 48.2772],
[-116.554, 48.2766],
],
],
},
};
let r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [lot] },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().replaced, 1);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().areas[0].shape, 'polygon');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_kind');
r = await app.inject({
method: 'PUT',
url: '/api/areas',
headers: auth,
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
});
assert.equal(r.statusCode, 400);
assert.equal(r.json().error, 'bad_geometry');
// A rejected batch must not have clobbered the good one.
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().count, 1);
assert.equal(r.json().areas[0].id, 'sp-022');
await app.close();
});
test('overlay defaults to identity and round-trips', async () => {
const app = await make();
let r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.deepEqual(r.json().overlay, {
dxMeters: 0,
dyMeters: 0,
scale: 1,
rotationDeg: 0,
updatedAt: 0,
});
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
payload: { dxMeters: 3 },
});
assert.equal(r.statusCode, 401);
r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
});
assert.equal(r.statusCode, 200);
assert.equal(r.json().dxMeters, 3.5);
assert.ok(r.json().updatedAt > 0);
r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.dyMeters, -2);
assert.equal(r.json().overlay.rotationDeg, 0.4);
await app.close();
});
test('an absurd overlay scale is refused rather than stored', async () => {
const app = await make();
for (const scale of [0, 0.4, 2, 5]) {
const r = await app.inject({
method: 'PUT',
url: '/api/areas/overlay',
headers: auth,
payload: { scale },
});
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
}
const r = await app.inject({ method: 'GET', url: '/api/areas' });
assert.equal(r.json().overlay.scale, 1);
await app.close();
});

72
tools/citymap/README.md Normal file
View file

@ -0,0 +1,72 @@
# 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
```bash
# 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.

View 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")

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()

114
tools/citymap/georef.py Normal file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Georeference the parking-map page coordinates against OSM.
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
uniform scale plus a translation (3 free params, not 6). Control points are
street centrelines identified by name: an avenue pins X, a street pins Y.
Streets in Sandpoint jog between the north and south halves of the grid, so each
control street's mercator coordinate is measured only over the span the page
segment actually covers, not over the whole way.
"""
import json
import math
R = 6378137.0
def merc(lat, lon):
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
def unmerc(X, Y):
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
osm = json.load(open("osm.json"))
ways = {}
for w in osm["elements"]:
n = w.get("tags", {}).get("name")
if not n or "geometry" not in w:
continue
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
def centreline(name, axis, lo, hi):
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
other = 1 - axis
vals = []
for g in ways.get(name, []):
for (x0, y0), (x1, y1) in zip(g, g[1:]):
p0, p1 = (x0, y0), (x1, y1)
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
continue
vals.append((p0[axis] + p1[axis]) / 2)
return sum(vals) / len(vals) if vals else None
# Page grid lines read off the rendered map, with the mercator window each spans.
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
YW = (6152200, 6153300) # Lake St .. Poplar St
AVENUES = [ # page x, OSM name
(83.1, "North 5th Avenue"),
(120.0, "North 4th Avenue"),
(163.8, "North 3rd Avenue"),
(207.7, "North 2nd Avenue"),
(235.7, "North 1st Avenue"),
(164.3, "South 3rd Avenue"),
(198.4, "South 2nd Avenue"),
]
STREETS = [ # page y, OSM name
(184.4, "Poplar Street"),
(228.3, "Alder Street"),
(272.3, "Cedar Street"),
(314.9, "Oak Street"),
(359.1, "Church Street"),
(398.0, "Pine Street"),
(438.2, "Lake Street"),
(492.1, "Superior Street"),
]
def fit(pairs, flip):
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
n = len(pairs)
sp = sum(p for p, v in pairs)
sv = sum(v for p, v in pairs)
spp = sum(p * p for p, v in pairs)
spv = sum(p * v for p, v in pairs)
s = (n * spv - sp * sv) / (n * spp - sp * sp)
t = (sv - s * sp) / n
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
print("control points (mercator metres):")
for (px, n), (_, v) in zip(AVENUES, ax):
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
for (py, n), (_, v) in zip(STREETS, ay):
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
ax = [(p, v) for p, v in ax if v is not None]
ay = [(p, v) for p, v in ay if v is not None]
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
def report(label, pairs, names):
s, t, res = fit(pairs, False)
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
for (p, v, r), nm in zip(res, names):
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
print(f" RMS = {rms:.1f} ground-m")
return s, t, rms
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)

View file

@ -0,0 +1,3 @@
[out:json][timeout:60];
way["highway"](48.2600,-116.5750,48.2900,-116.5300);
out geom;