poi-index: map-core selectors, planner.pois schema, /api/pois route + client, metrics

Replace the planner's Overpass proxy with a self-hosted POI index:
- map-core POI categories become structured tag selectors (single source of
  truth) + osmium filter / classification helpers
- planner.pois PostGIS table (centroid points, GiST + category indexes)
- /api/pois serving route (session + same-origin + rate limit, 100 cap)
- lib/pois.ts client (renamed from overpass.ts; GET, quantized bbox)
- metrics: poi_api_requests_total + DB-collected index rows/age gauges;
  drop overpass_* metrics
- remove /api/overpass proxy + dead OVERPASS_URLS on the planner service
  (Journal surface backfill still uses it via code default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-12 22:38:03 +02:00
parent 7dd9ccd2b0
commit b45e69885d
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
29 changed files with 887 additions and 933 deletions

View file

@ -12,8 +12,14 @@ export {
maxspeedColor,
} from "./colors/index.ts";
export type { PoiCategory } from "./poi.ts";
export { poiCategories, getCategoriesForProfile, profileOverlayDefaults } from "./poi.ts";
export type { PoiCategory, PoiSelector } from "./poi.ts";
export {
poiCategories,
getCategoriesForProfile,
matchingCategoryIds,
osmiumTagFilters,
profileOverlayDefaults,
} from "./poi.ts";
export {
Z_CURSOR, Z_GHOST_WAYPOINT, Z_WAYPOINT,

View file

@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { poiCategories, getCategoriesForProfile, profileOverlayDefaults } from "./poi.ts";
import {
poiCategories,
getCategoriesForProfile,
matchingCategoryIds,
osmiumTagFilters,
profileOverlayDefaults,
} from "./poi.ts";
describe("poiCategories", () => {
it("has expected categories", () => {
@ -16,7 +22,11 @@ describe("poiCategories", () => {
expect(cat.name).toBeTruthy();
expect(cat.icon).toBeTruthy();
expect(cat.color).toMatch(/^#[0-9a-fA-F]{6}$/);
expect(cat.query).toBeTruthy();
expect(cat.selectors.length).toBeGreaterThan(0);
for (const sel of cat.selectors) {
expect(sel.key).toBeTruthy();
expect(sel.value).toBeTruthy();
}
}
});
});
@ -45,6 +55,63 @@ describe("getCategoriesForProfile", () => {
});
});
describe("matchingCategoryIds", () => {
it("classifies an element by its tags", () => {
expect(matchingCategoryIds({ amenity: "drinking_water" })).toEqual(["drinking_water"]);
expect(matchingCategoryIds({ tourism: "viewpoint", name: "Aussicht" })).toEqual(["viewpoints"]);
});
it("returns an empty array for elements matching no category", () => {
expect(matchingCategoryIds({ highway: "primary" })).toEqual([]);
expect(matchingCategoryIds({})).toEqual([]);
});
it("returns every matching category when tags satisfy more than one", () => {
// A drinking water point that is also tagged as toilets belongs to both.
const ids = matchingCategoryIds({ amenity: "toilets", drinking_water: "yes" });
expect(ids).toContain("toilets");
});
it("matches secondary selectors within a category", () => {
expect(matchingCategoryIds({ amenity: "water_point" })).toEqual(["drinking_water"]);
expect(matchingCategoryIds({ tourism: "wilderness_hut" })).toEqual(["shelter"]);
});
});
describe("osmiumTagFilters", () => {
it("renders one nwr expression per distinct key, grouping values", () => {
const filters = osmiumTagFilters();
// amenity spans several categories; all its values collapse into one expr.
const amenity = filters.find((f) => f.startsWith("nwr/amenity="));
expect(amenity).toBeDefined();
expect(amenity).toContain("drinking_water");
expect(amenity).toContain("toilets");
expect(amenity).toContain("restaurant");
// tourism and shop each get their own expression.
expect(filters.some((f) => f.startsWith("nwr/tourism="))).toBe(true);
expect(filters.some((f) => f.startsWith("nwr/shop="))).toBe(true);
});
it("covers every selector value across the nine categories", () => {
const filters = osmiumTagFilters();
for (const cat of poiCategories) {
for (const sel of cat.selectors) {
const expr = filters.find((f) => f.startsWith(`nwr/${sel.key}=`));
expect(expr, `expected a filter for ${sel.key}`).toBeDefined();
expect(expr!.split("=")[1]!.split(",")).toContain(sel.value);
}
}
});
it("does not repeat a value for the same key", () => {
const filters = osmiumTagFilters();
for (const f of filters) {
const values = f.split("=")[1]!.split(",");
expect(new Set(values).size).toBe(values.length);
}
});
});
describe("profileOverlayDefaults", () => {
it("maps fastbike to cycling routes", () => {
expect(profileOverlayDefaults["fastbike"]).toContain("waymarked-cycling");

View file

@ -1,9 +1,23 @@
/**
* A single OSM tag selector: an element matches the selector when its tags
* contain `key` mapped to `value`. Categories are unions of selectors.
*
* This structured form is the single source of truth for "what is a shelter":
* the POI extract pipeline renders it to an osmium tag-filter expression, and
* the flagship importer classifies each filtered element back to its
* category/categories. Nothing builds Overpass QL from these any more.
*/
export interface PoiSelector {
key: string;
value: string;
}
export interface PoiCategory {
id: string;
name: string;
icon: string;
color: string;
query: string;
selectors: PoiSelector[];
profiles?: string[];
}
@ -13,43 +27,66 @@ export const poiCategories: PoiCategory[] = [
name: "poi.drinkingWater",
icon: "\u{1F4A7}",
color: "#2563eb",
query: 'nwr["amenity"="drinking_water"];nwr["amenity"="water_point"];',
selectors: [
{ key: "amenity", value: "drinking_water" },
{ key: "amenity", value: "water_point" },
],
},
{
id: "shelter",
name: "poi.shelter",
icon: "\u{1F6D6}",
color: "#8B6D3A",
query: 'nwr["amenity"="shelter"];nwr["tourism"="wilderness_hut"];',
selectors: [
{ key: "amenity", value: "shelter" },
{ key: "tourism", value: "wilderness_hut" },
],
profiles: ["trekking"],
},
{
id: "camping",
name: "poi.camping",
icon: "\u26FA",
icon: "",
color: "#059669",
query: 'nwr["tourism"="camp_site"];nwr["tourism"="caravan_site"];',
selectors: [
{ key: "tourism", value: "camp_site" },
{ key: "tourism", value: "caravan_site" },
],
},
{
id: "food",
name: "poi.food",
icon: "\u{1F37D}\uFE0F",
icon: "\u{1F37D}",
color: "#dc2626",
query: 'nwr["amenity"="restaurant"];nwr["amenity"="cafe"];nwr["amenity"="fast_food"];nwr["amenity"="pub"];nwr["amenity"="biergarten"];',
selectors: [
{ key: "amenity", value: "restaurant" },
{ key: "amenity", value: "cafe" },
{ key: "amenity", value: "fast_food" },
{ key: "amenity", value: "pub" },
{ key: "amenity", value: "biergarten" },
],
},
{
id: "groceries",
name: "poi.groceries",
icon: "\u{1F6D2}",
color: "#f97316",
query: 'nwr["shop"="supermarket"];nwr["shop"="convenience"];nwr["shop"="bakery"];',
selectors: [
{ key: "shop", value: "supermarket" },
{ key: "shop", value: "convenience" },
{ key: "shop", value: "bakery" },
],
},
{
id: "bike_infra",
name: "poi.bikeInfra",
icon: "\u{1F527}",
color: "#8b5cf6",
query: 'nwr["amenity"="bicycle_parking"];nwr["amenity"="bicycle_repair_station"];nwr["amenity"="bicycle_rental"];',
selectors: [
{ key: "amenity", value: "bicycle_parking" },
{ key: "amenity", value: "bicycle_repair_station" },
{ key: "amenity", value: "bicycle_rental" },
],
profiles: ["fastbike", "safety"],
},
{
@ -57,14 +94,18 @@ export const poiCategories: PoiCategory[] = [
name: "poi.accommodation",
icon: "\u{1F3E8}",
color: "#0891b2",
query: 'nwr["tourism"="hotel"];nwr["tourism"="hostel"];nwr["tourism"="guest_house"];',
selectors: [
{ key: "tourism", value: "hotel" },
{ key: "tourism", value: "hostel" },
{ key: "tourism", value: "guest_house" },
],
},
{
id: "viewpoints",
name: "poi.viewpoints",
icon: "\u{1F441}\uFE0F",
icon: "\u{1F441}",
color: "#9333ea",
query: 'nwr["tourism"="viewpoint"];',
selectors: [{ key: "tourism", value: "viewpoint" }],
profiles: ["trekking"],
},
{
@ -72,7 +113,7 @@ export const poiCategories: PoiCategory[] = [
name: "poi.toilets",
icon: "\u{1F6BB}",
color: "#6b7280",
query: 'nwr["amenity"="toilets"];',
selectors: [{ key: "amenity", value: "toilets" }],
},
];
@ -82,6 +123,43 @@ export function getCategoriesForProfile(profile: string): string[] {
.map((c) => c.id);
}
/**
* Return the ids of every category whose selectors match the given OSM tags.
* An element can belong to more than one category. Used by the POI import
* pipeline to classify each osmium-filtered element; the serving layer reads
* the stored category and never needs to re-classify.
*/
export function matchingCategoryIds(
tags: Record<string, string>,
categories: PoiCategory[] = poiCategories,
): string[] {
return categories
.filter((cat) => cat.selectors.some((s) => tags[s.key] === s.value))
.map((cat) => cat.id);
}
/**
* Render the categories' selectors as a deduplicated list of osmium
* `tags-filter` expressions, one per distinct key=value. Each expression
* matches nodes, ways and relations (`nwr/`), matching the previous Overpass
* `nwr[...]` selectors. Values for the same key are grouped so the filter is
* compact, e.g. `nwr/amenity=drinking_water,water_point`.
*/
export function osmiumTagFilters(
categories: PoiCategory[] = poiCategories,
): string[] {
// key -> ordered set of values (first-seen order, deduplicated)
const byKey = new Map<string, string[]>();
for (const cat of categories) {
for (const { key, value } of cat.selectors) {
const values = byKey.get(key) ?? [];
if (!values.includes(value)) values.push(value);
byKey.set(key, values);
}
}
return [...byKey.entries()].map(([key, values]) => `nwr/${key}=${values.join(",")}`);
}
/** Profile -> tile overlay mapping */
export const profileOverlayDefaults: Record<string, string[]> = {
fastbike: ["waymarked-cycling"],