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>
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
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)}`);
|
||
});
|