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:
parent
7dd9ccd2b0
commit
b45e69885d
29 changed files with 887 additions and 933 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue