v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s

Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking"
map as a georeferenced overlay, and lets you track your time on any of its
areas without ever touching the ParkSmarter/IPS API.

Georeferencing (tools/citymap/)
- The PDF carries no geo metadata, so the page->WebMercator affine is
  recovered by fitting the drawing to OSM street centrelines.
- pdftocairo writes stroked street segments with per-path matrix()
  transforms in local coords while filled lots are absolute; both are
  handled. The five legend swatches share the real geometry's colours and
  are identified by stroke-width and position, then dropped.
- 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a
  mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because
  they are angled bays along the old rail corridor, on no named road at all.
- Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows
  the same jog at the fitted scale, which independently confirms the fit.

App
- Map tab: "City map" layer in the legend's colours, tappable.
- "Park here" pins the car from GPS and auto-detects the containing area
  (40 m snap). With no fix it asks you to tap the spot instead, so the pin
  never depends on GPS working.
- The pin lives in its own storage key, not inside the session: pinning the
  car without starting a timer must survive backing out of the screen.
- Durations cap at the posted limit — a 2-hour space is not offered a
  4-hour timer. Lots and no-limit spots get the long options.
- Reuses the existing foreground-service countdown. The second notification
  button reads "+1 hr" for a city area rather than "Extend": there is
  nothing to buy, so it edits the local timer and says so.
- Account -> Align city map: nudge/scale/rotate the whole overlay against a
  live GPS fix. Save-on-phone needs no admin token, since the person who can
  see the misalignment is the one standing on the street.

Server
- parking_areas + map_overlay tables, public read, admin replace-all. The
  areas come from one source document, so replacement is wholesale rather
  than an upsert.

Dropped geometryCenter from the geo module: on the real data it returns a
point in the water for the crescent City Beach lot and mid-block for
L-shaped runs. Nothing used it.

Tests: 8 geometry tests in app/, 5 area/overlay tests in server/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 03:28:18 +00:00
parent ad55559f55
commit 148e1635d3
23 changed files with 3027 additions and 21 deletions

View file

@ -0,0 +1,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);
}