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