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

@ -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)}`);
});