v0.6.0: city parking-map overlay + local time tracking, no IPS API
All checks were successful
build-apk / build (push) Successful in 10m38s
All checks were successful
build-apk / build (push) Successful in 10m38s
Adds the City of Sandpoint's printed "Downtown & Waterfront Public Parking" map as a georeferenced overlay, and lets you track your time on any of its areas without ever touching the ParkSmarter/IPS API. Georeferencing (tools/citymap/) - The PDF carries no geo metadata, so the page->WebMercator affine is recovered by fitting the drawing to OSM street centrelines. - pdftocairo writes stroked street segments with per-path matrix() transforms in local coords while filled lots are absolute; both are handled. The five legend swatches share the real geometry's colours and are identified by stroke-width and position, then dropped. - 49 areas, fitted to RMS 4.1 m (X) / 3.5 m (Y). On-street segments land a mean 4.0 m from the nearest OSM road. sp-039/040 sit further out because they are angled bays along the old rail corridor, on no named road at all. - Sandpoint's grid jogs 38 m between N 2nd Ave and S 2nd Ave; the page shows the same jog at the fitted scale, which independently confirms the fit. App - Map tab: "City map" layer in the legend's colours, tappable. - "Park here" pins the car from GPS and auto-detects the containing area (40 m snap). With no fix it asks you to tap the spot instead, so the pin never depends on GPS working. - The pin lives in its own storage key, not inside the session: pinning the car without starting a timer must survive backing out of the screen. - Durations cap at the posted limit — a 2-hour space is not offered a 4-hour timer. Lots and no-limit spots get the long options. - Reuses the existing foreground-service countdown. The second notification button reads "+1 hr" for a city area rather than "Extend": there is nothing to buy, so it edits the local timer and says so. - Account -> Align city map: nudge/scale/rotate the whole overlay against a live GPS fix. Save-on-phone needs no admin token, since the person who can see the misalignment is the one standing on the street. Server - parking_areas + map_overlay tables, public read, admin replace-all. The areas come from one source document, so replacement is wholesale rather than an upsert. Dropped geometryCenter from the geo module: on the real data it returns a point in the water for the crescent City Beach lot and mid-block for L-shaped runs. Nothing used it. Tests: 8 geometry tests in app/, 5 area/overlay tests in server/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ad55559f55
commit
148e1635d3
23 changed files with 3027 additions and 21 deletions
147
app/src/features/citymap/geo.ts
Normal file
147
app/src/features/citymap/geo.ts
Normal 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];
|
||||
}
|
||||
1
app/src/features/citymap/parkingAreas.json
Normal file
1
app/src/features/citymap/parkingAreas.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue