feat(tours): find e-bike charging points along a bike tour, only those tagged for bicycles, with what they state

This commit is contained in:
Maurice
2026-10-21 11:58:39 +02:00
parent 924e38472e
commit 1a722e63fb
50 changed files with 1071 additions and 57 deletions
+2 -6
View File
@@ -3,6 +3,7 @@
* the stretches Overpass is asked in, and the query that asks for every kind
* near one stretch at once. Pure: PoisAlongResolver makes the calls.
*/
import { overpassSelector } from './osm-filter.helpers';
import { lineMetres, pointAtMeters, simplifyLine, sliceAtMeters, type LatLng } from '@trek/shared/roadtrip';
/**
@@ -147,12 +148,7 @@ const coordinate = (value: number) => value.toFixed(5);
export function overpassAlongQuery(filters: readonly string[], path: AroundPath, cap: number): string {
const { south, west, north, east } = path.bbox;
const box = `(${[south, west, north, east].map(coordinate).join(',')})`;
const selectors = filters
.map((filter) => {
const [key, value] = filter.split('=');
return ` nwr["${key}"="${value}"]${box};`;
})
.join('\n');
const selectors = filters.map((filter) => ` ${overpassSelector(filter, box)};`).join('\n');
const line = path.points.map((point) => `${coordinate(point.lat)},${coordinate(point.lng)}`).join(',');
return `[out:json][timeout:${OVERPASS_ALONG_QUERY_TIMEOUT_S}];\n(\n${selectors}\n)->.cand;\nnwr.cand(around:${path.radiusM},${line});\nout center tags ${cap + 25};`;
}
+55 -10
View File
@@ -254,8 +254,8 @@ export interface OverpassPoi {
/** 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;
/** What a charging station offers, when it is one and OSM says; for an e-bike one, who may charge too. */
charging: ChargingInfo | BikeChargingInfo | 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
@@ -315,9 +315,9 @@ function leadingNumber(raw: string | undefined): number | null {
return Number.isFinite(n) && n > 0 ? n : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
function readSockets(tags: Record<string, string>, families: readonly string[]): ChargingInfo['sockets'] {
const sockets: ChargingInfo['sockets'] = [];
for (const family of SOCKET_FAMILIES) {
for (const family of families) {
const raw = tags[`socket:${family}`];
if (raw === undefined) continue;
const count = Number.parseInt(raw, 10);
@@ -327,18 +327,60 @@ export function readChargingInfo(tags: Record<string, string>): ChargingInfo | n
kw: leadingNumber(tags[`socket:${family}:output`]),
});
}
return sockets;
}
function readCapacity(tags: Record<string, string>): number | null {
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,
};
return Number.isInteger(capacity) && capacity > 0 ? capacity : null;
}
function readFee(tags: Record<string, string>): boolean | null {
return tags.fee === 'yes' ? true : tags.fee === 'no' ? false : null;
}
export function readChargingInfo(tags: Record<string, string>): ChargingInfo | null {
const sockets = readSockets(tags, SOCKET_FAMILIES);
const fee = readFee(tags);
const info: ChargingInfo = { sockets, capacity: readCapacity(tags), 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;
}
/** What an e-bike charging point states beyond a car charger's facts: that bicycles may charge, and who may use it. */
interface BikeChargingInfo extends ChargingInfo {
/** `designated` is made for bikes, `yes` lets them charge; the selector asks for one of the two. */
bicycle: 'yes' | 'designated';
/** OSM's own access value, `customers` or `private` style; null when not stated. */
access: string | null;
}
/**
* The sockets an e-bike charger is mapped with: household plugs for the bike's own
* charger (Schuko, the French type E, the British, the Swiss T13/T23, the blue CEE
* camping plug) and Type 2. OSM documents no key for a maker's bike connector that
* mappers use consistently, so none is read.
*/
const BIKE_SOCKET_FAMILIES = ['schuko', 'typee', 'bs1363', 'sev1011_t13', 'sev1011_t23', 'cee_blue', 'type2'] as const;
/**
* What an e-bike charging point states, or null when it is not tagged for bicycles:
* the search asks for `bicycle=yes|designated`, and a point read here without either
* is never passed off as one. Sockets, capacity and fee are read as on a car charger.
*/
export function readBikeChargingInfo(tags: Record<string, string>): BikeChargingInfo | null {
const bicycle = tags.bicycle;
if (bicycle !== 'yes' && bicycle !== 'designated') return null;
return {
sockets: readSockets(tags, BIKE_SOCKET_FAMILIES),
capacity: readCapacity(tags),
fee: readFee(tags),
bicycle,
access: tags.access?.trim() || 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).
@@ -378,6 +420,9 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
slipway: ['leisure=slipway', 'canoe=put_in'],
boat_rental: ['amenity=boat_rental'],
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
// BIK-07: charging points that say they are for bicycles. A selector with `&` asks
// for every condition at once (osm-filter.helpers), so a car charger never comes back.
ebike_charging: ['amenity=charging_station&bicycle=designated', 'amenity=charging_station&bicycle=yes'],
// A city walk's market (CW-05): the square itself, not the shops of a mall as `shopping` has them.
market: ['amenity=marketplace'],
// A city walk's museums and galleries (CW-05), without the theatres and public art of `museum`.
+1 -1
View File
@@ -130,7 +130,7 @@ export class MapsMcp {
@Tool({
name: 'search_pois',
description:
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. ebike_charging finds only charging points tagged for bicycles, and reports beside the sockets whether they are made for bikes or let them charge (bicycle) and who may use them (access). This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
inputSchema: {
category: z
.union([z.enum(POI_CATEGORY_KEYS), z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES)])
@@ -0,0 +1,27 @@
/**
* The OSM selectors of CATEGORY_OSM_FILTERS: `key=value`, or several of them
* joined by `&` when a place has to carry every one (BIK-07: a charging station
* that also says it is for bicycles). Pure, so the explore pill, the search
* along a line and the reading of their answers build and match one grammar.
*/
/** The `key=value` conditions of one selector, in the order written. */
function conditionsOf(filter: string): Array<[string, string]> {
return filter.split('&').map((condition) => {
const [key, value] = condition.split('=');
return [key, value];
});
}
/** The Overpass statement for one selector inside a box, `nwr["k"="v"](box)`; no semicolon. */
export function overpassSelector(filter: string, box: string): string {
const conditions = conditionsOf(filter)
.map(([key, value]) => `["${key}"="${value}"]`)
.join('');
return `nwr${conditions}${box}`;
}
/** Whether a place's tags meet every condition of one selector. */
export function tagsMatchFilter(tags: Record<string, string>, filter: string): boolean {
return conditionsOf(filter).every(([key, value]) => tags[key] === value);
}
+22 -15
View File
@@ -34,10 +34,12 @@ import {
haversineMetres,
namesOverlap,
readChargingInfo,
readBikeChargingInfo,
buildOsmDetails,
OSM_PLACE_ID,
type OverpassPoi,
} from '../maps.helpers';
import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers';
import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers';
import { readWikiIdentity } from './wiki-identity';
import { Injectable } from '@nestjs/common';
@@ -259,8 +261,22 @@ const KEPT_UNNAMED_ALONG: ReadonlySet<string> = new Set([
'peak',
'slipway',
'bike_repair',
// A charging point is often mapped with neither a name nor an operator.
'ebike_charging',
]);
/**
* What a charging station states, read only where it means something. Every POI
* carries `capacity` and `fee` for its own reasons (a restaurant's capacity is
* seats), so reading them as charging data anywhere else would be wrong on most
* of the map.
*/
function chargingOf(category: string, tags: Record<string, string>): OverpassPoi['charging'] {
if (category === 'charging') return readChargingInfo(tags);
if (category === 'ebike_charging') return readBikeChargingInfo(tags);
return null;
}
/**
* Overpass elements as POI rows, each labelled with the category its selector
* belongs to. An unnamed element is dropped unless its category is one of
@@ -276,11 +292,7 @@ function readPois(
const pois: OverpassPoi[] = [];
for (const el of elements) {
const tags = el.tags || {};
const matched =
filters.find((f) => {
const [k, v] = f.split('=');
return tags[k] === v;
}) || filters[0];
const matched = filters.find((f) => tagsMatchFilter(tags, f)) || filters[0];
const category = categoryOfFilter.get(matched) ?? categories[0];
// `operator` comes last but matters for the road categories: petrol stations,
// charging points and service areas are routinely mapped with an operator and no
@@ -294,6 +306,9 @@ function readPois(
// statement in the hours field.
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
const charging = chargingOf(category, tags);
// A charger that does not say bicycles may charge is never passed off as an e-bike one.
if (category === 'ebike_charging' && !charging) continue;
const lat = el.lat ?? el.center?.lat;
const lng = el.lon ?? el.center?.lon;
if (lat == null || lng == null) continue;
@@ -317,10 +332,7 @@ function readPois(
// Only the plain Q-id form is passed on; anything else would be a lookup we
// would have to guess at.
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
// Only where it means something. Every POI carries `capacity` and `fee` for its
// own reasons (a restaurant's capacity is seats), so reading them as charging
// data anywhere else would be wrong on most of the map.
charging: category === 'charging' ? readChargingInfo(tags) : null,
charging,
source: 'openstreetmap',
});
}
@@ -762,12 +774,7 @@ export class OsmClient {
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${south},${west},${north},${east})`;
const selectors = filters
.map((f) => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
})
.join('\n');
const selectors = filters.map((f) => ` ${overpassSelector(f, box)};`).join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
@@ -39,6 +39,8 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
cafe: ['cafe'],
restaurant: ['restaurant'],
market: ['market'],
// BIK-07: only points tagged for bicycles; the explore pill's `charging` is for cars.
ebike_charging: ['ebike_charging'],
};
/** The most places of one kind the map search answers from each source. */
@@ -187,6 +189,14 @@ function answerRows(places: readonly TourPoi[], ranks: ReadonlyMap<string, numbe
return [...highlights, ...rest];
}
/** What an e-bike charging point states, as the answer carries it; nothing for any other place. */
function bikeChargingOf(poi: MapsPoi): Pick<TourPoi, 'charging'> {
const charging = poi.charging;
if (!charging || !('bicycle' in charging)) return {};
const { bicycle, sockets, capacity, fee, access } = charging;
return { charging: { bicycle, sockets, capacity, fee, access } };
}
function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined {
return asked.find((category) => MAPS_CATEGORIES[category].includes(mapsCategory));
}
@@ -221,6 +231,7 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
opening_hours: poi.opening_hours,
distance_m: Math.round(near.distanceM),
along_m: Math.round(near.alongM),
...bikeChargingOf(poi),
};
kept.set(poi.osm_id, place);
if (name) byName.set(name, [...namesakes, place]);
+1 -1
View File
@@ -239,7 +239,7 @@ export class ToursMcp {
@Tool({
name: 'search_tour_pois',
description:
'Search places along the saved line of a tour: what a hike, a run, a ride, a bike tour, a paddle tour, a ski and winter tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, for a ski and winter tour cafe and restaurant as well, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none; retry_after_s, when present, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
'Search places along the saved line of a tour: what a hike, a run, a ride, a bike tour, a paddle tour, a ski and winter tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, ebike_charging for the charging points of a bike tour, for a ski and winter tour cafe and restaurant as well, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. An ebike_charging place is one OpenStreetMap tags for bicycles (bicycle=designated or yes; a charger for cars alone never comes back) and carries charging: bicycle, sockets (type, count, kw), capacity, fee and access as OpenStreetMap states them, null or empty where it says nothing; whether it works or is free today is not known. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none; retry_after_s, when present, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. A tour saved with its points only (route_pending) has no line to search along yet. Nothing is added to the tour or the trip.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),