mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
adcaca3c5f
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.
909 lines
40 KiB
TypeScript
909 lines
40 KiB
TypeScript
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(' ', ' ').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;
|
||
}
|