BigBrainParking/app/test/geo.test.ts
Erik 148e1635d3
All checks were successful
build-apk / build (push) Successful in 10m38s
v0.6.0: city parking-map overlay + local time tracking, no IPS API
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>
2026-08-13 03:28:18 +00:00

152 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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