diff --git a/apps/planner/app/lib/overpass.test.ts b/apps/planner/app/lib/overpass.test.ts index b462e5b..0e284e3 100644 --- a/apps/planner/app/lib/overpass.test.ts +++ b/apps/planner/app/lib/overpass.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts"; +import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts"; import { poiCategories } from "@trails-cool/map-core"; describe("buildQuery", () => { @@ -9,7 +9,7 @@ describe("buildQuery", () => { const query = buildQuery(bbox, categories); expect(query).toContain("[out:json]"); - expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]"); + expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]"); expect(query).toContain('amenity"="drinking_water"'); expect(query).toContain("out center qt 100"); }); @@ -22,6 +22,45 @@ describe("buildQuery", () => { expect(query).toContain("drinking_water"); expect(query).toContain("camp_site"); }); + + it("produces identical queries for near-identical viewports within one grid cell", () => { + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const a = buildQuery( + { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, + categories, + ); + const b = buildQuery( + { south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 }, + categories, + ); + expect(a).toBe(b); + }); + + it("expands the bbox outward so the original viewport is always covered", () => { + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const query = buildQuery( + { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, + categories, + ); + expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]"); + }); +}); + +describe("quantizeBbox", () => { + it("snaps south and west down, north and east up", () => { + const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01); + expect(q.south).toBeCloseTo(52.5, 10); + expect(q.west).toBeCloseTo(13.3, 10); + expect(q.north).toBeCloseTo(52.6, 10); + expect(q.east).toBeCloseTo(13.5, 10); + }); + + it("is idempotent on already-aligned coordinates", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 }; + const q = quantizeBbox(bbox, 0.01); + expect(q.south).toBeCloseTo(52.5, 10); + expect(q.north).toBeCloseTo(52.6, 10); + }); }); describe("parseResponse", () => { diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts index 6a14fbd..ac04eaa 100644 --- a/apps/planner/app/lib/overpass.ts +++ b/apps/planner/app/lib/overpass.ts @@ -2,6 +2,18 @@ import type { PoiCategory } from "@trails-cool/map-core"; const OVERPASS_PROXY = "/api/overpass"; +// Snap bbox coordinates to this grid before building the query so that two +// users looking at nearly-identical viewports produce byte-identical queries +// and therefore share a server-side cache entry. Expansion is outward +// (south/west floor, north/east ceil) so the user's viewport is always +// covered. ~0.01° ≈ 1 km at mid-latitudes. +const BBOX_GRID_STEP = 0.01; + +// Decimals used when formatting the quantized bbox into the query string. +// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP, +// so the string is a stable representation of the quantized cell. +const BBOX_DECIMALS = 3; + export interface Poi { id: number; lat: number; @@ -18,11 +30,31 @@ export interface BBox { east: number; } +/** + * Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward + * so the original bbox is fully contained. Returns a new BBox whose + * coordinates are cell-aligned. + */ +export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox { + return { + south: Math.floor(bbox.south / step) * step, + west: Math.floor(bbox.west / step) * step, + north: Math.ceil(bbox.north / step) * step, + east: Math.ceil(bbox.east / step) * step, + }; +} + /** * Build an Overpass QL query combining all enabled categories into a union. + * The bbox is quantized to a fixed grid and formatted with a fixed number of + * decimals so near-identical viewports produce a byte-identical query — which + * the `/api/overpass` server-side cache keys on. */ export function buildQuery(bbox: BBox, categories: PoiCategory[]): string { - const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`; + const q = quantizeBbox(bbox); + const bboxStr = [q.south, q.west, q.north, q.east] + .map((n) => n.toFixed(BBOX_DECIMALS)) + .join(","); const unions = categories.map((c) => c.query).join(""); return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`; }