mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +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:
@@ -57,6 +57,7 @@ export * from './place/track-colors';
|
||||
export * from './tours/tours.schema';
|
||||
export * from './tours/tour-provenance.schema';
|
||||
export * from './tours/tour-library.schema';
|
||||
export * from './tours/tour-poi-search.schema';
|
||||
export * from './tours/tours.metrics';
|
||||
export * from './tours/tour-gpx-import.schema';
|
||||
export * from './tours/paddle-plan.schema';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
TOUR_POI_CATEGORIES,
|
||||
TOUR_POI_CATEGORIES_BY_TYPE,
|
||||
tourPoiSearchRequestSchema,
|
||||
tourPoiSearchResponseSchema,
|
||||
} from './tour-poi-search.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const line = [
|
||||
[47.4, 11.1],
|
||||
[47.41, 11.12],
|
||||
];
|
||||
|
||||
describe('tour POI search contracts (PLN-02)', () => {
|
||||
it('offers each tour type categories of its own, every one a known category', () => {
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).toContain('viewpoint');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.kayak).toContain('slipway');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).not.toContain('slipway');
|
||||
for (const type of Object.values(TOUR_POI_CATEGORIES_BY_TYPE))
|
||||
for (const category of type) expect(TOUR_POI_CATEGORIES).toContain(category);
|
||||
});
|
||||
|
||||
it('takes a line, a corridor and up to six categories, and nothing else', () => {
|
||||
const ok = { tour_type: 'hike', categories: ['viewpoint', 'food'], corridor_m: 500, line };
|
||||
expect(tourPoiSearchRequestSchema.safeParse(ok).success).toBe(true);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, categories: [] }).success).toBe(false);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, categories: ['fuel'] }).success).toBe(false);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, corridor_m: 5000 }).success).toBe(false);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, line: [[47, 11]] }).success).toBe(false);
|
||||
expect(
|
||||
tourPoiSearchRequestSchema.safeParse({
|
||||
...ok,
|
||||
line: [
|
||||
[91, 11],
|
||||
[47, 11],
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'bike' }).success).toBe(false);
|
||||
expect(tourPoiSearchRequestSchema.safeParse({ ...ok, add: true }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('answers places with where they lie against the line, opening hours nullable', () => {
|
||||
const answer = tourPoiSearchResponseSchema.parse({
|
||||
pois: [
|
||||
{
|
||||
id: 'node:1',
|
||||
name: 'Spring',
|
||||
lat: 47.4,
|
||||
lng: 11.1,
|
||||
category: 'drinking_water',
|
||||
kind: 'amenity=drinking_water',
|
||||
source: 'openstreetmap',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
distance_m: 40,
|
||||
along_m: 1200,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
partial: true,
|
||||
capped: false,
|
||||
});
|
||||
expect(answer.partial).toBe(true);
|
||||
expect(answer.pois[0]?.distance_m).toBe(40);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { plannableTourTypeSchema, type PlannableTourType } from './tours.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour, searched on purpose. The search reads only:
|
||||
* it changes no tour, place or day. Distances are how far a place lies from
|
||||
* the line as drawn, never a detour someone walked or paddled.
|
||||
*/
|
||||
|
||||
/** What can be looked for along a tour on foot or on the water. */
|
||||
export const TOUR_POI_CATEGORIES = [
|
||||
'viewpoint',
|
||||
'peak',
|
||||
'shelter',
|
||||
'drinking_water',
|
||||
'food',
|
||||
'toilets',
|
||||
'picnic',
|
||||
'campsite',
|
||||
'sights',
|
||||
'slipway',
|
||||
'boat_rental',
|
||||
] as const;
|
||||
export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES);
|
||||
export type TourPoiCategory = z.infer<typeof tourPoiCategorySchema>;
|
||||
|
||||
/** The categories each planned tour type offers, the most asked first. */
|
||||
export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly TourPoiCategory[]> = {
|
||||
hike: ['viewpoint', 'shelter', 'drinking_water', 'food', 'peak', 'toilets', 'picnic', 'campsite', 'sights'],
|
||||
kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'],
|
||||
};
|
||||
|
||||
/** How far from the line a place may lie, the choices offered, in metres. */
|
||||
export const TOUR_POI_CORRIDORS_M = [250, 500, 1000, 2000] as const;
|
||||
/** The most places one answer carries, nearest the start first. */
|
||||
export const TOUR_POI_LIMIT = 60;
|
||||
/** The most points the line sent may have; a longer line is thinned first. */
|
||||
export const TOUR_POI_MAX_LINE_POINTS = 1000;
|
||||
/** The most categories one search asks for. */
|
||||
export const TOUR_POI_MAX_CATEGORIES = 6;
|
||||
|
||||
export const tourPoiSearchRequestSchema = z
|
||||
.object({
|
||||
tour_type: plannableTourTypeSchema,
|
||||
categories: z.array(tourPoiCategorySchema).min(1).max(TOUR_POI_MAX_CATEGORIES),
|
||||
corridor_m: z.number().int().min(50).max(2000),
|
||||
line: z
|
||||
.array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)]))
|
||||
.min(2)
|
||||
.max(TOUR_POI_MAX_LINE_POINTS),
|
||||
lang: z.string().trim().min(2).max(10).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type TourPoiSearchRequest = z.infer<typeof tourPoiSearchRequestSchema>;
|
||||
|
||||
/** One place found: what the source says about it, and where it lies against the line. */
|
||||
export const tourPoiSchema = z.object({
|
||||
/** The source's own id, `node:123` style for OpenStreetMap. */
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
category: tourPoiCategorySchema,
|
||||
/** The source's own kind for it, `amenity=drinking_water` style. */
|
||||
kind: z.string(),
|
||||
/** Which index answered: OpenStreetMap, or TREK's own places index. */
|
||||
source: z.string(),
|
||||
address: z.string().nullable(),
|
||||
website: z.string().nullable(),
|
||||
phone: z.string().nullable(),
|
||||
/** As the source has them; a hint to check, never a promise. */
|
||||
opening_hours: z.string().nullable(),
|
||||
/** Metres from the line, straight across. */
|
||||
distance_m: z.number().nonnegative(),
|
||||
/** Metres along the line from its start to the point nearest the place. */
|
||||
along_m: z.number().nonnegative(),
|
||||
});
|
||||
export type TourPoi = z.infer<typeof tourPoiSchema>;
|
||||
|
||||
export const tourPoiSearchResponseSchema = z.object({
|
||||
pois: z.array(tourPoiSchema),
|
||||
/** More places matched than the answer carries. */
|
||||
truncated: z.boolean(),
|
||||
/** Some stretches of the line could not be searched; the places of the others are there. */
|
||||
partial: z.boolean(),
|
||||
/** The line was longer than one search covers; only its first part was searched. */
|
||||
capped: z.boolean(),
|
||||
});
|
||||
export type TourPoiSearchResponse = z.infer<typeof tourPoiSearchResponseSchema>;
|
||||
Reference in New Issue
Block a user