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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
216
app/src/api/parkingAreas.ts
Normal 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
230
app/src/screens/CityAreaScreen.tsx
Normal file
230
app/src/screens/CityAreaScreen.tsx
Normal 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',
|
||||
},
|
||||
});
|
||||
309
app/src/screens/MapAlignScreen.tsx
Normal file
309
app/src/screens/MapAlignScreen.tsx
Normal 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' },
|
||||
});
|
||||
|
|
@ -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
152
app/test/geo.test.ts
Normal 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)}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue