fix(tours): one summit per mountain in the places along a tour

The index holds a mountain as many pins named by whoever listed it, at its
foot as much as on top, so a hike up Mt. Takao listed it five times under
names like Mt.Takao, 東京都 高尾山 and 高尾山 (Mt. Takao). Rows that are one
place now merge as a group: like names in one spot for every kind, and for a
summit of the index like names anywhere on the mountain, linked through a
name that carries both scripts. The row kept is an OpenStreetMap object over
an index pin, then the plainest name. Two OpenStreetMap summits stay two.
This commit is contained in:
Maurice
2026-10-24 13:54:48 +02:00
parent 1be997f7d8
commit 736594696e
2 changed files with 92 additions and 14 deletions
@@ -66,10 +66,6 @@ function metresApart(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: num
return Math.hypot(dLat, dLng);
}
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
return metresApart(a, b) <= SAME_PLACE_M;
}
/** A name as compared: without case, accents or punctuation. */
function nameKey(name: string): string {
return name
@@ -202,21 +198,82 @@ function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[])
}
/**
* The places found, each once and within the corridor, with where they lie.
* OpenStreetMap often maps one place twice, as a point and as its outline, and
* the index may hold it beside OpenStreetMap: one name in one spot is one
* place. An unnamed place (a tap, a bench) is never merged with another.
* Within this many metres, a summit of the index and another of a like name
* are one mountain. The index holds a mountain as many pins, placed by whoever
* listed it, at its foot as much as on top, so they lie a mountain's breadth
* apart. Two OpenStreetMap summits are two peaks, as the Watzmann and the
* Kleiner Watzmann are.
*/
const SAME_MOUNTAIN_M = 3000;
/**
* Scripts written without spaces between words: there one name inside another
* is a word of it, as 高尾山 is of 八王子高尾山, where in Latin "Dom" is not
* "Domplatz".
*/
const UNSPACED_SCRIPT = /^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]{2,}$/u;
/** {@link similarNames}, or one word written without spaces inside the other name. */
function likeNames(a: string, b: string): boolean {
if (similarNames(a, b)) return true;
const inside = (word: string, name: string) => UNSPACED_SCRIPT.test(word) && name.replaceAll(' ', '').includes(word);
return a.split(' ').some((word) => inside(word, b)) || b.split(' ').some((word) => inside(word, a));
}
/**
* Two rows that are one place: the same name in one spot, as OpenStreetMap
* maps a place twice, as a point and as its outline, or the index holds it
* beside OpenStreetMap; a like name in one spot; or, for a summit of the
* index, a like name anywhere on the mountain. An unnamed place (a tap, a
* bench) is never one with another.
*/
function samePlace(a: TourPoi, b: TourPoi): boolean {
const name = nameKey(a.name);
const other = nameKey(b.name);
if (!name || !other || a.category !== b.category) return false;
const apart = metresApart(a, b);
const pinned = a.source !== 'openstreetmap' || b.source !== 'openstreetmap';
if (a.category === 'peak' && pinned && apart <= SAME_MOUNTAIN_M) return likeNames(name, other);
return apart <= SAME_PLACE_M && (name === other || likeNames(name, other));
}
/**
* The row a place mapped several times keeps: an OpenStreetMap object over an
* index pin, then the plainest name ("高尾山" rather than "Top Of Mt.Takao"),
* then the first found.
*/
function keptOf(rows: readonly TourPoi[]): TourPoi {
const plainness = (row: TourPoi) => [row.source === 'openstreetmap' ? 0 : 1, nameKey(row.name).length];
return rows.reduce((best, row) => {
const [a, b] = [plainness(row), plainness(best)];
return a[0] < b[0] || (a[0] === b[0] && a[1] < b[1]) ? row : best;
});
}
/**
* The rows that are one place, each once: a row joins every group it is one
* place with, so a bridge like "高尾山 (Mt. Takao)" joins "Mt.Takao" and
* "高尾山" although those two share no word.
*/
function oncePerPlace(rows: readonly TourPoi[]): TourPoi[] {
const groups: TourPoi[][] = [];
for (const row of rows) {
const joined = groups.filter((group) => group.some((other) => samePlace(row, other)));
const merged = [...joined.flat(), row];
groups.splice(0, groups.length, ...groups.filter((group) => !joined.includes(group)), merged);
}
const kept = new Set(groups.map(keptOf));
// In the order found, so the first of equals stays first.
return rows.filter((row) => kept.has(row));
}
/** The places found, each once and within the corridor, with where they lie. */
function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<[number, number]>): TourPoi[] {
const kept = new Map<string, TourPoi>();
const byName = new Map<string, TourPoi[]>();
for (const poi of found) {
const category = tourCategoryOf(poi.category, body.categories);
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
if (!category || !near || near.distanceM > body.corridor_m || kept.has(poi.osm_id)) continue;
const name = poi.name.trim().toLocaleLowerCase();
const namesakes = name ? (byName.get(name) ?? []) : [];
if (namesakes.some((other) => withinSamePlace(other, poi))) continue;
const place: TourPoi = {
id: poi.osm_id,
name: poi.name,
@@ -234,9 +291,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
...bikeChargingOf(poi),
};
kept.set(poi.osm_id, place);
if (name) byName.set(name, [...namesakes, place]);
}
return [...kept.values()];
return oncePerPlace([...kept.values()]);
}
/**
@@ -139,6 +139,28 @@ describe('TourPoiSearchService', () => {
expect(result.pois.map((found) => found.id)).toEqual(['node:9', 'node:10']);
});
it('TOUR-POI-019: the pins of one mountain under several names are one summit, two mapped summits stay two', async () => {
const pin = (id: string, name: string, lat: number) =>
poi(id, lat, 11.1003, 'peak', { name, source: 'trek-places', poi_type: 'mountain' });
poisAlong.mockResolvedValue(
answer([
// Pins at the foot of the mountain and on top, named in two scripts and bridged by one that has both.
pin('gers:1', 'Mt.Takao', 47.401),
pin('gers:2', '東京都 高尾山', 47.4011),
pin('gers:3', '高尾山 (Mt. Takao)', 47.403),
pin('gers:4', '八王子高尾山', 47.405),
pin('gers:5', '高尾山', 47.419),
pin('gers:6', 'Top Of Mt.Takao', 47.4191),
// Another mountain on the way, and two summits of OpenStreetMap a kilometre apart.
pin('gers:7', 'Mt.Kagenobu', 47.41),
poi('node:1', 47.406, 11.1003, 'peak', { name: 'Watzmann' }),
poi('node:2', 47.415, 11.1003, 'peak', { name: 'Kleiner Watzmann' }),
]),
);
const result = await service.search({ tour_type: 'hike', categories: ['peak'], corridor_m: 500, line: LINE });
expect(result.pois.map((found) => found.name)).toEqual(['Watzmann', 'Mt.Kagenobu', 'Kleiner Watzmann', '高尾山']);
});
it('TOUR-POI-002: asks the map search along the line for what each category means, and names a hit by the category asked', async () => {
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
const result = await service.search({