Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 718c72dde1 | |||
| 4217e88338 | |||
| 6af3dad762 | |||
| 7fc92d24e4 | |||
| 148e1635d3 |
28 changed files with 3629 additions and 98 deletions
44
README.md
44
README.md
|
|
@ -39,6 +39,8 @@ bigbrainparking/
|
|||
| --- | --- | --- |
|
||||
| Phone + password login | ✅ wired | tokens in OS keystore |
|
||||
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
|
||||
| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** |
|
||||
| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown |
|
||||
| Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" |
|
||||
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
|
||||
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
||||
|
|
@ -48,6 +50,48 @@ bigbrainparking/
|
|||
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
||||
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
||||
|
||||
## The city parking map
|
||||
|
||||
The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown &
|
||||
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
||||
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
||||
|
||||
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
|
||||
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
|
||||
phone's own clock, and the notification is the same foreground service every other session
|
||||
uses. So tracking your time on a free city spot works with no account, no signal, no
|
||||
payment, and in Anonymous Mode.
|
||||
|
||||
The **green city lots are the exception** — they're the map's only paid category, and paying
|
||||
for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking
|
||||
you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park
|
||||
here" says so rather than reporting nothing nearby.) A single lot can be flipped back via
|
||||
the server's `requiresAccount` field if it turns out to take payment another way.
|
||||
|
||||
Two ways to start:
|
||||
|
||||
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
|
||||
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
||||
stays on the map until you end the session, because "where did I leave the car" is half
|
||||
the point.
|
||||
- **Tap a coloured segment** — pick the block directly, no pin needed.
|
||||
|
||||
Either way you choose how long to track, capped at the posted limit (a 2-hour space won't
|
||||
offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's
|
||||
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
||||
edits the local timer and says so.
|
||||
|
||||
The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour,
|
||||
end it, and see recent ones — with no account and no network, because that is the only
|
||||
place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and
|
||||
failing to reach them (offline, or signed out) never hides the local half.
|
||||
|
||||
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
|
||||
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
|
||||
Because a few metres is the difference between two sides of a street, **Account → Align city
|
||||
map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or
|
||||
published to the server for every device if you hold the admin token.
|
||||
|
||||
## Privacy
|
||||
|
||||
BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
"name": "BigBrainParking",
|
||||
"slug": "bigbrainparking",
|
||||
"scheme": "bigbrainparking",
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.4",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"icon": "./assets/icon.png",
|
||||
"android": {
|
||||
"package": "top.mowden.bigbrainparking",
|
||||
"versionCode": 20,
|
||||
"versionCode": 25,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
234
app/src/api/parkingAreas.ts
Normal file
234
app/src/api/parkingAreas.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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;
|
||||
/**
|
||||
* Overrides the by-category default in [areaRequiresAccount]. Only set this to
|
||||
* correct a specific lot — e.g. one that turns out to be kiosk- or permit-only.
|
||||
*/
|
||||
requiresAccount?: boolean;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether you need a ParkSmarter account to park here.
|
||||
*
|
||||
* The city lots are the map's only paid category ("City lots — Paid hourly or
|
||||
* permit"); paying for them means ParkSmarter, so they're no use to someone
|
||||
* browsing without an account. Everything else is free with a posted time limit
|
||||
* and needs nothing. A single lot can override this if it turns out to take
|
||||
* payment some other way.
|
||||
*/
|
||||
export function areaRequiresAccount(area: ParkingArea): boolean {
|
||||
return area.requiresAccount ?? area.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);
|
||||
}
|
||||
|
|
@ -31,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Let the non-React modules see the mode. Kept in sync here rather than read
|
||||
// from storage: anonymous mode is deliberately not persisted across restarts.
|
||||
useEffect(() => {
|
||||
authBus.isAnonymous = status === 'anonymous';
|
||||
}, [status]);
|
||||
|
||||
// Any 401 from the API (expired/rotated token) bounces us back to sign-in.
|
||||
useEffect(() => {
|
||||
authBus.onUnauthorized = () => {
|
||||
// ...except in Anonymous Mode, where there is no session to expire. A 401
|
||||
// there just means something asked ParkSmarter a question it had no
|
||||
// business asking, and bouncing to sign-in would make the app unusable
|
||||
// without an account — which is the whole point of the mode. Read at call
|
||||
// time, so this stays correct as the status changes.
|
||||
if (authBus.isAnonymous) return;
|
||||
setError('Your session expired — please sign in again.');
|
||||
setStatus('signedOut');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,5 +2,13 @@
|
|||
* Tiny bridge so the API client (created at module load) can notify the React
|
||||
* auth layer when a 401 happens, without a circular import. AuthProvider
|
||||
* registers a handler; the client calls it via app/src/api/client.ts.
|
||||
*
|
||||
* `isAnonymous` mirrors the auth status for the non-React modules that need it.
|
||||
* It matters because the 401 hook is global: it fires on every unauthorized
|
||||
* response whether or not the caller caught the error, so without this a single
|
||||
* stray ParkSmarter call in Anonymous Mode throws the user to the sign-in screen.
|
||||
*/
|
||||
export const authBus: { onUnauthorized?: () => void } = {};
|
||||
export const authBus: {
|
||||
onUnauthorized?: () => void;
|
||||
isAnonymous: boolean;
|
||||
} = { isAnonymous: false };
|
||||
|
|
|
|||
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
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Location from 'expo-location';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
|
|
@ -7,6 +7,20 @@ export interface Coords {
|
|||
longitude: number;
|
||||
}
|
||||
|
||||
export interface UseLocationOptions {
|
||||
/**
|
||||
* Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount.
|
||||
* A fix goes stale as soon as you drive a block, so any screen that shows
|
||||
* "where am I" for more than a moment wants this.
|
||||
*/
|
||||
intervalMs?: number;
|
||||
/**
|
||||
* Poll only while true. Callers pass screen focus AND app foreground: polling
|
||||
* a map nobody is looking at spends battery on an answer no one reads.
|
||||
*/
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const LAST_LOC_KEY = 'ps_last_location';
|
||||
|
||||
/** Persist the most recent fix so the "near my last location" button works cold. */
|
||||
|
|
@ -21,32 +35,54 @@ export async function getLastKnownSavedLocation(): Promise<Coords | null> {
|
|||
/**
|
||||
* Foreground location. On GrapheneOS this uses the OS location provider directly
|
||||
* (no Google Play Services). We prefer a fast last-known fix, then refine.
|
||||
*
|
||||
* `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix
|
||||
* from one that has been sitting there since the screen opened.
|
||||
*/
|
||||
export function useLocation() {
|
||||
export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) {
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState(0);
|
||||
const [granted, setGranted] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Read inside refresh() without making it a dependency — refresh is the
|
||||
// interval's callback, and a changing identity would restart the timer on
|
||||
// every fix, so it would never actually reach the interval.
|
||||
const haveFix = useRef(false);
|
||||
const permitted = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
if (!permitted.current) {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
const ok = status === 'granted';
|
||||
permitted.current = ok;
|
||||
setGranted(ok);
|
||||
if (!ok) {
|
||||
setError('Location permission denied.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Only worth it before we have anything to show: on a later poll the
|
||||
// last-known fix is usually older than the one we already hold, and
|
||||
// publishing it would make the dot jump backwards.
|
||||
if (!haveFix.current) {
|
||||
const last = await Location.getLastKnownPositionAsync();
|
||||
if (last) {
|
||||
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
||||
haveFix.current = true;
|
||||
setCoords(c);
|
||||
setUpdatedAt(Date.now());
|
||||
void saveLastLocation(c);
|
||||
}
|
||||
}
|
||||
const cur = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
||||
haveFix.current = true;
|
||||
setCoords(c);
|
||||
setUpdatedAt(Date.now());
|
||||
setError(null);
|
||||
void saveLastLocation(c);
|
||||
return c;
|
||||
} catch (e: any) {
|
||||
|
|
@ -56,8 +92,19 @@ export function useLocation() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Denied is denied — polling it every 30s just burns wake-ups to be told no.
|
||||
if (granted === false) return;
|
||||
// Re-activating (screen focused, app foregrounded) is exactly when the held
|
||||
// fix is most likely to be stale, so read one straight away rather than
|
||||
// waiting out a whole interval.
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
if (!intervalMs) return;
|
||||
const id = setInterval(() => {
|
||||
void refresh();
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [active, granted, intervalMs, refresh]);
|
||||
|
||||
return { coords, granted, error, refresh };
|
||||
return { coords, updatedAt, granted, error, refresh };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|||
import * as Notifications from 'expo-notifications';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
import { ps } from '@/api/client';
|
||||
import { authBus } from '@/auth/authBus';
|
||||
import { parseApiTime } from '@/api/parseTime';
|
||||
import type { LabelKind } from '@/api/zoneLabels';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
|
@ -18,11 +19,15 @@ import {
|
|||
getRemindersEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import type { ParkingArea } from '@/api/parkingAreas';
|
||||
import { recordLocalSession } from './localHistory';
|
||||
import {
|
||||
clearActiveParking,
|
||||
getActiveParking,
|
||||
setActiveParking,
|
||||
setParkedPin,
|
||||
type ActiveParking,
|
||||
type ParkedSpot,
|
||||
} from './activeParkingStore';
|
||||
import {
|
||||
clearSession,
|
||||
|
|
@ -48,6 +53,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 +76,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 +185,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.
|
||||
*
|
||||
|
|
@ -184,6 +256,10 @@ export async function startFreeCheckin(
|
|||
* keeps running at the meter whether or not the app is showing it.
|
||||
*/
|
||||
export async function endActiveParking(): Promise<void> {
|
||||
// Write it to history before dropping it. A local session has no server copy, so
|
||||
// if it isn't recorded here it is simply gone.
|
||||
const current = await getActiveParking();
|
||||
if (current) await recordLocalSession(current);
|
||||
await clearActiveParking();
|
||||
await clearNotification();
|
||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||
|
|
@ -247,7 +323,18 @@ export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promis
|
|||
current = null;
|
||||
}
|
||||
|
||||
if (!current) current = await discoverPaidSession();
|
||||
// "+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. In Anonymous Mode there is no account to ask at
|
||||
// all — asking anyway would 401 on every single foreground.
|
||||
if (!current && !authBus.isAnonymous) current = await discoverPaidSession();
|
||||
|
||||
if (current) {
|
||||
await postNotification(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);
|
||||
}
|
||||
|
|
|
|||
81
app/src/features/session/localHistory.ts
Normal file
81
app/src/features/session/localHistory.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { AreaKind } from '@/api/parkingAreas';
|
||||
import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore';
|
||||
|
||||
/**
|
||||
* History for the sessions ParkSmarter never sees.
|
||||
*
|
||||
* A city-map timer or a free check-in exists only on this phone, so if it isn't
|
||||
* recorded here it vanishes the moment it ends — there is no server to ask. Paid
|
||||
* ParkSmarter sessions are deliberately excluded: those already come back from the
|
||||
* account, and storing them too would show every one of them twice.
|
||||
*/
|
||||
|
||||
const KEY = 'ps_local_session_history';
|
||||
/** Enough to cover months of parking without letting the record grow forever. */
|
||||
const MAX = 50;
|
||||
|
||||
export interface LocalSessionRecord {
|
||||
/** Start time doubles as the id — there is only ever one session at a time. */
|
||||
id: string;
|
||||
kind: ParkingKind;
|
||||
zoneName: string;
|
||||
areaId?: string;
|
||||
areaKind?: AreaKind;
|
||||
color?: string;
|
||||
legend?: string;
|
||||
startMs: number;
|
||||
/** When it was due to end. */
|
||||
plannedEndMs: number;
|
||||
/** When it actually ended. */
|
||||
endedAtMs: number;
|
||||
/** True when the user ended it before the clock ran out. */
|
||||
endedEarly: boolean;
|
||||
spot?: ParkedSpot;
|
||||
}
|
||||
|
||||
/** True when ParkSmarter has no record of this session, so we must keep our own. */
|
||||
export function isLocalOnly(p: ActiveParking): boolean {
|
||||
return !p.transactionId;
|
||||
}
|
||||
|
||||
export async function getLocalHistory(): Promise<LocalSessionRecord[]> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as LocalSessionRecord[];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a finished local session. No-op for anything ParkSmarter already has. */
|
||||
export async function recordLocalSession(p: ActiveParking): Promise<void> {
|
||||
if (!isLocalOnly(p)) return;
|
||||
const endedAtMs = Date.now();
|
||||
const record: LocalSessionRecord = {
|
||||
id: String(p.startMs),
|
||||
kind: p.kind,
|
||||
zoneName: p.zoneName,
|
||||
areaId: p.area?.id,
|
||||
areaKind: p.area?.kind,
|
||||
color: p.area?.color,
|
||||
legend: p.area?.legend,
|
||||
startMs: p.startMs,
|
||||
plannedEndMs: p.endMs,
|
||||
endedAtMs,
|
||||
endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up
|
||||
spot: p.spot,
|
||||
};
|
||||
const list = await getLocalHistory();
|
||||
// Guard against double-recording: ending can be driven from the notification and
|
||||
// the screen at nearly the same moment.
|
||||
const deduped = list.filter((r) => r.id !== record.id);
|
||||
deduped.unshift(record);
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX)));
|
||||
}
|
||||
|
||||
export async function clearLocalHistory(): Promise<void> {
|
||||
await AsyncStorage.removeItem(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)"
|
||||
|
|
|
|||
243
app/src/screens/CityAreaScreen.tsx
Normal file
243
app/src/screens/CityAreaScreen.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
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, areaRequiresAccount } from '@/api/parkingAreas';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
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 { isAnonymous, requireLogin } = useAuth();
|
||||
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}
|
||||
|
||||
{isAnonymous && areaRequiresAccount(area) ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Sign in to park here</Text>
|
||||
<Text style={s.sub}>
|
||||
This is a paid city lot — parking in it is bought through ParkSmarter, so it needs
|
||||
an account. The free time-limited streets on the map don't.
|
||||
</Text>
|
||||
<TouchableOpacity style={s.primary} onPress={requireLogin}>
|
||||
<Text style={s.primaryText}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : 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,24 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
AppState,
|
||||
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 +26,19 @@ 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 {
|
||||
areaRequiresAccount,
|
||||
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';
|
||||
|
||||
|
|
@ -30,8 +53,43 @@ const MAP_STYLE_DARK =
|
|||
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||
const DEFAULT_ZOOM = 14;
|
||||
|
||||
/**
|
||||
* How often to re-read the GPS while the map is the screen you're looking at.
|
||||
* A single fix at mount goes stale the moment you walk a block, which is the
|
||||
* whole time you'd be looking at this screen.
|
||||
*/
|
||||
const GPS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How old a fix can be before "Park here" / "My location" stops trusting it and
|
||||
* goes and asks again. A poll and a half, so a fix arriving on schedule is never
|
||||
* treated as stale.
|
||||
*/
|
||||
const FIX_MAX_AGE_MS = 45_000;
|
||||
|
||||
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');
|
||||
|
|
@ -49,13 +107,33 @@ export function MapScreen() {
|
|||
const insets = useSafeAreaInsets();
|
||||
const { mode } = useTheme();
|
||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
const { coords, refresh } = useLocation();
|
||||
// Poll the GPS while this screen is actually in front of someone. Focus alone
|
||||
// isn't enough: a backgrounded app stays "focused" on its last tab, and Android
|
||||
// won't give a foreground app's location out to one that isn't.
|
||||
const [focused, setFocused] = useState(true);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active'));
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
const { coords, updatedAt, refresh } = useLocation({
|
||||
intervalMs: GPS_REFRESH_MS,
|
||||
active: focused && foreground,
|
||||
});
|
||||
const { isAnonymous } = useAuth();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
||||
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);
|
||||
|
|
@ -69,11 +147,16 @@ export function MapScreen() {
|
|||
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||
// The native UserLocation dot has its own GPS feed — capture it so "My
|
||||
// location" works even when expo-location can't get a fix (e.g. indoors).
|
||||
const nativeFix = useRef<Coords | null>(null);
|
||||
// Stamped, because that feed goes quiet whenever the map isn't drawing and a
|
||||
// silently stale fix is worse than no fix.
|
||||
const nativeFix = useRef<(Coords & { at: number }) | null>(null);
|
||||
|
||||
// Look up the LAST session's parking-lot coordinate (the meter's own location
|
||||
// from history — never the user's GPS). Used to open the map and by "Last lot".
|
||||
const lastSessionLot = useCallback(async (): Promise<Coords | null> => {
|
||||
// No account, no session history — and asking anyway 401s, which the global
|
||||
// handler would turn into a bogus "session expired" bounce.
|
||||
if (isAnonymous) return null;
|
||||
try {
|
||||
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
|
||||
const s = past.Session?.[0] as Record<string, any> | undefined;
|
||||
|
|
@ -86,7 +169,7 @@ export function MapScreen() {
|
|||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
}, [isAnonymous]);
|
||||
|
||||
// Open on your last parking lot — NOT your GPS. Your location is only ever sent
|
||||
// to the API when you explicitly tap "My location", so we never auto-center on it.
|
||||
|
|
@ -148,6 +231,53 @@ 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. Focus also gates the GPS poll.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void getParkedPin().then(setSpot);
|
||||
setFocused(true);
|
||||
return () => setFocused(false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
/**
|
||||
* The freshest fix we can get, in the order it's cheapest to get it: the map's
|
||||
* own dot, then the polled expo fix, then a forced read. Whichever we hold is
|
||||
* only used if it's recent — the point of the poll is that "where am I" answers
|
||||
* with where you are now, not where you were when the screen opened.
|
||||
*/
|
||||
const bestFix = useCallback(async (): Promise<Coords | null> => {
|
||||
const now = Date.now();
|
||||
const n = nativeFix.current;
|
||||
if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude };
|
||||
if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords;
|
||||
const fresh = await refresh();
|
||||
if (fresh) return fresh;
|
||||
// Nothing current and nothing new — a stale fix still beats no answer.
|
||||
return n ? { latitude: n.latitude, longitude: n.longitude } : coords;
|
||||
}, [coords, updatedAt, refresh]);
|
||||
|
||||
// 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.)
|
||||
|
|
@ -171,10 +301,9 @@ export function MapScreen() {
|
|||
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
||||
};
|
||||
|
||||
// Recenter on the live GPS fix (if available) and search there. Prefer the
|
||||
// native map fix (the blue dot), then expo-location, then a forced refresh.
|
||||
// Recenter on the live GPS fix (if available) and search there.
|
||||
const goToMyLocation = async () => {
|
||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
||||
const c = await bestFix();
|
||||
if (!c) {
|
||||
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
||||
return;
|
||||
|
|
@ -190,6 +319,10 @@ export function MapScreen() {
|
|||
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
|
||||
// history — never your GPS) and search around it with a ~couple-mile view.
|
||||
const searchLastSessionLot = async () => {
|
||||
if (isAnonymous) {
|
||||
setStatus('Sign in to use your last parking lot — it comes from your account history.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus('Finding your last parking lot…');
|
||||
const lot = await lastSessionLot();
|
||||
|
|
@ -206,6 +339,139 @@ export function MapScreen() {
|
|||
await searchAt(lot, 'your last lot');
|
||||
};
|
||||
|
||||
/* -------------------------------------------------- parking-map interaction */
|
||||
|
||||
/**
|
||||
* The paid city lots are ParkSmarter-only, so they're hidden from someone
|
||||
* browsing without an account — showing parking you can't actually buy is worse
|
||||
* than not showing it.
|
||||
*/
|
||||
const hidden = useCallback(
|
||||
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
|
||||
[isAnonymous],
|
||||
);
|
||||
|
||||
/** What actually gets drawn and tapped. */
|
||||
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
|
||||
|
||||
/** 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);
|
||||
// Detect against every area, including the ones hidden from this user, so
|
||||
// standing in a paid lot gets an explanation rather than "nothing found".
|
||||
const found = areaAt([c.longitude, c.latitude], areas);
|
||||
if (found && hidden(found)) {
|
||||
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
|
||||
} else 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, hidden, 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 = await bestFix();
|
||||
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 = visibleAreas.find((a) => a.id === id);
|
||||
if (found) openArea(found, spot ?? undefined);
|
||||
};
|
||||
|
||||
const areaFeatures = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: visibleAreas.map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
id: a.id,
|
||||
geometry: a.geometry,
|
||||
properties: { id: a.id, color: a.color, kind: a.kind },
|
||||
})),
|
||||
}),
|
||||
[visibleAreas],
|
||||
);
|
||||
|
||||
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(
|
||||
|
|
@ -246,6 +512,7 @@ export function MapScreen() {
|
|||
nativeFix.current = {
|
||||
latitude: loc.coords.latitude,
|
||||
longitude: loc.coords.longitude,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
}}
|
||||
|
|
@ -262,6 +529,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 +541,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,7 +622,22 @@ export function MapScreen() {
|
|||
</View>
|
||||
|
||||
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
|
||||
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
|
||||
<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}>
|
||||
|
|
@ -314,6 +648,7 @@ export function MapScreen() {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -337,9 +672,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 +688,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' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,72 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { endActiveParking, extendAreaParking } from '@/features/session/activeParking';
|
||||
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||
import {
|
||||
getLocalHistory,
|
||||
isLocalOnly,
|
||||
type LocalSessionRecord,
|
||||
} from '@/features/session/localHistory';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDate(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function fmtSpan(from: number, to: number): string {
|
||||
const mins = Math.max(0, Math.round((to - from) / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions, in two halves that must not depend on each other.
|
||||
*
|
||||
* Anything tracked on this phone — a city-map timer, a free check-in — is shown
|
||||
* and managed with no network and no account, because that is the only place it
|
||||
* exists. ParkSmarter's own sessions are layered on top when signed in, and a
|
||||
* failure to reach them (offline, or simply not logged in) must never hide the
|
||||
* local half.
|
||||
*/
|
||||
export function SessionsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
|
||||
const [local, setLocal] = useState<ActiveParking | null>(null);
|
||||
const [history, setHistory] = useState<LocalSessionRecord[]>([]);
|
||||
const [active, setActive] = useState<ActiveSession[]>([]);
|
||||
const [past, setPast] = useState<PastSession[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
/** On-device only. Never awaits the network, so it works offline and signed out. */
|
||||
const loadLocal = useCallback(async () => {
|
||||
const [a, h] = await Promise.all([getActiveParking(), getLocalHistory()]);
|
||||
setLocal(a && a.endMs > Date.now() ? a : null);
|
||||
setHistory(h);
|
||||
}, []);
|
||||
|
||||
const loadRemote = useCallback(async () => {
|
||||
if (isAnonymous) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
ps.getActiveParkingSessions(),
|
||||
|
|
@ -28,10 +74,22 @@ export function SessionsScreen() {
|
|||
]);
|
||||
setActive(a.ParkingSession ?? []);
|
||||
setPast(p.Session ?? []);
|
||||
setRemoteError(null);
|
||||
} catch (e: any) {
|
||||
// Offline or the API is unhappy. Say so quietly and keep the local half.
|
||||
setRemoteError(e?.serverMessage ?? e?.message ?? 'Could not reach ParkSmarter.');
|
||||
}
|
||||
}, [isAnonymous]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadLocal();
|
||||
await loadRemote();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [isAnonymous]);
|
||||
}, [loadLocal, loadRemote]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
|
@ -39,24 +97,14 @@ export function SessionsScreen() {
|
|||
}, [load]),
|
||||
);
|
||||
|
||||
if (isAnonymous) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
||||
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
|
||||
Sign in to see your sessions
|
||||
</Text>
|
||||
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
|
||||
Your active and past parking sessions live in your ParkSmarter account.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
|
||||
onPress={requireLogin}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Keep "time left" honest while the screen sits open.
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const id = setInterval(() => void loadLocal(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [local, loadLocal]);
|
||||
|
||||
const s = styles;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
|
@ -64,52 +112,166 @@ export function SessionsScreen() {
|
|||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||
>
|
||||
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
|
||||
{local ? (
|
||||
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
|
||||
<View style={s.chipRow}>
|
||||
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.big, { color: colors.text }]}>
|
||||
{fmtRemaining(local.endMs - Date.now())} left
|
||||
</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{local.area?.legend ? `${local.area.legend} · ` : ''}
|
||||
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
|
||||
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
|
||||
</Text>
|
||||
<View style={s.row}>
|
||||
{/* Same split as the notification's second button: a local timer can be
|
||||
nudged for free, but a bought session can only be extended by buying
|
||||
more, so that one goes to the purchase screen instead of lying. */}
|
||||
{isLocalOnly(local) ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={async () => {
|
||||
await extendAreaParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
|
||||
</TouchableOpacity>
|
||||
) : local.zone ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={[s.btnFilled, { backgroundColor: colors.danger }]}
|
||||
onPress={async () => {
|
||||
await endActiveParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[s.hint, { color: colors.subtext }]}>
|
||||
{isLocalOnly(local)
|
||||
? 'Works offline — this timer lives on your phone, not on a server.'
|
||||
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
active.map((s, i) => (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>
|
||||
Nothing being tracked. Start one from the map — “Park here”, or tap a coloured block.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{history.length > 0 ? (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Recent on this phone</Text>
|
||||
{history.map((r) => (
|
||||
<View key={r.id} style={[s.card, { backgroundColor: colors.card }]}>
|
||||
<View style={s.chipRow}>
|
||||
{r.color ? <View style={[s.swatch, { backgroundColor: r.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{r.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{fmtDate(r.startMs)} · {fmtClock(r.startMs)}–{fmtClock(r.endedAtMs)} ·{' '}
|
||||
{fmtSpan(r.startMs, r.endedAtMs)}
|
||||
{r.endedEarly ? ' · ended early' : ' · ran out'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAnonymous ? (
|
||||
<View style={[s.card, { backgroundColor: colors.card, marginTop: 20 }]}>
|
||||
<Text style={[s.zone, { color: colors.text }]}>Paid ParkSmarter sessions</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext, marginBottom: 10 }]}>
|
||||
Sessions you bought live in your ParkSmarter account. Sign in to see them here —
|
||||
everything above stays on this phone either way.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||
onPress={requireLogin}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||
) : active.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
) : (
|
||||
active.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
|
||||
style={[s.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} › tap for details
|
||||
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
|
||||
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} › tap for details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
|
||||
<Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
|
||||
{past.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
|
||||
) : past.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((s, i) => (
|
||||
past.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
|
||||
style={[s.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>
|
||||
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
|
||||
<Text style={[s.zone, { color: colors.text }]}>
|
||||
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} › tap for receipt
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} › tap for
|
||||
receipt
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
|
||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
empty: { marginBottom: 8 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, gap: 4 },
|
||||
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
swatch: { width: 20, height: 11, borderRadius: 3 },
|
||||
zone: { fontSize: 16, fontWeight: '600' },
|
||||
big: { fontSize: 26, fontWeight: '700' },
|
||||
meta: { fontSize: 13, marginTop: 2 },
|
||||
hint: { fontSize: 12, marginTop: 6 },
|
||||
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
btn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnFilled: { flex: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
|
||||
});
|
||||
|
|
|
|||
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)}`);
|
||||
});
|
||||
504
package-lock.json
generated
504
package-lock.json
generated
|
|
@ -40,6 +40,7 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "~19.0.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "~5.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -1599,6 +1600,448 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/cli": {
|
||||
"version": "0.24.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
|
||||
|
|
@ -4502,6 +4945,48 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -9151,6 +9636,25 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
|
||||
import {
|
||||
LabelDb,
|
||||
LABEL_KINDS,
|
||||
isLabelKind,
|
||||
isAreaKind,
|
||||
AREA_KINDS,
|
||||
type ZoneLabel,
|
||||
type ParkingArea,
|
||||
} from './db.js';
|
||||
import { AbuseGuard, safeEqual } from './auth.js';
|
||||
|
||||
export interface BuildOptions {
|
||||
|
|
@ -143,6 +151,81 @@ export async function buildApp(opts: BuildOptions) {
|
|||
},
|
||||
);
|
||||
|
||||
// ---- City parking-map areas ---------------------------------------------
|
||||
// The colour-coded areas from the city's printed Downtown & Waterfront parking
|
||||
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
|
||||
// which is the point — tracking time on these spots must work with no IPS call.
|
||||
|
||||
const num = (v: unknown, fallback: number): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
app.get('/api/areas', async () => ({
|
||||
areas: db.allAreas(),
|
||||
overlay: db.getOverlay(),
|
||||
count: db.areaCount(),
|
||||
updatedAt: db.areasUpdatedAt(),
|
||||
}));
|
||||
|
||||
// Replace-all, not upsert: the areas come from one source document, so a partial
|
||||
// update would leave a map matching neither the old print nor the new one.
|
||||
app.put(
|
||||
'/api/areas',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
|
||||
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
|
||||
if (!input) return reply.code(400).send({ error: 'areas_required' });
|
||||
|
||||
const areas: ParkingArea[] = [];
|
||||
for (const raw of input as Array<Record<string, unknown>>) {
|
||||
if (raw == null || raw.id == null) continue;
|
||||
if (!isAreaKind(raw.kind)) {
|
||||
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
|
||||
}
|
||||
const g = raw.geometry as { type?: unknown } | null;
|
||||
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
|
||||
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
|
||||
}
|
||||
areas.push({
|
||||
id: String(raw.id),
|
||||
kind: raw.kind,
|
||||
name: String(raw.name ?? raw.id),
|
||||
label: String(raw.label ?? raw.kind),
|
||||
legend: String(raw.legend ?? ''),
|
||||
hours: num(raw.hours, 0),
|
||||
color: String(raw.color ?? '#888888'),
|
||||
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
||||
geometry: g,
|
||||
// Absent means "let the client decide by category"; only an explicit
|
||||
// boolean overrides a specific lot.
|
||||
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
|
||||
});
|
||||
}
|
||||
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
||||
},
|
||||
);
|
||||
|
||||
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
|
||||
app.put(
|
||||
'/api/areas/overlay',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const scale = num(b.scale, 1);
|
||||
if (scale <= 0.5 || scale >= 2) {
|
||||
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
|
||||
return reply.code(400).send({ error: 'scale_out_of_range' });
|
||||
}
|
||||
return db.setOverlay({
|
||||
dxMeters: num(b.dxMeters, 0),
|
||||
dyMeters: num(b.dyMeters, 0),
|
||||
scale,
|
||||
rotationDeg: num(b.rotationDeg, 0),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.addHook('onClose', async () => db.close());
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
175
server/src/db.ts
175
server/src/db.ts
|
|
@ -9,6 +9,62 @@ export function isLabelKind(v: unknown): v is LabelKind {
|
|||
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/** The five categories on the city's printed Downtown & Waterfront parking map. */
|
||||
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
|
||||
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
|
||||
|
||||
export function isAreaKind(v: unknown): v is AreaKind {
|
||||
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* One coloured area from the city map: an on-street segment (LineString) or a
|
||||
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones —
|
||||
* nothing here ever reaches the IPS API.
|
||||
*/
|
||||
export interface ParkingArea {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
name: string;
|
||||
label: string;
|
||||
legend: string;
|
||||
hours: number;
|
||||
color: string;
|
||||
shape: 'line' | 'polygon';
|
||||
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
||||
geometry: unknown;
|
||||
/**
|
||||
* Overrides the client's by-category default (paid city lots need a
|
||||
* ParkSmarter account, free time-limited streets don't). Null means "use the
|
||||
* default"; set it only to correct a specific lot.
|
||||
*/
|
||||
requiresAccount: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
|
||||
* is good to a few metres but not perfect; this lets the alignment be nudged from the
|
||||
* phone against a live GPS fix and persisted, with no app release.
|
||||
*
|
||||
* Offsets are ground metres (east/north); scale and rotation apply about the
|
||||
* overlay's own centroid.
|
||||
*/
|
||||
export interface OverlayAdjust {
|
||||
dxMeters: number;
|
||||
dyMeters: number;
|
||||
scale: number;
|
||||
rotationDeg: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_OVERLAY: OverlayAdjust = {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
export interface ZoneLabel {
|
||||
zoneId: string;
|
||||
customerId: string | null;
|
||||
|
|
@ -70,7 +126,126 @@ export class LabelDb {
|
|||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS parking_areas (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL,
|
||||
hours REAL NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_overlay (
|
||||
id TEXT PRIMARY KEY,
|
||||
dx_meters REAL NOT NULL,
|
||||
dy_meters REAL NOT NULL,
|
||||
scale REAL NOT NULL,
|
||||
rotation REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// CREATE TABLE IF NOT EXISTS won't add a column to a table that already
|
||||
// exists, so added columns need an explicit migration.
|
||||
this.addColumnIfMissing('parking_areas', 'requires_account', 'INTEGER');
|
||||
}
|
||||
|
||||
private addColumnIfMissing(table: string, column: string, type: string): void {
|
||||
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === column)) return;
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ city parking-map areas */
|
||||
|
||||
allAreas(): ParkingArea[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM parking_areas ORDER BY id')
|
||||
.all() as Array<Record<string, any>>;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind as AreaKind,
|
||||
name: r.name,
|
||||
label: r.label,
|
||||
legend: r.legend,
|
||||
hours: r.hours,
|
||||
color: r.color,
|
||||
shape: r.shape as 'line' | 'polygon',
|
||||
geometry: JSON.parse(r.geometry),
|
||||
// SQLite has no boolean; null stays null so the client applies its default.
|
||||
requiresAccount: r.requires_account == null ? null : !!r.requires_account,
|
||||
}));
|
||||
}
|
||||
|
||||
areaCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
|
||||
areasUpdatedAt(): number {
|
||||
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
|
||||
t: number | null;
|
||||
};
|
||||
return r.t ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole area set in one transaction. The areas come from a single
|
||||
* source document, so a partial update would leave a map that matches neither
|
||||
* the old print nor the new one.
|
||||
*/
|
||||
replaceAreas(areas: ParkingArea[]): number {
|
||||
const now = Date.now();
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO parking_areas
|
||||
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at)
|
||||
VALUES
|
||||
(@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @requiresAccount, @updatedAt)`,
|
||||
);
|
||||
this.db.transaction((items: ParkingArea[]) => {
|
||||
this.db.prepare('DELETE FROM parking_areas').run();
|
||||
for (const a of items) {
|
||||
insert.run({
|
||||
...a,
|
||||
geometry: JSON.stringify(a.geometry),
|
||||
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
})(areas);
|
||||
return areas.length;
|
||||
}
|
||||
|
||||
getOverlay(): OverlayAdjust {
|
||||
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
if (!r) return IDENTITY_OVERLAY;
|
||||
return {
|
||||
dxMeters: r.dx_meters,
|
||||
dyMeters: r.dy_meters,
|
||||
scale: r.scale,
|
||||
rotationDeg: r.rotation,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
|
||||
const updatedAt = Date.now();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
|
||||
VALUES ('default', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
|
||||
scale = excluded.scale, rotation = excluded.rotation,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
|
||||
return { ...o, updatedAt };
|
||||
}
|
||||
|
||||
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
|
||||
|
|
|
|||
248
server/test/areas.test.ts
Normal file
248
server/test/areas.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp, type BuildOptions } from '../src/app.ts';
|
||||
|
||||
const TOKEN = 'test-admin-token-0123456789';
|
||||
const auth = { authorization: `Bearer ${TOKEN}` };
|
||||
|
||||
const make = (o: Partial<BuildOptions> = {}) =>
|
||||
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
|
||||
|
||||
const line = (id: string, kind = 'free_2h') => ({
|
||||
id,
|
||||
kind,
|
||||
name: `${id} name`,
|
||||
label: '2-hour free',
|
||||
legend: 'Permits not valid',
|
||||
hours: 2,
|
||||
color: '#d367cc',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-116.5535, 48.2766],
|
||||
[-116.5525, 48.2766],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('areas are public to read and admin-only to replace', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.deepEqual(r.json().areas, []);
|
||||
assert.equal(r.json().count, 0);
|
||||
|
||||
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 2);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
const areas = r.json().areas;
|
||||
assert.equal(areas.length, 2);
|
||||
assert.equal(areas[0].id, 'sp-001');
|
||||
assert.equal(areas[0].kind, 'free_2h');
|
||||
assert.equal(areas[0].shape, 'line');
|
||||
// Geometry survives the round-trip as real GeoJSON, not a string.
|
||||
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('replace is wholesale — stale areas do not survive', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002')] },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-003')] },
|
||||
});
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-003');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
|
||||
const app = await make();
|
||||
|
||||
const lot = {
|
||||
id: 'sp-022',
|
||||
kind: 'green_lot',
|
||||
name: 'Lot off Oak St',
|
||||
label: 'City lot',
|
||||
legend: 'Paid hourly or permit',
|
||||
hours: 2,
|
||||
color: '#75b259',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-116.554, 48.2766],
|
||||
[-116.553, 48.2766],
|
||||
[-116.553, 48.2772],
|
||||
[-116.554, 48.2766],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [lot] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 1);
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().areas[0].shape, 'polygon');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_kind');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_geometry');
|
||||
|
||||
// A rejected batch must not have clobbered the good one.
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-022');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('requiresAccount is null unless explicitly overridden', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: {
|
||||
areas: [
|
||||
line('sp-001'), // no override -> client decides by category
|
||||
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
|
||||
{ ...line('sp-003'), requiresAccount: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
|
||||
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
|
||||
assert.equal(by['sp-002'], false);
|
||||
assert.equal(by['sp-003'], true);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('a database created before requires_account existed still works', async () => {
|
||||
// Simulate an older deployment: build the table without the column, then let
|
||||
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const file = `/tmp/bbp-migrate-${process.pid}.db`;
|
||||
const raw = new Database(file);
|
||||
raw.exec(`CREATE TABLE parking_areas (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`);
|
||||
raw.close();
|
||||
|
||||
const app = await make({ dbPath: file });
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
assert.equal(areas[0].requiresAccount, true);
|
||||
|
||||
await app.close();
|
||||
(await import('node:fs')).rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
test('overlay defaults to identity and round-trips', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.deepEqual(r.json().overlay, {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
payload: { dxMeters: 3 },
|
||||
});
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().dxMeters, 3.5);
|
||||
assert.ok(r.json().updatedAt > 0);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.dyMeters, -2);
|
||||
assert.equal(r.json().overlay.rotationDeg, 0.4);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('an absurd overlay scale is refused rather than stored', async () => {
|
||||
const app = await make();
|
||||
|
||||
for (const scale of [0, 0.4, 2, 5]) {
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { scale },
|
||||
});
|
||||
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
|
||||
}
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.scale, 1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
72
tools/citymap/README.md
Normal file
72
tools/citymap/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Georeferencing the city parking map
|
||||
|
||||
Turns the City of Sandpoint's printed **Downtown & Waterfront Public Parking** PDF into
|
||||
`app/src/features/citymap/parkingAreas.json` — the colour-coded overlay the app draws and
|
||||
hit-tests against.
|
||||
|
||||
Run this again when the city publishes a new edition of the map.
|
||||
|
||||
## Why it needs doing at all
|
||||
|
||||
The PDF carries **no** georeferencing metadata (no `/Measure`, `/GPTS`, `/LPTS`, `/GEO`,
|
||||
`/Viewport`, `/GCS`). It is a north-up Web Mercator screenshot of a slippy map with vector
|
||||
parking stripes drawn on top, so page coordinates relate to the world by a plain affine
|
||||
transform — which has to be recovered by fitting the drawing to something we already know
|
||||
the coordinates of. That something is OpenStreetMap's street centrelines.
|
||||
|
||||
Two structural details cost the most time, so they are worth knowing up front:
|
||||
|
||||
- `pdftocairo` writes each **stroked** street segment with its own `matrix()` transform and
|
||||
*local* coordinates, while **filled** lots are in absolute page coordinates. Both have to
|
||||
be handled or the streets land in a heap near the origin.
|
||||
- The legend swatches are drawn in the same five colours as the real geometry. They are
|
||||
identified by stroke-width 7 inside the legend card's x-band and dropped.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```bash
|
||||
# 0. deps: poppler-utils (pdftocairo, pdftotext, pdfimages), python3, curl
|
||||
pdftocairo -svg downtown_and_waterfront_public_parking_map.pdf map.svg
|
||||
|
||||
# 1. vector geometry -> page coordinates, bucketed by the legend's five colours
|
||||
python3 extract_map.py map.svg map_page_coords.json
|
||||
|
||||
# 2. OSM street centrelines for downtown Sandpoint
|
||||
curl -s --data-binary @roads.overpass https://overpass-api.de/api/interpreter -o osm.json
|
||||
|
||||
# 3. fit page -> Web Mercator against named streets; prints per-street residuals
|
||||
python3 georef.py # writes fit_raw.json
|
||||
|
||||
# 4. apply the fit, name each area from OSM, verify, emit the GeoJSON
|
||||
python3 build_geojson.py # writes parking_areas.geojson
|
||||
|
||||
cp parking_areas.geojson ../../app/src/features/citymap/parkingAreas.json
|
||||
```
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
`georef.py` prints a residual per control street and `build_geojson.py` prints how far each
|
||||
on-street segment sits from the nearest OSM road. The current edition fits to:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| X control residual (avenues) | **RMS 4.1 m** |
|
||||
| Y control residual (streets) | **RMS 3.5 m** |
|
||||
| On-street segments vs nearest OSM road | **mean 4.0 m**, 40 of 42 under 10 m |
|
||||
|
||||
The two segments over 10 m (`sp-039`, `sp-040`) are correct, not errors: they are angled
|
||||
bays along the old rail corridor that sit on no named road at all — the nearest way is a
|
||||
service alley 19 m off. Anything much worse than the table above means a control street was
|
||||
mis-identified; `georef.py`'s per-street residuals will say which.
|
||||
|
||||
Residual error is also correctable after the fact without re-running any of this — the app's
|
||||
**Account → Align city map** screen nudges the whole overlay against a live GPS fix and
|
||||
persists the correction.
|
||||
|
||||
## Control points
|
||||
|
||||
`georef.py` maps page grid lines to OSM street names by hand (`AVENUES` / `STREETS`). Sandpoint's
|
||||
grid **jogs** between its north and south halves — North 2nd Ave and South 2nd Ave are 38 m
|
||||
apart — so each control street is measured only over the span its page segment actually
|
||||
covers, and both halves are used as independent control points. That jog is a useful sanity
|
||||
check: the page shows the same 9.3 pt offset, which at the fitted scale is 38 m.
|
||||
190
tools/citymap/build_geojson.py
Normal file
190
tools/citymap/build_geojson.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the fitted transform and emit the final parking-areas GeoJSON.
|
||||
|
||||
Also verifies the result the only way that matters: every stroked segment should
|
||||
land on an actual road, so measure each one's distance to the nearest OSM road
|
||||
centreline. Lots are skipped in that check — they are off-street by definition.
|
||||
|
||||
Each feature gets a human name from OSM (the street it runs along, plus the two
|
||||
cross streets it lies between) so the app can list areas without the map.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
COS = math.cos(math.radians(48.278))
|
||||
|
||||
fit = json.load(open("fit_raw.json"))
|
||||
SX, TX, SY, TY = fit["sx"], fit["tx"], fit["sy"], fit["ty"]
|
||||
|
||||
|
||||
def to_merc(x, y):
|
||||
return (SX * x + TX, SY * y + TY)
|
||||
|
||||
|
||||
def to_lonlat(x, y):
|
||||
X, Y = to_merc(x, y)
|
||||
return (round(math.degrees(X / R), 7), round(math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), 7))
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
px, py = p
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = dx * dx + dy * dy
|
||||
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- OSM roads
|
||||
osm = json.load(open("osm.json"))
|
||||
SKIP = {"footway", "path", "cycleway", "steps", "track", "service"}
|
||||
roads = [] # (a, b, name) in mercator
|
||||
for w in osm["elements"]:
|
||||
t = w.get("tags", {})
|
||||
if t.get("highway") in SKIP or "geometry" not in w:
|
||||
continue
|
||||
g = [merc(p["lat"], p["lon"]) for p in w["geometry"]]
|
||||
nm = t.get("name")
|
||||
for a, b in zip(g, g[1:]):
|
||||
roads.append((a, b, nm))
|
||||
named = [r for r in roads if r[2]]
|
||||
|
||||
SHORT = [
|
||||
("North ", "N "), ("South ", "S "), ("East ", "E "), ("West ", "W "),
|
||||
(" Street", " St"), (" Avenue", " Ave"), (" Boulevard", " Blvd"),
|
||||
(" Road", " Rd"), (" Drive", " Dr"), (" Lane", " Ln"), (" Bridge", " Brg"),
|
||||
]
|
||||
|
||||
|
||||
def short(n):
|
||||
for a, b in SHORT:
|
||||
n = n.replace(a, b)
|
||||
return n
|
||||
|
||||
|
||||
def nearest_name(p, exclude=None, limit=60.0):
|
||||
best, bestd = None, limit
|
||||
for a, b, nm in named:
|
||||
if nm == exclude:
|
||||
continue
|
||||
d = dist_to_seg(p, a, b)
|
||||
if d < bestd:
|
||||
best, bestd = nm, d
|
||||
return best
|
||||
|
||||
|
||||
def describe(pts_merc, is_line):
|
||||
"""'N 3rd Ave · Cedar St to Oak St' for a segment, or the nearest road for a lot."""
|
||||
mid = pts_merc[len(pts_merc) // 2]
|
||||
on = nearest_name(mid) if is_line else None
|
||||
if not is_line:
|
||||
near = nearest_name(mid, limit=200.0)
|
||||
return f"Lot off {short(near)}" if near else "City lot"
|
||||
ends = [pts_merc[0], pts_merc[-1]]
|
||||
cross = []
|
||||
for e in ends:
|
||||
c = nearest_name(e, exclude=on, limit=45.0)
|
||||
if c and short(c) not in cross:
|
||||
cross.append(short(c))
|
||||
if not on:
|
||||
# Bays along the old rail corridor sit on no named road — describe them
|
||||
# by what they are near rather than inventing a street.
|
||||
near = nearest_name(mid, limit=150.0)
|
||||
return f"Off-street bays near {short(near)}" if near else "Off-street bays"
|
||||
base = short(on)
|
||||
if len(cross) == 2:
|
||||
return f"{base} · {cross[0]} to {cross[1]}"
|
||||
if len(cross) == 1:
|
||||
return f"{base} · at {cross[0]}"
|
||||
return base
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build output
|
||||
page = json.load(open("map_page_coords.json"))
|
||||
|
||||
|
||||
def is_legend(f):
|
||||
"""The five legend swatches: stroke-width 7 sitting in the legend card's x-band."""
|
||||
x0, y0, x1, y1 = f["bbox"]
|
||||
return f["strokeWidth"] > 5 and 25 < x0 < 28 and 60 < y0 < 130
|
||||
|
||||
|
||||
# kind -> (short label, legend text, default tracked hours, colour)
|
||||
KINDS = {
|
||||
"green_lot": ("City lot", "Paid hourly or permit", 2, "#75b259"),
|
||||
"free_2h": ("2-hour free", "Permits not valid", 2, "#d367cc"),
|
||||
"limit_3h": ("3-hour", "3-hour or permit", 3, "#ccc542"),
|
||||
"limit_4h": ("4-hour", "4-hour or permit", 4, "#f78b08"),
|
||||
"no_limit": ("No time limit", "No posted time limit", 0, "#c3c4c2"),
|
||||
}
|
||||
|
||||
features = []
|
||||
n_legend = 0
|
||||
for i, f in enumerate(page["features"]):
|
||||
if is_legend(f):
|
||||
n_legend += 1
|
||||
continue
|
||||
is_line = f["geom"] == "line"
|
||||
pm = [to_merc(x, y) for x, y in f["points"]]
|
||||
coords = [to_lonlat(x, y) for x, y in f["points"]]
|
||||
if is_line:
|
||||
geom = {"type": "LineString", "coordinates": coords}
|
||||
else:
|
||||
if coords[0] != coords[-1]:
|
||||
coords.append(coords[0])
|
||||
geom = {"type": "Polygon", "coordinates": [coords]}
|
||||
label, legend, hours, color = KINDS[f["kind"]]
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"sp-{i:03d}",
|
||||
"geometry": geom,
|
||||
"properties": {
|
||||
"id": f"sp-{i:03d}",
|
||||
"kind": f["kind"],
|
||||
"label": label,
|
||||
"legend": legend,
|
||||
"hours": hours,
|
||||
"color": color,
|
||||
"shape": "line" if is_line else "polygon",
|
||||
"name": describe(pm, is_line),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fc = {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"metadata": {
|
||||
"source": "City of Sandpoint — Downtown & Waterfront Public Parking map",
|
||||
"generated": "from downtown_and_waterfront_public_parking_map.pdf",
|
||||
"georeference": "affine page->WebMercator fitted to OSM street centrelines",
|
||||
},
|
||||
}
|
||||
json.dump(fc, open("parking_areas.geojson", "w"), indent=1)
|
||||
print(f"{len(features)} features written ({n_legend} legend swatches dropped)\n")
|
||||
for f in features:
|
||||
p = f["properties"]
|
||||
print(f" {p['id']} {p['kind']:10s} {p['shape']:7s} {p['name']}")
|
||||
|
||||
# ---- verification: distance from each on-street segment to the nearest road
|
||||
worst = []
|
||||
for f in features:
|
||||
if f["properties"]["shape"] != "line":
|
||||
continue
|
||||
ds = [min(dist_to_seg(merc(lat, lon), a, b) for a, b, _ in roads) * COS
|
||||
for lon, lat in f["geometry"]["coordinates"]]
|
||||
worst.append((max(ds), sum(ds) / len(ds), f["properties"]["id"], f["properties"]["kind"]))
|
||||
|
||||
worst.sort(reverse=True)
|
||||
print(f"\non-street segments: {len(worst)}, mean offset from nearest road = "
|
||||
f"{sum(m for _, m, _, _ in worst)/len(worst):.1f} m")
|
||||
print(f"segments with mean offset > 10 m: {sum(1 for _, m, _, _ in worst if m > 10)}")
|
||||
for mx, mn, fid, kind in worst[:4]:
|
||||
print(f" worst: {fid} {kind:10s} max={mx:6.1f} m mean={mn:6.1f} m")
|
||||
195
tools/citymap/extract_map.py
Normal file
195
tools/citymap/extract_map.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Extract the color-coded parking geometry from the city's parking-map PDF.
|
||||
|
||||
pdftocairo emits every street segment as a stroked <path> carrying its own
|
||||
matrix() transform with local coordinates, and every lot as an absolute filled
|
||||
<path>. So: parse the path, flatten curves, push through the path's own matrix,
|
||||
and bucket by the exact colour pdftocairo wrote.
|
||||
|
||||
Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the
|
||||
georeferencing step turns that into lat/lon.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg"
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json"
|
||||
|
||||
# Colours exactly as pdftocairo writes them, mapped to the map legend.
|
||||
COLORS = {
|
||||
"rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit",
|
||||
"rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h",
|
||||
"rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h",
|
||||
"rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h",
|
||||
"rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot",
|
||||
}
|
||||
|
||||
NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
||||
|
||||
|
||||
def parse_matrix(s):
|
||||
"""matrix(a,b,c,d,e,f) -> tuple. Identity when absent."""
|
||||
if not s:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
m = re.search(r"matrix\s*\(([^)]*)\)", s)
|
||||
if not m:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
v = [float(x) for x in NUM.findall(m.group(1))]
|
||||
return tuple(v[:6])
|
||||
|
||||
|
||||
def apply(mtx, x, y):
|
||||
a, b, c, d, e, f = mtx
|
||||
return (a * x + c * y + e, b * x + d * y + f)
|
||||
|
||||
|
||||
def bezier(p0, p1, p2, p3, steps=8):
|
||||
"""Flatten a cubic to points. The map's curves are gentle; 8 is plenty."""
|
||||
out = []
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
u = 1 - t
|
||||
out.append(
|
||||
(
|
||||
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
||||
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_path(d):
|
||||
"""Return a list of subpaths [(points, closed)] in the path's local space."""
|
||||
tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d)
|
||||
subpaths, pts = [], []
|
||||
cur = (0.0, 0.0)
|
||||
start = (0.0, 0.0)
|
||||
cmd = None
|
||||
i = 0
|
||||
flat = [(c, n) for c, n in tokens]
|
||||
|
||||
def nums(k):
|
||||
nonlocal i
|
||||
vals = []
|
||||
while len(vals) < k and i < len(flat) and flat[i][1]:
|
||||
vals.append(float(flat[i][1]))
|
||||
i += 1
|
||||
return vals
|
||||
|
||||
while i < len(flat):
|
||||
c, n = flat[i]
|
||||
if c:
|
||||
cmd = c
|
||||
i += 1
|
||||
elif cmd is None:
|
||||
i += 1
|
||||
continue
|
||||
if cmd in "Zz":
|
||||
if pts:
|
||||
subpaths.append((pts, True))
|
||||
pts = []
|
||||
cur = start
|
||||
cmd = None
|
||||
continue
|
||||
if cmd in "Mm":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
pts = []
|
||||
cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1])
|
||||
start = cur
|
||||
pts = [cur]
|
||||
cmd = "L" if cmd == "M" else "l"
|
||||
elif cmd in "Ll":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Hh":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Vv":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0])
|
||||
pts.append(cur)
|
||||
elif cmd in "Cc":
|
||||
v = nums(6)
|
||||
if len(v) < 6:
|
||||
break
|
||||
if cmd == "C":
|
||||
p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5])
|
||||
else:
|
||||
p1 = (cur[0] + v[0], cur[1] + v[1])
|
||||
p2 = (cur[0] + v[2], cur[1] + v[3])
|
||||
p3 = (cur[0] + v[4], cur[1] + v[5])
|
||||
pts.extend(bezier(cur, p1, p2, p3))
|
||||
cur = p3
|
||||
else:
|
||||
i += 1
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
return subpaths
|
||||
|
||||
|
||||
def main():
|
||||
tree = ET.parse(SVG)
|
||||
root = tree.getroot()
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
|
||||
features = []
|
||||
for el in root.iter(ns + "path"):
|
||||
stroke = (el.get("stroke") or "").strip()
|
||||
fill = (el.get("fill") or "").strip()
|
||||
kind = COLORS.get(stroke) or COLORS.get(fill)
|
||||
if not kind:
|
||||
continue
|
||||
is_stroke = stroke in COLORS
|
||||
mtx = parse_matrix(el.get("transform"))
|
||||
width = float(el.get("stroke-width") or 0)
|
||||
for local, closed in parse_path(el.get("d") or ""):
|
||||
world = [apply(mtx, x, y) for x, y in local]
|
||||
if len(world) < 2:
|
||||
continue
|
||||
xs = [p[0] for p in world]
|
||||
ys = [p[1] for p in world]
|
||||
features.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"geom": "line" if is_stroke else "polygon",
|
||||
"closed": closed,
|
||||
"strokeWidth": width,
|
||||
"bbox": [min(xs), min(ys), max(xs), max(ys)],
|
||||
"points": [[round(x, 3), round(y, 3)] for x, y in world],
|
||||
}
|
||||
)
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1)
|
||||
|
||||
from collections import Counter
|
||||
print(f"{len(features)} features -> {OUT}")
|
||||
for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()):
|
||||
print(f" {k:10s} {g:8s} {n}")
|
||||
# Where are they? Legend swatches cluster in one corner; real geometry spreads out.
|
||||
print("\nbbox spread by kind:")
|
||||
for k in COLORS.values():
|
||||
fs = [f for f in features if f["kind"] == k]
|
||||
if not fs:
|
||||
continue
|
||||
print(
|
||||
f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] "
|
||||
f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
114
tools/citymap/georef.py
Normal file
114
tools/citymap/georef.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Georeference the parking-map page coordinates against OSM.
|
||||
|
||||
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
|
||||
uniform scale plus a translation (3 free params, not 6). Control points are
|
||||
street centrelines identified by name: an avenue pins X, a street pins Y.
|
||||
|
||||
Streets in Sandpoint jog between the north and south halves of the grid, so each
|
||||
control street's mercator coordinate is measured only over the span the page
|
||||
segment actually covers, not over the whole way.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def unmerc(X, Y):
|
||||
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
|
||||
|
||||
|
||||
osm = json.load(open("osm.json"))
|
||||
ways = {}
|
||||
for w in osm["elements"]:
|
||||
n = w.get("tags", {}).get("name")
|
||||
if not n or "geometry" not in w:
|
||||
continue
|
||||
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
|
||||
|
||||
|
||||
def centreline(name, axis, lo, hi):
|
||||
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
|
||||
other = 1 - axis
|
||||
vals = []
|
||||
for g in ways.get(name, []):
|
||||
for (x0, y0), (x1, y1) in zip(g, g[1:]):
|
||||
p0, p1 = (x0, y0), (x1, y1)
|
||||
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
|
||||
continue
|
||||
vals.append((p0[axis] + p1[axis]) / 2)
|
||||
return sum(vals) / len(vals) if vals else None
|
||||
|
||||
|
||||
# Page grid lines read off the rendered map, with the mercator window each spans.
|
||||
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
|
||||
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
|
||||
YW = (6152200, 6153300) # Lake St .. Poplar St
|
||||
|
||||
AVENUES = [ # page x, OSM name
|
||||
(83.1, "North 5th Avenue"),
|
||||
(120.0, "North 4th Avenue"),
|
||||
(163.8, "North 3rd Avenue"),
|
||||
(207.7, "North 2nd Avenue"),
|
||||
(235.7, "North 1st Avenue"),
|
||||
(164.3, "South 3rd Avenue"),
|
||||
(198.4, "South 2nd Avenue"),
|
||||
]
|
||||
STREETS = [ # page y, OSM name
|
||||
(184.4, "Poplar Street"),
|
||||
(228.3, "Alder Street"),
|
||||
(272.3, "Cedar Street"),
|
||||
(314.9, "Oak Street"),
|
||||
(359.1, "Church Street"),
|
||||
(398.0, "Pine Street"),
|
||||
(438.2, "Lake Street"),
|
||||
(492.1, "Superior Street"),
|
||||
]
|
||||
|
||||
|
||||
def fit(pairs, flip):
|
||||
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
|
||||
n = len(pairs)
|
||||
sp = sum(p for p, v in pairs)
|
||||
sv = sum(v for p, v in pairs)
|
||||
spp = sum(p * p for p, v in pairs)
|
||||
spv = sum(p * v for p, v in pairs)
|
||||
s = (n * spv - sp * sv) / (n * spp - sp * sp)
|
||||
t = (sv - s * sp) / n
|
||||
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
|
||||
|
||||
|
||||
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
|
||||
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
|
||||
print("control points (mercator metres):")
|
||||
for (px, n), (_, v) in zip(AVENUES, ax):
|
||||
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
for (py, n), (_, v) in zip(STREETS, ay):
|
||||
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
|
||||
ax = [(p, v) for p, v in ax if v is not None]
|
||||
ay = [(p, v) for p, v in ay if v is not None]
|
||||
|
||||
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
|
||||
|
||||
|
||||
def report(label, pairs, names):
|
||||
s, t, res = fit(pairs, False)
|
||||
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
|
||||
for (p, v, r), nm in zip(res, names):
|
||||
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
|
||||
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
|
||||
print(f" RMS = {rms:.1f} ground-m")
|
||||
return s, t, rms
|
||||
|
||||
|
||||
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
|
||||
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
|
||||
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
|
||||
|
||||
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)
|
||||
3
tools/citymap/roads.overpass
Normal file
3
tools/citymap/roads.overpass
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[out:json][timeout:60];
|
||||
way["highway"](48.2600,-116.5750,48.2900,-116.5300);
|
||||
out geom;
|
||||
Loading…
Add table
Add a link
Reference in a new issue