trails/packages/map-core/src/poi.ts
Ullrich Schäfer b45e69885d
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>
2026-07-12 22:38:03 +02:00

168 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;
selectors: PoiSelector[];
profiles?: string[];
}
export const poiCategories: PoiCategory[] = [
{
id: "drinking_water",
name: "poi.drinkingWater",
icon: "\u{1F4A7}",
color: "#2563eb",
selectors: [
{ key: "amenity", value: "drinking_water" },
{ key: "amenity", value: "water_point" },
],
},
{
id: "shelter",
name: "poi.shelter",
icon: "\u{1F6D6}",
color: "#8B6D3A",
selectors: [
{ key: "amenity", value: "shelter" },
{ key: "tourism", value: "wilderness_hut" },
],
profiles: ["trekking"],
},
{
id: "camping",
name: "poi.camping",
icon: "⛺",
color: "#059669",
selectors: [
{ key: "tourism", value: "camp_site" },
{ key: "tourism", value: "caravan_site" },
],
},
{
id: "food",
name: "poi.food",
icon: "\u{1F37D}",
color: "#dc2626",
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",
selectors: [
{ key: "shop", value: "supermarket" },
{ key: "shop", value: "convenience" },
{ key: "shop", value: "bakery" },
],
},
{
id: "bike_infra",
name: "poi.bikeInfra",
icon: "\u{1F527}",
color: "#8b5cf6",
selectors: [
{ key: "amenity", value: "bicycle_parking" },
{ key: "amenity", value: "bicycle_repair_station" },
{ key: "amenity", value: "bicycle_rental" },
],
profiles: ["fastbike", "safety"],
},
{
id: "accommodation",
name: "poi.accommodation",
icon: "\u{1F3E8}",
color: "#0891b2",
selectors: [
{ key: "tourism", value: "hotel" },
{ key: "tourism", value: "hostel" },
{ key: "tourism", value: "guest_house" },
],
},
{
id: "viewpoints",
name: "poi.viewpoints",
icon: "\u{1F441}",
color: "#9333ea",
selectors: [{ key: "tourism", value: "viewpoint" }],
profiles: ["trekking"],
},
{
id: "toilets",
name: "poi.toilets",
icon: "\u{1F6BB}",
color: "#6b7280",
selectors: [{ key: "amenity", value: "toilets" }],
},
];
export function getCategoriesForProfile(profile: string): string[] {
return poiCategories
.filter((c) => c.profiles?.includes(profile))
.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"],
safety: ["waymarked-cycling"],
trekking: ["waymarked-hiking"],
};