Files
TREK/server/src/nest/maps/maps.helpers.ts
T
Maurice adcaca3c5f feat(tours): bike tours in the contract and on the server (BIK-01 to BIK-06, RTE-01)
A tour can be a bike tour with a routing profile (touring, road, gravel,
mtb), stored per tour by a new migration. The server grades the ways of a
mountain bike route from OpenStreetMap (mtb:scale) through one shared
Overpass client, offers CyclOSM behind its own admin switch, and exports a
bike tour's GPX with the activity of its profile. A hike's route choices
are now exactly the three the card names: steep climbs, steps and ferries.
2026-10-19 21:33:40 +02:00

909 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readEnv, getAppUrl } from '../../app-config';
import { haversineMetres } from '../common/geo';
import { stripHtmlTags } from '../common/stripHtmlTags';
import { buildUserAgent, parseOverpassEndpoints } from './overpass.constants';
import { normalizePlaceWebsite } from '@trek/shared';
import { z } from 'zod';
/**
* Pure maps/geo helpers — no DB, no Nest, no side effects beyond reading env.
*
* Moved verbatim from the legacy services/mapsService.ts when the maps domain
* went DI-native. Kept as plain functions (files.constants.ts / client-ip.ts
* precedent) because they are consumed both by MapsService and by plain-module
* code outside the container (transitService's User-Agent, the unit suites'
* parser cases).
*/
// The User-Agent rule (instance URL appended, the localhost fallback dropped) lives in
// overpass.constants.ts, the part of maps other domains may import; it is re-exported
// here for the callers that always read it from this module.
export { buildUserAgent };
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
// Nominatim) expect BCP-47, so normalise before sending — otherwise names and
// opening hours come back in the wrong language. Codes not listed here pass
// through unchanged (they are already valid), as do locale forms the client
// sometimes sends (e.g. 'pt-BR').
const API_LANG_OVERRIDES: Record<string, string> = {
br: 'pt-BR',
gr: 'el',
'el-GR': 'el',
};
export function toApiLang(lang: string | undefined, fallback = 'en'): string {
const code = (lang || '').trim();
if (!code) return fallback;
return API_LANG_OVERRIDES[code] ?? code;
}
/**
* A wiki subdomain out of a TREK language code.
*
* Not the same normalisation as `toApiLang`, and the difference bites: TREK
* calls Brazilian Portuguese `br`, but `br.wikipedia.org` exists and is the
* BRETON Wikipedia. Passing the raw code through would not fail — it would
* quietly return Breton articles. `pt-br.wikipedia.org` does not resolve at
* all, so the region subtag has to go as well.
*/
const WIKI_LANG_OVERRIDES: Record<string, string> = {
br: 'pt',
gr: 'el',
};
export function toWikiLang(lang: string | undefined, fallback = 'en'): string {
const raw = (lang || '').trim();
if (!raw) return fallback;
const mapped = WIKI_LANG_OVERRIDES[raw] ?? raw;
const base = mapped.split('-')[0].toLowerCase();
return /^[a-z]{2,3}$/.test(base) ? base : fallback;
}
// Re-exported, not redefined: this was one of three copies. Kept as an export
// here because callers and a test import it from maps.helpers.
export { haversineMetres };
/**
* Whether two place names plausibly refer to the same thing.
*
* The gate that keeps a coordinate-based lookup from confidently describing the
* wrong building. "Hamburg Airport" and "Flughafen Hamburg" pass on "hamburg";
* "Hamburg Airport" and "Bahnhof Ohlsdorf" do not.
*
* One shared word is enough only when it carries some weight — four letters or
* more. Otherwise two have to match. A single short word is almost always an
* article or a generic noun, and TREK speaks 23 languages, so "Der Kiosk" and
* "Der Bahnhof" would sail through a plain word-overlap test while a stopword
* list for all of them is its own maintenance problem. Anything the pair rule
* lets through has already survived the distance check.
*/
/**
* Put the index's answer and OpenStreetMap's into one list.
*
* Overture Places is a dataset of businesses. It is very good at those and
* largely does not carry temples, bridges, riverside walks, viewpoints or
* observation decks, which is a fair share of what somebody planning a trip
* searches for. Measured against 128 places out of a real trip to Japan, saved
* through the old search: the index answered 51.6 percent inside its top five,
* and for most of the rest it returned the shops AROUND the landmark rather
* than nothing, which is worse than nothing because it looks like an answer.
*
* Three orderings were tried against that corpus, and the two clever ones lost.
*
* A score threshold cannot tell the cases apart: at 0.75 it catches 19 of 62
* misses and throws away 6 of 66 hits. Promoting results whose name matches
* what was typed is worse than it sounds, because "Hase Station" shares a word
* with "Hase-dera" while OpenStreetMap answers in the local language and
* returns the correct temple under a name that shares nothing at all: the rule
* pushed seven weak index matches above the right answer, which landed at
* position eight.
*
* So this does not rank. It alternates, index first, and lets each source's own
* ordering stand. An exact business match still opens the list, because the
* index put it first; a landmark the index does not carry is second rather than
* eleventh. Neither source has to be judged by the other's yardstick.
*
* A place both sources know is kept once, as the index's copy, because that is
* the one carrying a stable id, contact details and hours.
*/
export function mergeSearchResults(
fromIndex: Record<string, unknown>[],
fromOsm: Record<string, unknown>[],
limit = 10,
): Record<string, unknown>[] {
const extra = fromOsm.filter((o) => !fromIndex.some((i) => samePlace(i, o)));
const out: Record<string, unknown>[] = [];
for (let i = 0; i < Math.max(fromIndex.length, extra.length) && out.length < limit; i++) {
if (i < fromIndex.length) out.push(fromIndex[i]);
if (i < extra.length && out.length < limit) out.push(extra[i]);
}
return out;
}
/**
* Whether two rows from different sources are one place. The search list and
* the suggestion list both dedupe on this, so a place shown once in one is
* shown once in the other.
*/
export function samePlace(a: { lat?: unknown; lng?: unknown; name?: unknown }, b: typeof a): boolean {
const [aLat, aLng, bLat, bLng] = [a.lat, a.lng, b.lat, b.lng];
if (![aLat, aLng, bLat, bLng].every((v) => typeof v === 'number')) return false;
// 60 m is close enough to be the same shop. It is NOT enough on its own:
// a temple and the coffee shop at its gate are fifty metres apart, and
// deduping on distance alone swallowed the temple, which is the exact
// result this function exists to surface. Two things in one spot with
// unrelated names are two things.
if (haversineMetres(aLat as number, aLng as number, bLat as number, bLng as number) >= 60) {
return false;
}
return typeof a.name === 'string' && typeof b.name === 'string' && namesOverlap(a.name, b.name);
}
export function namesOverlap(a: string, b: string): boolean {
const words = (value: string): Set<string> =>
new Set(
value
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.split(/[^a-z0-9]+/)
.filter((word) => word.length > 2),
);
const left = [...words(a)];
const right = [...words(b)];
/**
* Neither name has a Latin word to compare.
*
* `words()` splits on `[^a-z0-9]`, so a name written in Japanese, Korean,
* Chinese, Greek, Cyrillic, Arabic, Hebrew or Thai tokenises to nothing at
* all, and returning false there made this function blind to exactly the
* places the two sources most often both know: 長谷寺 came back once from the
* index and once from OpenStreetMap, one above the other in the same list.
*
* Compared strictly, not by overlap. Two CJK names that merely share a
* character are usually two different places (東京タワー and 東京駅 share
* 東京), and deduping the wrong pair swallows a real result — the failure this
* whole function is written to avoid. Identical names at the same coordinate
* are the case worth catching, and it is the common one, because both sources
* carry the official local name.
*
* NFKC folds the width and compatibility variants the two sources disagree
* on (タワー against タワー), which NFD, used for the Latin path above, does not.
*/
if (left.length === 0 && right.length === 0) {
const strict = (value: string): string => value.normalize('NFKC').toLowerCase().replace(/\s+/gu, '');
const [sa, sb] = [strict(a), strict(b)];
return sa.length > 0 && sa === sb;
}
if (left.length === 0) return false;
/**
* Same word, allowing for an inflected ending.
*
* Providers localise names, and the two sides of this comparison rarely come
* from the same language: searching "Hamburg Airport" returns "Hamburger
* Flughafen Helmut Schmidt", and a plain equality test rejects its own
* correct answer over the "-er". A short prefix would over-match, so the
* shared stem has to carry weight and the endings have to be close.
*/
const sameStem = (x: string, y: string): boolean => {
if (x === y) return true;
const [short, long] = x.length <= y.length ? [x, y] : [y, x];
return short.length >= 4 && long.length - short.length <= 3 && long.startsWith(short);
};
let shared = 0;
for (const word of right) {
const match = left.find((candidate) => sameStem(candidate, word));
if (!match) continue;
if (Math.min(match.length, word.length) >= 4) return true;
if (++shared >= 2) return true;
}
return false;
}
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
// The same id inside the `data=` pathname blob, where a resolved share link keeps it:
// /maps/place/<name>/data=!4m6!3m5!1s0x47e66e1f06e2b70f:0x40b82c3688c9460!8m2!3d48.85!4d2.29
const GOOGLE_FTID_DATA_RE = /!1s(0x[0-9a-f]{1,20}:0x[0-9a-f]{1,20})/i;
// Extracts a Google Maps feature id (ftid, 0x..:0x..) from a URL.
// Two shapes carry it. The `?ftid=` query parameter is the old one. A share link that
// resolveGoogleMapsUrl has followed to its final hop carries it instead in the `data=`
// pathname blob, in the same `!1s…!3d…!4d…` run the coordinates come from (#1954) — so
// reading only the query parameter dropped the id for every pasted maps.app.goo.gl link.
// The Places API (New) googleMapsUri is usually a cid-style URL (https://maps.google.com/?cid=NNN)
// with neither, so this still returns null for most API responses — the precise
// query_place_id link is used instead.
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
const ftid = parsed.searchParams.get('ftid')?.trim();
if (ftid && GOOGLE_FTID_RE.test(ftid)) return ftid.toLowerCase();
// Only for a place URL. A /maps/dir/ route carries one !1s per waypoint and the
// first one is the origin, not the place the link is about.
if (!parsed.pathname.includes('/place/')) return null;
const fromPath = GOOGLE_FTID_DATA_RE.exec(parsed.pathname)?.[1];
return fromPath ? fromPath.toLowerCase() : null;
} catch {
return null;
}
}
// ── Overpass POI categories ──────────────────────────────────────────────────
export interface OverpassPoi {
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
name: string;
lat: number;
lng: number;
category: string; // the requested pill category key, e.g. 'restaurant'
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
address: string | null;
website: string | null;
phone: string | null;
opening_hours: string | null;
cuisine: string | null;
/** Brand name and its Wikidata id, when OSM carries them — the logo is looked up from the id. */
brand: string | null;
brand_wikidata: string | null;
/** What a charging station offers, when it is one and OSM says. */
charging: ChargingInfo | null;
/**
* Which index the row came from. Overture is not OpenStreetMap: it carries
* OSM among other sources under other licences, so a row from the TREK index
* says so rather than borrowing OSM's name. The wire contract keeps this an
* open string, so widening it here breaks nothing.
*/
source: 'openstreetmap' | 'trek-places';
}
/**
* The part of a charging station that decides whether it is any use to a particular car.
*
* Read out of tags the query has always returned and the projection has always thrown
* away: `out center tags` hands back the whole tag set, and only six keys of it were ever
* passed on. Nothing here costs an extra request.
*
* Coverage is the reason this is all optional. Across the charging stations in OSM,
* roughly a third carry a socket type, about seven in ten a capacity, and about half say
* whether they charge a fee. A filter built on it has to treat "not stated" as its own
* answer rather than as a no.
*/
export interface ChargingInfo {
/** One entry per socket family the station lists, with how many and how fast. */
sockets: { type: string; count: number | null; kw: number | null }[];
/** How many vehicles can charge at once, across all sockets. */
capacity: number | null;
/** true = costs money, false = free, null = OSM does not say. */
fee: boolean | null;
}
/**
* OSM writes sockets as one key per family: `socket:type2=4` is the count, and
* `socket:type2:output=22 kW` the power. Both are free text in practice, so the count is
* only taken when it parses as a whole number and the power only when a number can be
* read off the front of it.
*
* The families are listed rather than derived from the tag names, because `socket:` also
* carries keys that are not a socket family at all.
*/
const SOCKET_FAMILIES = [
'type2',
'type2_combo',
'type2_cable',
'ccs',
'chademo',
'type1',
'type1_combo',
'schuko',
'tesla_supercharger',
'tesla_destination',
] as const;
/** Leading number out of a free-text value like "22 kW" or "50kw". */
function leadingNumber(raw: string | undefined): number | null {
if (!raw) return null;
const n = Number.parseFloat(raw.replace(',', '.'));
return Number.isFinite(n) && n > 0 ? n : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
const sockets: ChargingInfo['sockets'] = [];
for (const family of SOCKET_FAMILIES) {
const raw = tags[`socket:${family}`];
if (raw === undefined) continue;
const count = Number.parseInt(raw, 10);
sockets.push({
type: family,
count: Number.isInteger(count) && count > 0 ? count : null,
kw: leadingNumber(tags[`socket:${family}:output`]),
});
}
const capacity = Number.parseInt(tags.capacity ?? '', 10);
const fee = tags.fee === 'yes' ? true : tags.fee === 'no' ? false : null;
const info: ChargingInfo = {
sockets,
capacity: Number.isInteger(capacity) && capacity > 0 ? capacity : null,
fee,
};
// Nothing said is null rather than an empty shell, so the client can tell "no data"
// from "no sockets" without inspecting three fields.
return sockets.length || info.capacity !== null || fee !== null ? info : null;
}
// Each pill category → the OSM tag selectors it searches. Keys here are the
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
// client-side).
export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
cafe: ['amenity=cafe'],
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
sights: [
'tourism=attraction',
'tourism=viewpoint',
'historic=monument',
'historic=castle',
'historic=memorial',
'historic=ruins',
],
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
supermarket: ['shop=supermarket', 'shop=convenience'],
// What a drive needs rather than what a city visit does (#1797). Separate from
// `activity`/`nature` on purpose: nobody browsing museums wants petrol stations in
// the same result set, and the road trip panel asks for these by name.
fuel: ['amenity=fuel'],
charging: ['amenity=charging_station'],
rest_area: ['highway=rest_area', 'highway=services'],
campsite: ['tourism=camp_site', 'tourism=caravan_site'],
// What a hike, a ride or a paddle tour stops for along its line (PLN-02). Asked for by the
// Tours search along a route, never offered by the explore pill.
viewpoint: ['tourism=viewpoint'],
peak: ['natural=peak'],
shelter: ['amenity=shelter', 'tourism=wilderness_hut', 'tourism=alpine_hut'],
drinking_water: ['amenity=drinking_water', 'amenity=water_point'],
toilets: ['amenity=toilets'],
picnic: ['tourism=picnic_site', 'leisure=picnic_table'],
slipway: ['leisure=slipway', 'canoe=put_in'],
boat_rental: ['amenity=boat_rental'],
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
};
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
/**
* Largest viewport side one POI search covers. A country-sized box makes Overpass scan
* millions of elements and time out, and a plugin POI provider is no better placed to
* answer for a continent, so both paths narrow a larger box to a centred window.
*/
export const MAX_POI_BBOX_SPAN_DEG = 0.5;
export interface PoiBbox {
south: number;
west: number;
north: number;
east: number;
}
/** Narrow an oversized viewport to a centred window; `clamped` says it happened. */
export function clampPoiBbox(bbox: PoiBbox): { bbox: PoiBbox; clamped: boolean } {
let { south, west, north, east } = bbox;
let clamped = false;
if (north - south > MAX_POI_BBOX_SPAN_DEG) {
const c = (north + south) / 2;
south = c - MAX_POI_BBOX_SPAN_DEG / 2;
north = c + MAX_POI_BBOX_SPAN_DEG / 2;
clamped = true;
}
if (east - west > MAX_POI_BBOX_SPAN_DEG) {
const c = (east + west) / 2;
west = c - MAX_POI_BBOX_SPAN_DEG / 2;
east = c + MAX_POI_BBOX_SPAN_DEG / 2;
clamped = true;
}
return { bbox: { south, west, north, east }, clamped };
}
/**
* The rectangle the MCP POI tools take: search_pois for the core categories and
* search_plugin_pois for the plugin ones. One definition, so both accept the same box
* and describe the same window it is narrowed to.
*/
export const POI_BBOX_TOOL_INPUT = z
.strictObject({
south: z.number().min(-90).max(90).describe('Southern edge, latitude'),
west: z.number().min(-180).max(180).describe('Western edge, longitude'),
north: z.number().min(-90).max(90).describe('Northern edge, latitude'),
east: z.number().min(-180).max(180).describe('Eastern edge, longitude'),
})
.describe(
`The rectangle to search. Anything wider than ${MAX_POI_BBOX_SPAN_DEG} degrees is narrowed to a centred window so the query stays fast; the answer reports that as \`clamped\``,
);
/** How many categories one POI query may carry, so a caller can't fan out the mirrors. */
export const MAX_POI_CATEGORIES = 8;
/**
* Reads the `category` parameter, which is either one key or a comma-separated list.
*
* Asking for several kinds at once is one Overpass round-trip instead of one per kind —
* the road trip corridor searches four categories over a dozen boxes, and as separate
* requests that is four times the load on a shared mirror for the same answer.
*/
export function parsePoiCategories(raw: string): string[] {
const seen = new Set<string>();
for (const part of raw.split(',')) {
const key = part.trim();
if (key) seen.add(key);
if (seen.size >= MAX_POI_CATEGORIES) break;
}
return [...seen];
}
// OVERPASS_URL as the endpoint list (it REPLACES the public mirrors, see #1309), read
// live by default. The parsing and the mirror list are shared with the via ferrata
// lookup through overpass.constants.ts.
export function resolveOverpassEndpoints(raw: string | undefined = readEnv().integrations.overpassUrl): string[] {
return parseOverpassEndpoints(raw);
}
// Per-mirror fetch cap. Because mirrors race in parallel this is also the worst-case
// wait before every mirror is given up on and a 502 is returned. Public mirrors answer
// in 1–2s when reachable, so the cap mainly bounds dead/blocked ones; operators with a
// slow self-hosted endpoint can raise it via OVERPASS_TIMEOUT_MS. A non-positive or
// non-numeric value falls back to the default — a 0/negative cap would abort every
// request immediately and 502 the search.
/**
* How long Overpass is allowed to spend on one query, in seconds.
*
* Sent inside the query as `[timeout:N]`, so the mirror itself enforces it. Anything we
* wait client-side has to be longer than this or we hang up on an answer that was still
* coming — which is exactly what used to happen: the query asked for twenty seconds of
* work and the fetch was aborted after twelve, so a mirror under load never got to
* finish and a corridor search reported half its stretches as unsearchable.
*/
export const OVERPASS_QUERY_TIMEOUT_S = 20;
/** The client-side budget: the mirror's own, plus room to hand the answer back. */
export const OVERPASS_TIMEOUT_DEFAULT_MS = (OVERPASS_QUERY_TIMEOUT_S + 5) * 1000;
export function resolveOverpassTimeoutMs(raw?: string): number {
if (raw === undefined) return readEnv().integrations.overpassTimeoutMs;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : OVERPASS_TIMEOUT_DEFAULT_MS;
}
// ── Opening hours parsing ────────────────────────────────────────────────────
export interface GoogleTimePoint {
day?: number;
hour?: number;
minute?: number;
}
export interface GoogleOpeningHours {
weekdayDescriptions?: string[];
openNow?: boolean;
periods?: { open?: GoogleTimePoint; close?: GoogleTimePoint }[];
specialDays?: { date?: { year?: number; month?: number; day?: number } }[];
}
// Machine-readable opening hours, in Google's numbering: day 0 = Sunday … 6 = Saturday.
// The weekday descriptions next to them are display text in the requested language and
// cannot be parsed reliably; these can. A period without `close` is Google's way of
// saying the place never closes.
export interface OpeningTimePoint {
day: number;
hour: number;
minute: number;
}
export interface OpeningPeriod {
open: OpeningTimePoint;
close: OpeningTimePoint | null;
}
// Places (New) speaks proto3 JSON, which leaves out fields that hold the default
// value — a period starting Sunday midnight arrives as `{}`, not as three zeroes.
function toTimePoint(point: GoogleTimePoint | undefined): OpeningTimePoint | null {
if (!point || typeof point !== 'object') return null;
const field = (value: unknown): number | null => {
if (value === undefined || value === null) return 0;
return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : null;
};
const day = field(point.day);
const hour = field(point.hour);
const minute = field(point.minute);
if (day === null || hour === null || minute === null) return null;
if (day < 0 || day > 6 || hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { day, hour, minute };
}
export function normalizeOpeningPeriods(periods: GoogleOpeningHours['periods']): OpeningPeriod[] | null {
if (!Array.isArray(periods) || periods.length === 0) return null;
const result: OpeningPeriod[] = [];
for (const period of periods) {
const open = toTimePoint(period?.open);
if (!open) continue;
// A missing close means "never closes"; a close that is there but unusable makes
// the whole period a guess, so it is dropped rather than read as round-the-clock.
const close = period?.close == null ? null : toTimePoint(period.close);
if (period?.close != null && !close) continue;
result.push({ open, close });
}
return result.length > 0 ? result : null;
}
// Holidays and other exceptional days that the weekly periods do not describe. Google
// reports them as calendar dates; they travel to the client as YYYY-MM-DD so it can
// compare them against the place's own local date.
export function normalizeSpecialDays(specialDays: GoogleOpeningHours['specialDays']): string[] | null {
if (!Array.isArray(specialDays) || specialDays.length === 0) return null;
const dates: string[] = [];
for (const entry of specialDays) {
const date = entry?.date;
if (!date) continue;
const { year, month, day } = date;
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') continue;
if (month < 1 || month > 12 || day < 1 || day > 31) continue;
const iso = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (!dates.includes(iso)) dates.push(iso);
}
return dates.length > 0 ? dates : null;
}
// "09:00-18:00", also the "20:00-02:00" and "00:00-24:00" spellings OSM uses.
const OSM_TIME_RANGE = /(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g;
/** Periods for one OSM weekday line. `dayIdx` is Monday-based, the output is not. */
function osmPeriods(dayIdx: number, timePart: string): OpeningPeriod[] {
const day = (dayIdx + 1) % 7;
const periods: OpeningPeriod[] = [];
for (const match of timePart.matchAll(OSM_TIME_RANGE)) {
const openHour = Number.parseInt(match[1], 10);
const openMinute = Number.parseInt(match[2], 10);
let closeHour = Number.parseInt(match[3], 10);
const closeMinute = Number.parseInt(match[4], 10);
if (openHour > 23 || openMinute > 59 || closeHour > 24 || closeMinute > 59) continue;
// OSM writes the end of a day as 24:00, a clock reading Google's numbering has no
// hour 24 — that is midnight of the following day, same as any range that wraps.
let nextDay = closeHour * 60 + closeMinute <= openHour * 60 + openMinute;
if (closeHour === 24) {
if (closeMinute !== 0) continue;
closeHour = 0;
nextDay = true;
}
periods.push({
open: { day, hour: openHour, minute: openMinute },
close: { day: nextDay ? (day + 1) % 7 : day, hour: closeHour, minute: closeMinute },
});
}
return periods;
}
export function parseOpeningHours(ohString: string): {
weekdayDescriptions: string[];
openNow: boolean | null;
periods: OpeningPeriod[];
} {
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const result: string[] = LONG.map((d) => `${d}: ?`);
const periods: OpeningPeriod[] = [];
// "24/7" — and it is not an edge case. Every segment below needs a weekday
// prefix to match, so this fell straight through, all seven lines stayed "?",
// and `buildOsmDetails` then threw the whole thing away as unparseable. Which
// is why airports, main stations and petrol stations — the places most likely
// to be tagged this way — showed no opening hours at all.
if (/^\s*(24\/7|open)\s*$/i.test(ohString)) {
return {
weekdayDescriptions: LONG.map((d) => `${d}: 00:00-24:00`),
openNow: true,
// One period that never closes. `close: null` is how the client's
// open/closed logic already spells "does not close".
periods: [{ open: { day: 0, hour: 0, minute: 0 }, close: null }],
};
}
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
for (const segment of ohString.split(';')) {
const trimmed = segment.trim();
if (!trimmed) continue;
// The time part is `\S.*`, not `.+`: the segment was trimmed above, so both
// spell "everything after the gap". But `\s+(.+)` lets the gap and the time
// part fight over the same spaces, and a segment that is a weekday followed by
// nothing but blanks then costs a pass per space.
const match = trimmed.match(
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(\S.*)$/i,
);
if (!match) continue;
const [, daysPart, timePart] = match;
const dayIndices = new Set<number>();
for (const range of daysPart.split(',')) {
const parts = range
.trim()
.split('-')
.map((d) => DAYS.indexOf(d.trim()));
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
// do/while, not while: a range that wraps all the way round — "Mo-Su",
// or "Tu-Mo", both of which mean every day — starts already satisfying
// the exit condition, so the loop never ran and only the closing day
// was added. "Mo-Su 11:30-23:00" is how a place open daily is usually
// tagged, and it produced exactly one day of hours.
let day = parts[0];
do {
dayIndices.add(day);
day = (day + 1) % 7;
} while (day !== (parts[1] + 1) % 7);
} else if (parts[0] >= 0) {
dayIndices.add(parts[0]);
}
}
for (const idx of dayIndices) {
result[idx] = `${LONG[idx]}: ${timePart.trim()}`;
periods.push(...osmPeriods(idx, timePart));
}
}
// Compute openNow
let openNow: boolean | null = null;
try {
const now = new Date();
const jsDay = now.getDay();
const dayIdx = jsDay === 0 ? 6 : jsDay - 1;
const todayLine = result[dayIdx];
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
if (timeRanges.length > 0) {
const nowMins = now.getHours() * 60 + now.getMinutes();
openNow = timeRanges.some((m) => {
const start = Number.parseInt(m[1]) * 60 + Number.parseInt(m[2]);
const end = Number.parseInt(m[3]) * 60 + Number.parseInt(m[4]);
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
});
}
} catch {
/* best effort */
}
return { weekdayDescriptions: result, openNow, periods };
}
// ── Build standardized OSM details ───────────────────────────────────────────
export function buildOsmDetails(tags: Record<string, string>, osmType: string, osmId: string) {
let opening_hours: string[] | null = null;
let open_now: boolean | null = null;
let opening_periods: OpeningPeriod[] | null = null;
if (tags.opening_hours) {
const parsed = parseOpeningHours(tags.opening_hours);
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
if (hasData) {
opening_hours = parsed.weekdayDescriptions;
open_now = parsed.openNow;
opening_periods = parsed.periods.length > 0 ? parsed.periods : null;
}
}
return {
website: normalizePlaceWebsite(tags['contact:website']) ?? normalizePlaceWebsite(tags.website),
phone: tags['contact:phone'] || tags.phone || null,
opening_hours,
open_now,
opening_periods,
osm_url: `https://www.openstreetmap.org/${osmType}/${osmId}`,
summary: tags.description || null,
// Kept so enrichment can resolve the right Wikipedia article instead of
// guessing one from the place name, which picks the wrong article whenever
// the name is ambiguous ("Bahnhofstraße", "Rathaus", any chain restaurant).
wikipedia: tags.wikipedia || null,
wikidata: tags.wikidata || null,
// A Commons category is pictures OF this place, where a coordinate search
// only finds whatever was photographed near it.
wikimedia_commons: tags.wikimedia_commons || null,
// The tags that make the difference for a restaurant or a shop: no
// encyclopaedia will ever describe one, but its cuisine, its hours and a
// link to its menu are usually right here.
cuisine: tags.cuisine || null,
menu_url: tags['website:menu'] || tags.menu || null,
outdoor_seating: tags.outdoor_seating || null,
takeaway: tags.takeaway || null,
delivery: tags.delivery || null,
wheelchair: tags.wheelchair || null,
diet_vegetarian: tags['diet:vegetarian'] || null,
diet_vegan: tags['diet:vegan'] || null,
internet_access: tags.internet_access || null,
source: 'openstreetmap' as const,
};
}
// ── Wiki metadata ────────────────────────────────────────────────────────────
/**
* Commons extmetadata values arrive as HTML fragments — an author is typically
* an <a> to the uploader's user page, a licence can carry <span> wrappers. We
* show these as plain text next to a thumbnail, so the markup has to go.
*/
export function stripWikiMarkup(value: string | undefined | null): string | null {
if (!value) return null;
const text = stripHtmlTags(value, ' ').replaceAll('&nbsp;', ' ').replace(/\s+/g, ' ').trim();
return text || null;
}
/**
* Splits an OSM `wikipedia` tag ("de:Museum Ludwig") into host language and
* article title. Returns null for the bare-title spelling, which has no language
* and would send us to the wrong wiki.
*/
export function parseWikipediaTag(tag: string | undefined | null): { lang: string; title: string } | null {
if (!tag) return null;
const match = /^([a-z-]{2,12}):(.+)$/i.exec(tag.trim());
if (!match) return null;
const title = match[2].trim();
return title ? { lang: match[1].toLowerCase(), title } : null;
}
// ── Place-id classification ──────────────────────────────────────────────────
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
// collection views send), OSM ids the client sends when a place has no
// google_place_id, GERS ids from the TREK index, Amap POI ids (`amap:<poiid>`,
// which are otherwise shaped exactly like a Google id), the raw photo URLs
// legacy rows keep in image_url, and photo cache keys of the form "<placeId>~p3"
// that enrichment mints for the picker.
// Google answers those with a billable 400 INVALID_ARGUMENT, so every lookup
// sorts them out before the call and uses the right provider instead.
//
// `gers:` belongs here for the same reason as `node:`, and it matters more: the
// index answers first for search and autocomplete, so a GERS id is what an
// ordinary new place now carries. Leaving it out billed three invalid lookups
// per place — photo refs, editorial summary, and the photo route.
const NON_GOOGLE_PLACE_ID =
/^(?:coords|gers|node|way|relation|amap):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
// `plugin:` too (`plugin:<pluginId>:<id>`, #2221 and #1781): a place picked from a
// plugin search or a plugin POI category keeps the plugin's id, and it names the
// plugin's own index, never a Google record. A pattern of its own, because the one
// above is already as tangled as a pattern should get.
const PLUGIN_PLACE_ID = /^plugin:/i;
// The subset that still has a provider behind it — Overpass for details,
// Wikimedia for photos. The id has to be the whole of what follows the colon:
// it is written into an Overpass query as it is, and an element id is a number.
export const OSM_PLACE_ID = /^(?:node|way|relation):\d+$/i;
export function isGooglePlaceId(placeId: string): boolean {
return !NON_GOOGLE_PLACE_ID.test(placeId) && !PLUGIN_PLACE_ID.test(placeId);
}
// ── Ranking Commons candidates ───────────────────────────────────────────────
/**
* A candidate as the ranker needs to see it. Structurally the subset of
* `CommonsCandidate` it reads, kept local so the pure function does not have to
* import from the service that imports this file.
*/
export interface RankableCommonsCandidate {
pageId: number | null;
title: string | null;
attribution: string | null;
width: number | null;
height: number | null;
descriptors: string | null;
photoUrl: string;
}
/**
* Commons files that are near a place but are not a picture of it.
*
* Orthophotos are the worst offender by volume: German states upload their
* aerial survey tiles to Commons with coordinates, so a geosearch around any
* airport returns the runway as seen from a plane at 3000m — four of them for
* Hamburg, one per survey year. The rest are documents that happen to live in a
* place's category: noise maps, floor plans, terminal layouts, logos, coats of
* arms.
*/
const NOT_A_PHOTO_OF_THE_PLACE =
/orthophoto|orthofoto|sommerbefliegung|luftbildkarte|dop\d+|l[äa]rmkarte|noise map|floor ?plan|grundriss|lageplan|layout|diagram|schematic|blueprint|coat of arms|wappen|\blogo\b|flag of/i;
/**
* Strips what makes two frames of the same burst look like different files, so
* they collapse onto one stem: trailing counters, camera dumps, dates, and the
* `- 17` / `(2)` suffixes press sets use.
*/
function seriesStem(title: string): string {
return (
title
.replace(/^File:/i, '')
.replace(/\.[a-z0-9]+$/i, '')
.toLowerCase()
// 20260614 100717648 HDR — a camera dump, all from the same minute
.replace(/\b\d{8}[ _-]\d{6,9}\b/g, ' ')
.replace(/\b(19|20)\d{2}\b/g, ' ')
// The character in front of the suffix is matched and put straight back:
// /[ _-]+…$/ on its own restarts at every space of a title that has no such
// suffix, and re-reads the rest of the run each time.
.replace(/([^ _-]|^)[ _-]+\(?\d{1,4}\)?$/g, '$1')
.replace(/[^a-z]+/g, ' ')
.trim()
);
}
/**
* Picks the pictures worth showing out of whatever the providers returned.
*
* Everything here was written against a real failing case. Deduplication is by
* page id because the same file reaches us under different thumbnail URLs. The
* series rule exists because Commons categories are full of bursts — the four
* `Hauptbahnhof Berlin interior 0807..0810` shots that filled the picker were
* one person walking across a concourse. The author cap is the same problem
* seen from the other side: one contributor who documented a station thoroughly
* should not supply the whole strip.
*
* `perAuthor` is the one knob: coordinate search gets 1, because there being no
* evidence the pictures are even of the right subject makes variety the only
* defence. Curated sources get 2 — someone already vouched that these depict
* the place.
*/
export function rankCommonsCandidates<T extends RankableCommonsCandidate>(
candidates: T[],
limit: number,
opts: { perAuthor?: number } = {},
): T[] {
const perAuthor = opts.perAuthor ?? 2;
const seenPages = new Set<number>();
const seenUrls = new Set<string>();
const seenStems = new Set<string>();
const authorCounts = new Map<string, number>();
const out: T[] = [];
for (const candidate of candidates) {
if (out.length >= limit) break;
if (candidate.pageId != null) {
if (seenPages.has(candidate.pageId)) continue;
} else if (seenUrls.has(candidate.photoUrl)) {
// No page id (an older payload, or a provider that does not report one):
// the URL is a weaker key but better than letting an exact repeat through.
continue;
}
const haystack = `${candidate.title ?? ''} ${candidate.descriptors ?? ''}`;
if (NOT_A_PHOTO_OF_THE_PLACE.test(haystack)) continue;
const { width, height } = candidate;
if (width && height) {
// Survey tiles are square and enormous; nothing photographed by hand is.
if (width === height && width >= 3000) continue;
const ratio = Math.max(width / height, height / width);
// Panorama strips and tall banners crop to nothing in a square tile.
if (ratio > 4) continue;
}
const stem = candidate.title ? seriesStem(candidate.title) : '';
if (stem && seenStems.has(stem)) continue;
const author = (candidate.attribution ?? '').trim().toLowerCase();
if (author) {
const used = authorCounts.get(author) ?? 0;
if (used >= perAuthor) continue;
authorCounts.set(author, used + 1);
}
if (candidate.pageId != null) seenPages.add(candidate.pageId);
seenUrls.add(candidate.photoUrl);
if (stem) seenStems.add(stem);
out.push(candidate);
}
return out;
}