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>
168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
/**
|
||
* 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"],
|
||
};
|