mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(tours): search places along a tour, server and contract (PLN-02)
POST /api/trips/:tripId/tours/poi-search looks for what a hike or a paddle tour stops for along a line: viewpoints, peaks, shelters, drinking water, food, toilets, picnic spots, campsites and sights, and on the water slipways and boat rental. The line comes in the body, so a draft not yet saved can be searched, thinned to at most 1000 points; the corridor is 50 m to 2 km wide. The line is cut into stretches as the road trip's corridor search does, each searched through the map search (the TREK places index first, OpenStreetMap after), three at a time; a long line is searched along its first twelve stretches and the answer says so (capped). The places kept lie within the corridor of the line itself, each once, nearest the start first, up to 60, each with its source, kind, address, website, phone and opening hours as the source has them, its distance straight across to the line (distance_m, never called a detour) and how far along it lies. A stretch that fails leaves the others' places and marks the answer partial; a cut answer is truncated. Nothing is written. It is a read, so anyone who sees the trip may ask. The map search's OSM filters gain those outdoor categories; the explore pill does not offer them. MCP: search_tour_pois, along a saved tour's line.
This commit is contained in:
@@ -367,6 +367,16 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
charging: ['amenity=charging_station'],
|
||||
rest_area: ['highway=rest_area', 'highway=services'],
|
||||
campsite: ['tourism=camp_site', 'tourism=caravan_site'],
|
||||
// What a hike 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'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourPoiSearchRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourPoiSearchDto extends createZodDto(tourPoiSearchRequestSchema) {}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository, TourLibraryRow } from '../../db/repositories/Tours.repository';
|
||||
import { TripMembershipService } from '../trip-membership/trip-membership.service';
|
||||
import { parseRouteGeometry } from './tours.helpers';
|
||||
import { parseRouteGeometry, thinnedLine } from './tours.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
@@ -40,18 +40,6 @@ function toItem(row: TourLibraryRow): TourLibraryItem {
|
||||
};
|
||||
}
|
||||
|
||||
/** Every `step`-th point of a line, its last point always kept, so a preview draws the whole tour lightly. */
|
||||
function thinned(line: Array<[number, number]>, most: number): Array<[number, number]> {
|
||||
if (line.length <= most) return line;
|
||||
const step = (line.length - 1) / (most - 1);
|
||||
return Array.from({ length: most }, (_, index) => Math.min(line.length - 1, Math.round(index * step))).flatMap(
|
||||
(index) => {
|
||||
const point = line[index];
|
||||
return point ? [point] : [];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* LIB-02: the tours of every other trip the user can open, to take one over
|
||||
* into the trip they are planning. Listing and previewing read only. Every
|
||||
@@ -97,7 +85,11 @@ export class TourLibraryService {
|
||||
const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId);
|
||||
if (!row) throw new NotFoundException('Tour not found');
|
||||
const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]);
|
||||
return { tour: toItem(row), line: thinned(line, TOUR_LIBRARY_PREVIEW_POINTS), waypoint_count: row.waypoint_count };
|
||||
return {
|
||||
tour: toItem(row),
|
||||
line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS),
|
||||
waypoint_count: row.waypoint_count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourPoiSearchDto } from './dto/tour-poi-search.dto';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { tourPoiSearchResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* PLN-02 POST /api/trips/:tripId/tours/poi-search: places along the line of
|
||||
* the tour being planned. A read, so anyone who sees the trip may ask; the
|
||||
* line comes in the body because the draft may not be saved yet.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourPoiSearchController {
|
||||
constructor(private readonly search: TourPoiSearchService) {}
|
||||
|
||||
@Post('poi-search')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(tourPoiSearchResponseSchema)
|
||||
async searchAlong(@Body() body: TourPoiSearchDto) {
|
||||
return await this.search.search(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { MapsService } from '../maps/maps.service';
|
||||
import { parseRouteGeometry, thinnedLine } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
nearnessToLine,
|
||||
plannableTourTypeSchema,
|
||||
TOUR_POI_LIMIT,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
type TourPoi,
|
||||
type TourPoiCategory,
|
||||
type TourPoiSearchRequest,
|
||||
type TourPoiSearchResponse,
|
||||
} from '@trek/shared';
|
||||
import { corridorTiles, simplifyLine } from '@trek/shared/roadtrip';
|
||||
|
||||
/** The map search's own categories each Tours category asks for. */
|
||||
const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
|
||||
viewpoint: ['viewpoint'],
|
||||
peak: ['peak'],
|
||||
shelter: ['shelter'],
|
||||
drinking_water: ['drinking_water'],
|
||||
food: ['restaurant', 'cafe'],
|
||||
toilets: ['toilets'],
|
||||
picnic: ['picnic'],
|
||||
campsite: ['campsite'],
|
||||
sights: ['sights'],
|
||||
slipway: ['slipway'],
|
||||
boat_rental: ['boat_rental'],
|
||||
};
|
||||
|
||||
/**
|
||||
* How many stretches of the line one search covers, each at most about half a
|
||||
* degree, so a long tour is searched along its first stretches and the answer
|
||||
* says so. Three are asked at a time, so the public mirrors are not flooded.
|
||||
*/
|
||||
const MAX_TILES = 12;
|
||||
const PARALLEL_TILES = 3;
|
||||
/** The most places one stretch is asked for. */
|
||||
const PER_TILE_LIMIT = 60;
|
||||
|
||||
function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined {
|
||||
return asked.find((category) => MAPS_CATEGORIES[category].includes(mapsCategory));
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour, searched on purpose. The line is cut into
|
||||
* stretches as the road trip's corridor search does, each searched through
|
||||
* the map search (the TREK places index first, OpenStreetMap after), the
|
||||
* places kept that lie within the corridor of the line itself, nearest the
|
||||
* start first. Nothing is written. A stretch that fails leaves the others'
|
||||
* places and marks the answer partial; the distance is measured straight
|
||||
* across to the line, never a way someone walked or paddled.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourPoiSearchService {
|
||||
constructor(
|
||||
private readonly maps: MapsService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
) {}
|
||||
|
||||
async search(body: TourPoiSearchRequest): Promise<TourPoiSearchResponse> {
|
||||
const corridorKm = body.corridor_m / 1000;
|
||||
// The contract's tuples, spelled out as pairs once for the measures below.
|
||||
const line = body.line.map((point): [number, number] => [Number(point[0]), Number(point[1])]);
|
||||
const simplified = simplifyLine(
|
||||
line.map(([lat, lng]) => ({ lat, lng })),
|
||||
Math.max(0.05, corridorKm / 4),
|
||||
);
|
||||
const tiles = corridorTiles(simplified, corridorKm);
|
||||
const searched = tiles.slice(0, MAX_TILES);
|
||||
const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))].join(',');
|
||||
|
||||
// Three stretches at a time, each batch after the last, so no more than three are ever in flight.
|
||||
const batches = Array.from({ length: Math.ceil(searched.length / PARALLEL_TILES) }, (_, index) =>
|
||||
searched.slice(index * PARALLEL_TILES, (index + 1) * PARALLEL_TILES),
|
||||
);
|
||||
const answers = await batches.reduce<
|
||||
Promise<Array<PromiseSettledResult<Awaited<ReturnType<MapsService['pois']>>>>>
|
||||
>(
|
||||
async (done, batch) => [
|
||||
...(await done),
|
||||
...(await Promise.allSettled(batch.map((tile) => this.maps.pois(asked, tile, body.lang, PER_TILE_LIMIT)))),
|
||||
],
|
||||
Promise.resolve([]),
|
||||
);
|
||||
|
||||
const found = new Map<string, TourPoi>();
|
||||
let truncated = false;
|
||||
for (const answer of answers) {
|
||||
if (answer.status !== 'fulfilled') continue;
|
||||
truncated ||= answer.value.truncated || answer.value.clamped;
|
||||
for (const poi of answer.value.pois) {
|
||||
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 || found.has(poi.osm_id)) continue;
|
||||
found.set(poi.osm_id, {
|
||||
id: poi.osm_id,
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
category,
|
||||
kind: poi.poi_type,
|
||||
source: poi.source ?? 'openstreetmap',
|
||||
address: poi.address,
|
||||
website: poi.website,
|
||||
phone: poi.phone,
|
||||
opening_hours: poi.opening_hours,
|
||||
distance_m: Math.round(near.distanceM),
|
||||
along_m: Math.round(near.alongM),
|
||||
});
|
||||
}
|
||||
}
|
||||
const pois = [...found.values()].sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m);
|
||||
return {
|
||||
pois: pois.slice(0, TOUR_POI_LIMIT),
|
||||
truncated: truncated || pois.length > TOUR_POI_LIMIT,
|
||||
partial: answers.some((answer) => answer.status === 'rejected'),
|
||||
capped: tiles.length > MAX_TILES,
|
||||
};
|
||||
}
|
||||
|
||||
/** The same search along a saved tour's line, for a client that has no draft (MCP). */
|
||||
async searchSavedTour(
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
request: Pick<TourPoiSearchRequest, 'categories' | 'corridor_m' | 'lang'>,
|
||||
): Promise<TourPoiSearchResponse> {
|
||||
const [tour, place] = await Promise.all([
|
||||
this.toursRepo.findInTrip(tripId, placeId),
|
||||
this.placesRepo.findInTrip(placeId, tripId),
|
||||
]);
|
||||
const line = parseRouteGeometry(place?.route_geometry).map((point): [number, number] => [point[0], point[1]]);
|
||||
const tourType = plannableTourTypeSchema.safeParse(tour?.tour_type);
|
||||
if (!tour || line.length < 2) throw new NotFoundException('Tour not found');
|
||||
return await this.search({
|
||||
...request,
|
||||
tour_type: tourType.success ? tourType.data : 'hike',
|
||||
line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,3 +69,18 @@ export function changedParts(before: string, now: string): Array<(typeof CONTENT
|
||||
const b = now.split('.');
|
||||
return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* At most `most` points of a line, evenly spread along it with its ends kept:
|
||||
* a preview or a search gets the whole shape without the thousands of points.
|
||||
*/
|
||||
export function thinnedLine<P>(line: readonly P[], most: number): P[] {
|
||||
if (line.length <= most) return [...line];
|
||||
const step = (line.length - 1) / (most - 1);
|
||||
return Array.from({ length: most }, (_, index) => Math.min(line.length - 1, Math.round(index * step))).flatMap(
|
||||
(index) => {
|
||||
const point = line[index];
|
||||
return point === undefined ? [] : [point];
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { idSchema, type TourListItem } from '@trek/shared';
|
||||
import { idSchema, tourPoiSearchRequestSchema, type TourListItem, type TourPoiSearchRequest } from '@trek/shared';
|
||||
|
||||
type TourDay = Pick<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
|
||||
|
||||
const poiFields = tourPoiSearchRequestSchema.shape;
|
||||
|
||||
/** A tour as a client reads it: its list facts and the days of the trip it is on. */
|
||||
function withDays(tour: TourListItem, days: ReadonlyMap<number, TourDay[]>) {
|
||||
return { ...tour, days: days.get(tour.place_id) ?? [] };
|
||||
@@ -32,6 +35,7 @@ export class ToursMcp {
|
||||
private readonly tours: ToursService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly gpxExport: TourGpxExportService,
|
||||
private readonly poiSearch: TourPoiSearchService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
@@ -122,4 +126,36 @@ export class ToursMcp {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_tour_pois',
|
||||
description:
|
||||
'Search places along the saved line of a tour: what a hike or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental), within corridor_m metres of the line. Answers each place with its source, 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), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. 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'),
|
||||
categories: poiFields.categories,
|
||||
corridor_m: poiFields.corridor_m,
|
||||
lang: poiFields.lang,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async searchTourPois(
|
||||
{
|
||||
tripId,
|
||||
placeId,
|
||||
...request
|
||||
}: { tripId: number; placeId: number } & Pick<TourPoiSearchRequest, 'categories' | 'corridor_m' | 'lang'>,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
try {
|
||||
return ok(await this.poiSearch.searchSavedTour(tripId, placeId, request));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('Tour not found.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { MapsModule } from '../maps/maps.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
@@ -20,6 +21,8 @@ import { TourObstacleReviewsService } from './tour-obstacle-reviews.service';
|
||||
import { PaddleCheckCache } from './tour-paddle-check.cache';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { TourPaddlePlanService } from './tour-paddle-plan.service';
|
||||
import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
@@ -44,6 +47,8 @@ import { Module } from '@nestjs/common';
|
||||
TripMembershipModule,
|
||||
// The take-over tool checks 'place_edit' on the trip it writes into (LIB-02).
|
||||
McpSharedModule,
|
||||
// PLN-02 searches places along a tour through the map search.
|
||||
MapsModule,
|
||||
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
@@ -54,6 +59,7 @@ import { Module } from '@nestjs/common';
|
||||
],
|
||||
controllers: [
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
ToursController,
|
||||
ToursImportController,
|
||||
ToursWandererController,
|
||||
@@ -68,6 +74,7 @@ import { Module } from '@nestjs/common';
|
||||
TourGpxExportService,
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
ToursMcp,
|
||||
TourLibraryMcp,
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user