mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(tours): bike tours in the contract and on the server (BIK-01 to BIK-06, RTE-01)
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.
This commit is contained in:
@@ -38,6 +38,7 @@ export class Tours {
|
||||
hiking_preferences?: string | null;
|
||||
taken_over_from_trip_title?: string | null;
|
||||
taken_over_from_tour_name?: string | null;
|
||||
bike_profile?: TToursBikeProfile | null;
|
||||
tourTypeRef!: Ref<TourTypes>;
|
||||
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
|
||||
tour_sources: Ref<TourSources> | null = null;
|
||||
@@ -75,6 +76,15 @@ export const ToursPaddleLakeStyle = {
|
||||
|
||||
export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle];
|
||||
|
||||
export const ToursBikeProfile = {
|
||||
TOURING: 'touring',
|
||||
ROAD: 'road',
|
||||
GRAVEL: 'gravel',
|
||||
MTB: 'mtb',
|
||||
} as const;
|
||||
|
||||
export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile];
|
||||
|
||||
export const ToursSchema = defineEntity({
|
||||
class: Tours,
|
||||
repository: () => ToursRepository,
|
||||
@@ -116,6 +126,7 @@ export const ToursSchema = defineEntity({
|
||||
hiking_preferences: p.text().nullable(),
|
||||
taken_over_from_trip_title: p.text().nullable(),
|
||||
taken_over_from_tour_name: p.text().nullable(),
|
||||
bike_profile: p.enum(() => ToursBikeProfile).nullable(),
|
||||
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
|
||||
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
|
||||
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
|
||||
|
||||
@@ -30,4 +30,5 @@ export interface ToursTable {
|
||||
hiking_preferences: InsertOptional<string | null>;
|
||||
taken_over_from_trip_title: InsertOptional<string | null>;
|
||||
taken_over_from_tour_name: InsertOptional<string | null>;
|
||||
bike_profile: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* BIK-01: a tour can be a bike tour. The `bike` key has been in `tour_types`,
|
||||
* disabled, since every contract type was seeded; it is switched on here. The
|
||||
* profile it is ridden on (touring, road, gravel, mtb) is a column of the
|
||||
* tour, NULL on every other type, so no existing tour changes its meaning.
|
||||
*/
|
||||
export class Migration20200101045200_a_tour_can_be_ridden extends Migration {
|
||||
override name = 'Migration20200101045200_a_tour_can_be_ridden';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(
|
||||
"ALTER TABLE tours ADD COLUMN bike_profile TEXT CHECK (bike_profile IS NULL OR bike_profile IN ('touring', 'road', 'gravel', 'mtb'))",
|
||||
);
|
||||
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'bike'");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
|
||||
import type { DB } from '../kysely/db';
|
||||
import type { AssertRowKeys } from './_shared/rows';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
import type { PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
|
||||
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* A tour as the Tours list and detail read it: the `tours` facet joined with
|
||||
@@ -28,6 +28,8 @@ export interface TourListRow {
|
||||
max_hiking_difficulty: number | null;
|
||||
derived_from_place_id: number | null;
|
||||
origin: string;
|
||||
/** BIK-01: a bike tour's profile; NULL on every other type. */
|
||||
bike_profile: string | null;
|
||||
boat_type: string | null;
|
||||
water_context: string | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -92,6 +94,7 @@ export interface TourRow {
|
||||
hiking_preferences: string | null;
|
||||
taken_over_from_trip_title: string | null;
|
||||
taken_over_from_tour_name: string | null;
|
||||
bike_profile: string | null;
|
||||
}
|
||||
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
|
||||
|
||||
@@ -107,6 +110,8 @@ export interface TourInsert {
|
||||
break_additional_minutes?: number | null;
|
||||
match_confidence: number | null;
|
||||
max_hiking_difficulty: number | null;
|
||||
/** BIK-01: a bike tour's profile; left out, NULL. */
|
||||
bike_profile?: BikeProfile | null;
|
||||
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
@@ -123,6 +128,7 @@ export type TourUpdate = Omit<
|
||||
| 'place_id'
|
||||
| 'planned_duration_minutes'
|
||||
| 'break_additional_minutes'
|
||||
| 'bike_profile'
|
||||
| 'boat_type'
|
||||
| 'water_context'
|
||||
| 'paddle_speed_kmh'
|
||||
@@ -131,6 +137,7 @@ export type TourUpdate = Omit<
|
||||
> & {
|
||||
planned_duration_minutes?: number | null;
|
||||
break_additional_minutes?: number | null;
|
||||
bike_profile: BikeProfile | null;
|
||||
boat_type: PaddleBoatType | null;
|
||||
water_context: PaddleWaterContext | null;
|
||||
paddle_speed_kmh: number | null;
|
||||
@@ -232,6 +239,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
't.max_hiking_difficulty as max_hiking_difficulty',
|
||||
't.derived_from_place_id as derived_from_place_id',
|
||||
't.origin as origin',
|
||||
't.bike_profile as bike_profile',
|
||||
't.boat_type as boat_type',
|
||||
't.water_context as water_context',
|
||||
't.paddle_speed_kmh as paddle_speed_kmh',
|
||||
@@ -585,6 +593,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
break_additional_minutes: input.break_additional_minutes ?? null,
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.bike_profile ?? null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -669,6 +678,7 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
: {}),
|
||||
match_confidence: input.match_confidence,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.bike_profile,
|
||||
boat_type: input.boat_type,
|
||||
water_context: input.water_context,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh,
|
||||
|
||||
@@ -328,6 +328,11 @@ export function applyGlobalMiddleware(
|
||||
// OpenHikingMap, the hiking base map of the Tours planner (MAP-01), loaded only
|
||||
// while a user shows it and the admin allows it; GL reads it through fetch.
|
||||
'https://tile.openmaps.fr',
|
||||
// CyclOSM, the cycling base map of a bike tour in the Tours planner (BIK-06), on the
|
||||
// same terms; its three shards are named, so no wildcard opens the whole domain.
|
||||
'https://a.tile-cyclosm.openstreetmap.fr',
|
||||
'https://b.tile-cyclosm.openstreetmap.fr',
|
||||
'https://c.tile-cyclosm.openstreetmap.fr',
|
||||
// Amap's raster tiles, for an install whose users are in China, for the
|
||||
// same reason as the hosts above (#2180). Road (webrd01..04) and
|
||||
// satellite (webst01..04) are numbered shards of one domain, so the
|
||||
|
||||
@@ -91,6 +91,16 @@ export class AddonsService {
|
||||
return this.writeFlag('tours_hiking_map_enabled', enabled);
|
||||
}
|
||||
|
||||
/** BIK-06: the admin's switch for CyclOSM under Tours, as stored; fail-open like OpenHikingMap's. */
|
||||
async getToursCycleMap() {
|
||||
const value = await readAppSetting(this.appSettings, 'tours_cycle_map_enabled');
|
||||
return { enabled: value !== 'false' };
|
||||
}
|
||||
|
||||
async updateToursCycleMap(enabled: boolean) {
|
||||
return this.writeFlag('tours_cycle_map_enabled', enabled);
|
||||
}
|
||||
|
||||
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
|
||||
async isWandererAvailable(): Promise<boolean> {
|
||||
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
|
||||
@@ -177,6 +187,7 @@ export class AddonsService {
|
||||
bagTracking: (await this.getBagTracking()).enabled,
|
||||
toursWanderer: await this.isWandererAvailable(),
|
||||
toursHikingMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursHikingMap()).enabled,
|
||||
toursCycleMap: (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursCycleMap()).enabled,
|
||||
addons: [
|
||||
// The repository row carries every scalar column (description, config,
|
||||
// sort_order included); the legacy statement selected only these five,
|
||||
|
||||
@@ -323,6 +323,25 @@ export class AdminController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('tours-cycle-map')
|
||||
@ResponseContract(adminFeatureToggleResponseSchema)
|
||||
getToursCycleMap() {
|
||||
return this.addons.getToursCycleMap();
|
||||
}
|
||||
|
||||
@Put('tours-cycle-map')
|
||||
@ResponseContract(adminFeatureToggleResponseSchema)
|
||||
async updateToursCycleMap(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateToursCycleMap(body.enabled);
|
||||
await this.audit.writeAudit({
|
||||
userId: user.id,
|
||||
action: 'admin.tours_cycle_map',
|
||||
ip: getClientIp(req),
|
||||
details: { enabled: result.enabled },
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('places-photos')
|
||||
getPlacesPhotos() {
|
||||
return this.addons.getPlacesPhotos();
|
||||
|
||||
@@ -112,6 +112,10 @@ export const APP_SETTINGS = {
|
||||
// the installs TREK is made for; an admin whose install is commercial or larger switches it off
|
||||
// until there is an agreement with the operator (openmaps.fr/tile-usage-policy.html).
|
||||
tours_hiking_map_enabled: { schema: flag, default: 'true' },
|
||||
// BIK-06: CyclOSM as a base map of a bike tour in the Tours planner, on the same terms: its
|
||||
// tiles are OpenStreetMap France's, free for moderate use with a Referer; a heavy or commercial
|
||||
// install switches it off (or reaches an agreement) under the operator's usage policy.
|
||||
tours_cycle_map_enabled: { schema: flag, default: 'true' },
|
||||
route_usage_enabled: { schema: flag, default: 'false' },
|
||||
// PAD-11C: how TREK gets the water data of a place a paddler plans in (auto, ask, off) and the largest extract it loads by itself.
|
||||
paddle_auto_load: { schema: z.enum(['auto', 'ask', 'off']), default: 'auto' },
|
||||
|
||||
@@ -202,11 +202,6 @@ type WayElement = z.infer<typeof wayElementSchema>;
|
||||
type RelationElement = z.infer<typeof relationElementSchema>;
|
||||
|
||||
/** What an Overpass mirror answers with, at the boundary. */
|
||||
export const overpassFerrataAnswerSchema = z.object({
|
||||
elements: z.array(z.unknown()).max(50000),
|
||||
remark: z.string().optional(),
|
||||
});
|
||||
|
||||
function cleanName(value: string | undefined): string | null {
|
||||
const name = value?.trim() ?? '';
|
||||
return name === '' ? null : name.slice(0, NAME_MAX);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FerrataTiles } from '../../db/entities/FerrataTiles.entity';
|
||||
import { OverpassModule } from '../overpass/overpass.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { FerratasOverpassClient } from './ferratas-overpass.client';
|
||||
import { FerratasController } from './ferratas.controller';
|
||||
import { FerratasService } from './ferratas.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
@@ -8,14 +8,14 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Via ferratas around a trip, read from OpenStreetMap through Overpass.
|
||||
* PermissionsModule brings TripAccessGuard; RuntimeEnvService (the Overpass
|
||||
* endpoints and deadline) comes from the global AppConfigModule. The answers
|
||||
* PermissionsModule brings TripAccessGuard; OverpassModule the client that asks
|
||||
* the operator's Overpass mirrors. The answers
|
||||
* are kept per grid cell in `ferrata_tiles`; UnitOfWork and MikroORM come from
|
||||
* the global OrmModule.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PermissionsModule, MikroOrmModule.forFeature([FerrataTiles])],
|
||||
imports: [PermissionsModule, OverpassModule, MikroOrmModule.forFeature([FerrataTiles])],
|
||||
controllers: [FerratasController],
|
||||
providers: [FerratasService, FerratasOverpassClient],
|
||||
providers: [FerratasService],
|
||||
})
|
||||
export class FerratasModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logWarn } from '../audit/audit-log.logger';
|
||||
import { badRequest, DomainError } from '../common/domain-error';
|
||||
import { withRequestContext } from '../database/request-context';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { FerratasOverpassClient } from './ferratas-overpass.client';
|
||||
import { OverpassElementsClient } from '../overpass/overpass-elements.client';
|
||||
import {
|
||||
buildFerrataQuery,
|
||||
ferrataTileBlocks,
|
||||
@@ -98,7 +98,7 @@ export class FerratasService {
|
||||
private readonly waiting: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
private readonly overpass: FerratasOverpassClient,
|
||||
private readonly overpass: OverpassElementsClient,
|
||||
@InjectRepository(FerrataTiles) private readonly tiles: FerrataTilesRepository,
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly orm: MikroORM,
|
||||
@@ -242,7 +242,7 @@ export class FerratasService {
|
||||
private async queryOverpass(keys: readonly string[]): Promise<unknown[]> {
|
||||
await this.turn();
|
||||
try {
|
||||
return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)));
|
||||
return await this.overpass.query(buildFerrataQuery(ferrataTilesBbox(keys)), 'Ferratas');
|
||||
} catch {
|
||||
// The client already logged why each mirror failed.
|
||||
throw new DomainError(502, FERRATAS_UNAVAILABLE_MESSAGE, {
|
||||
|
||||
@@ -367,8 +367,8 @@ 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.
|
||||
// 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'],
|
||||
@@ -377,6 +377,7 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
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);
|
||||
|
||||
+16
-7
@@ -3,12 +3,14 @@ import { readCappedJson } from '../../utils/cappedFetch';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { buildUserAgent, parseOverpassEndpoints } from '../maps/overpass.constants';
|
||||
import { overpassFerrataAnswerSchema } from './ferratas.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* The outbound half of the via ferrata lookup: one Overpass query raced across
|
||||
* the mirrors the operator configured.
|
||||
* One Overpass query raced across the mirrors the operator configured, for the
|
||||
* domains that read OpenStreetMap elements themselves: the via ferratas around
|
||||
* a trip, the trail grades along a bike tour (BIK-05).
|
||||
*
|
||||
* It reads the same settings the maps domain's Overpass client reads
|
||||
* (OVERPASS_URL, a comma-separated list that replaces the public mirrors, and
|
||||
@@ -22,6 +24,12 @@ import { Injectable } from '@nestjs/common';
|
||||
// megabytes means the mirror is misbehaving, not that the mountains got bigger.
|
||||
const MAX_ANSWER_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** An Overpass answer: its elements, and a remark when Overpass gave up on the query. */
|
||||
export const overpassAnswerSchema = z.object({
|
||||
elements: z.array(z.unknown()).max(50000),
|
||||
remark: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Why every mirror failed, one reason per mirror, for the log line. */
|
||||
function reasons(err: unknown): string {
|
||||
const all = err instanceof AggregateError ? err.errors : [err];
|
||||
@@ -29,7 +37,7 @@ function reasons(err: unknown): string {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FerratasOverpassClient {
|
||||
export class OverpassElementsClient {
|
||||
constructor(private readonly env: RuntimeEnvService) {}
|
||||
|
||||
/**
|
||||
@@ -37,8 +45,9 @@ export class FerratasOverpassClient {
|
||||
* document. A `remark` (Overpass' own timeout or runtime error, sent with a
|
||||
* 200) counts as a failure, so a fast but empty answer cannot win the race.
|
||||
* Throws once every mirror failed; the caller decides what the client sees.
|
||||
* `caller` names the domain in the log line.
|
||||
*/
|
||||
async query(query: string): Promise<unknown[]> {
|
||||
async query(query: string, caller: string): Promise<unknown[]> {
|
||||
const { overpassUrl, overpassTimeoutMs } = this.env.env().integrations;
|
||||
const endpoints = parseOverpassEndpoints(overpassUrl);
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
@@ -52,7 +61,7 @@ export class FerratasOverpassClient {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
}
|
||||
const answer = overpassFerrataAnswerSchema.safeParse(await readCappedJson<unknown>(res, MAX_ANSWER_BYTES));
|
||||
const answer = overpassAnswerSchema.safeParse(await readCappedJson<unknown>(res, MAX_ANSWER_BYTES));
|
||||
if (!answer.success) throw new Error(`Overpass non-OSM or oversized body @ ${url}`);
|
||||
if (answer.data.remark) throw new Error(`Overpass remark @ ${url}: ${answer.data.remark}`);
|
||||
return answer.data.elements;
|
||||
@@ -61,7 +70,7 @@ export class FerratasOverpassClient {
|
||||
try {
|
||||
return await Promise.any(endpoints.map(attempt));
|
||||
} catch (err) {
|
||||
logWarn(`[Ferratas] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`);
|
||||
logWarn(`[${caller}] all ${endpoints.length} Overpass endpoint(s) failed: ${reasons(err)}`);
|
||||
throw new Error('Could not reach any Overpass endpoint', { cause: err });
|
||||
} finally {
|
||||
// The losers are cancelled once one mirror answered (or all gave up).
|
||||
@@ -0,0 +1,13 @@
|
||||
import { OverpassElementsClient } from './overpass-elements.client';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* The Overpass client shared by the domains that read OpenStreetMap elements
|
||||
* themselves. RuntimeEnvService (the mirrors and the deadline) comes from the
|
||||
* global AppConfigModule.
|
||||
*/
|
||||
@Module({
|
||||
providers: [OverpassElementsClient],
|
||||
exports: [OverpassElementsClient],
|
||||
})
|
||||
export class OverpassModule {}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourTrailGradeRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourTrailGradeDto extends createZodDto(tourTrailGradeRequestSchema) {}
|
||||
@@ -9,7 +9,7 @@ import { toRowId } from '../common/row-id';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { PaddleBoatType } from '@trek/shared';
|
||||
import type { BikeProfile, PaddleBoatType } from '@trek/shared';
|
||||
|
||||
/** What GPX calls the activity in a track's <type>; a paddle tour says what it is paddled in. */
|
||||
const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
|
||||
@@ -19,8 +19,18 @@ const PADDLE_ACTIVITY: Record<PaddleBoatType, string> = {
|
||||
other: 'paddling',
|
||||
};
|
||||
|
||||
function gpxActivityOf(type: string, boat: string | null): string {
|
||||
/** A bike tour says what it is ridden on, in the words Garmin and Strava read. */
|
||||
const BIKE_ACTIVITY: Record<BikeProfile, string> = {
|
||||
touring: 'cycling',
|
||||
road: 'road_biking',
|
||||
gravel: 'gravel_cycling',
|
||||
mtb: 'mountain_biking',
|
||||
};
|
||||
|
||||
function gpxActivityOf(tour: { tour_type: string; boat_type?: string | null; bike_profile?: string | null }): string {
|
||||
const { tour_type: type, boat_type: boat, bike_profile: bike } = tour;
|
||||
if (type === 'kayak') return PADDLE_ACTIVITY[(boat ?? 'other') as PaddleBoatType] ?? PADDLE_ACTIVITY.other;
|
||||
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
|
||||
return type === 'hike' ? 'hiking' : type;
|
||||
}
|
||||
|
||||
@@ -51,7 +61,7 @@ export class TourGpxExportService {
|
||||
name: tour.name,
|
||||
description: tour.description ?? null,
|
||||
website: tour.website ?? null,
|
||||
activity: gpxActivityOf(tour.tour_type, tour.boat_type),
|
||||
activity: gpxActivityOf(tour),
|
||||
route_geometry: place.route_geometry ?? null,
|
||||
stops,
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
|
||||
sights: ['sights'],
|
||||
slipway: ['slipway'],
|
||||
boat_rental: ['boat_rental'],
|
||||
bike_repair: ['bike_repair'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 { TourTrailGradeDto } from './dto/tour-trail-grade.dto';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
|
||||
import { tourTrailGradeResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* BIK-05 POST /api/trips/:tripId/tours/trail-grades: the trail grades of the
|
||||
* ways a mountain bike tour runs on. A read, so anyone who sees the trip may
|
||||
* ask; the ways come 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 TourTrailGradeController {
|
||||
constructor(private readonly trailGrades: TourTrailGradeService) {}
|
||||
|
||||
@Post('trail-grades')
|
||||
@HttpCode(200)
|
||||
@ResponseContract(tourTrailGradeResponseSchema)
|
||||
async grades(@Body() body: TourTrailGradeDto) {
|
||||
return await this.trailGrades.grades(body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { OverpassElementsClient } from '../overpass/overpass-elements.client';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { TourTrailGrade, TourTrailGradeRequest, TourTrailGradeResponse } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Ways per Overpass question: a query names them all, and a shorter one answers sooner. */
|
||||
const WAYS_PER_QUERY = 400;
|
||||
/** How long a way's grades are kept: OpenStreetMap changes slowly, the mirrors are shared. */
|
||||
const KEPT_MS = 24 * 60 * 60 * 1000;
|
||||
/** The most ways kept at once; the oldest go first. */
|
||||
const MAX_KEPT = 50_000;
|
||||
|
||||
const wayElementSchema = z.object({
|
||||
type: z.literal('way'),
|
||||
id: z.number().int().positive(),
|
||||
tags: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/** A tag's value as a step of its scale (`S2` and ` 2 ` read as 2), or null when it is none. */
|
||||
function step(value: string | undefined, top: number, signed: boolean): string | null {
|
||||
const match = /^\s*S?([0-9])([+-])?\s*$/i.exec(value ?? '');
|
||||
if (!match || Number(match[1]) > top) return null;
|
||||
return signed ? `${match[1]}${match[2] ?? ''}` : (match[1] ?? null);
|
||||
}
|
||||
|
||||
function gradeOf(id: number, tags: Record<string, string> = {}): TourTrailGrade | null {
|
||||
const grade = {
|
||||
id,
|
||||
mtb_scale: step(tags['mtb:scale'], 6, true),
|
||||
mtb_scale_uphill: step(tags['mtb:scale:uphill'], 5, true),
|
||||
mtb_scale_imba: step(tags['mtb:scale:imba'], 4, false),
|
||||
};
|
||||
return grade.mtb_scale || grade.mtb_scale_uphill || grade.mtb_scale_imba ? grade : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BIK-05: the trail grades of the ways a mountain bike tour runs on, read from
|
||||
* OpenStreetMap through the operator's Overpass mirrors. What was read is kept
|
||||
* a day, so the same tour asked about again costs the mirrors nothing; ways a
|
||||
* mirror could not answer are said, never guessed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourTrailGradeService {
|
||||
private readonly kept = new Map<number, { grade: TourTrailGrade | null; at: number }>();
|
||||
|
||||
constructor(private readonly overpass: OverpassElementsClient) {}
|
||||
|
||||
async grades(request: TourTrailGradeRequest): Promise<TourTrailGradeResponse> {
|
||||
const now = Date.now();
|
||||
const ids = [...new Set(request.way_ids)];
|
||||
const unknown = ids.filter((id) => {
|
||||
const known = this.kept.get(id);
|
||||
return !known || now - known.at > KEPT_MS;
|
||||
});
|
||||
const parts = Array.from({ length: Math.ceil(unknown.length / WAYS_PER_QUERY) }, (_, index) =>
|
||||
unknown.slice(index * WAYS_PER_QUERY, (index + 1) * WAYS_PER_QUERY),
|
||||
);
|
||||
// One question after another, so a long tour does not flood the shared mirrors.
|
||||
const unanswered = await parts.reduce<Promise<number[]>>(async (done, part) => {
|
||||
const failed = await done;
|
||||
return (await this.ask(part, now)) ? failed : [...failed, ...part];
|
||||
}, Promise.resolve([]));
|
||||
const ways = ids.flatMap((id) => {
|
||||
const grade = this.kept.get(id)?.grade;
|
||||
return grade ? [grade] : [];
|
||||
});
|
||||
return { ways, unanswered };
|
||||
}
|
||||
|
||||
/** Reads one part's grades into what is kept; false when no mirror answered. */
|
||||
private async ask(ids: number[], now: number): Promise<boolean> {
|
||||
let elements: unknown[];
|
||||
try {
|
||||
elements = await this.overpass.query(`[out:json][timeout:25];way(id:${ids.join(',')});out tags;`, 'Tours');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const tagged = new Map<number, Record<string, string> | undefined>();
|
||||
for (const element of elements) {
|
||||
const way = wayElementSchema.safeParse(element);
|
||||
if (way.success) tagged.set(way.data.id, way.data.tags);
|
||||
}
|
||||
// A way the answer leaves out has no tags worth naming, or no longer exists: no grade either way.
|
||||
for (const id of ids) this.keep(id, gradeOf(id, tagged.get(id)), now);
|
||||
return true;
|
||||
}
|
||||
|
||||
private keep(id: number, grade: TourTrailGrade | null, at: number): void {
|
||||
this.kept.delete(id);
|
||||
this.kept.set(id, { grade, at });
|
||||
if (this.kept.size > MAX_KEPT) {
|
||||
const oldest = this.kept.keys().next().value;
|
||||
if (oldest !== undefined) this.kept.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes and paddle tours planned or imported in the Tours addon): name, type (hike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_steps, avoid_ferries, prefer_paths, shortest; weightings, never a grade of the path), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes, bike tours and paddle tours planned or imported in the Tours addon): name, type (hike, bike, or kayak for a paddle tour), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -130,7 +130,7 @@ export class ToursMcp {
|
||||
@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.',
|
||||
'Search places along the saved line of a tour: what a hike, a bike tour or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops), 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'),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { OverpassModule } from '../overpass/overpass.module';
|
||||
import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlaceImportModule } from '../place-import/place-import.module';
|
||||
@@ -23,6 +24,8 @@ 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 { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
@@ -49,7 +52,8 @@ import { Module } from '@nestjs/common';
|
||||
McpSharedModule,
|
||||
// PLN-02 searches places along a tour through the map search.
|
||||
MapsModule,
|
||||
|
||||
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap.
|
||||
OverpassModule,
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
WandererModule,
|
||||
@@ -60,6 +64,7 @@ import { Module } from '@nestjs/common';
|
||||
controllers: [
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
TourTrailGradeController,
|
||||
ToursController,
|
||||
ToursImportController,
|
||||
ToursWandererController,
|
||||
@@ -75,6 +80,7 @@ import { Module } from '@nestjs/common';
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
TourTrailGradeService,
|
||||
ToursMcp,
|
||||
TourLibraryMcp,
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
MAX_PLANNED_TOUR_DURATION_MINUTES,
|
||||
plannedTourDurationMinutesSchema,
|
||||
tourWebsiteSchema,
|
||||
type BikeProfile,
|
||||
type PaddleBoatType,
|
||||
type PaddleLakeStyle,
|
||||
type PaddleWaterContext,
|
||||
@@ -108,6 +109,7 @@ export class ToursService {
|
||||
max_hiking_difficulty: r.max_hiking_difficulty,
|
||||
derived_from_place_id: r.derived_from_place_id,
|
||||
origin: r.origin as TourOrigin,
|
||||
bike_profile: r.bike_profile as BikeProfile | null,
|
||||
boat_type: r.boat_type as PaddleBoatType | null,
|
||||
water_context: r.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: r.paddle_speed_kmh,
|
||||
@@ -143,6 +145,7 @@ export class ToursService {
|
||||
: {}),
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: input.max_hiking_difficulty,
|
||||
bike_profile: input.tour_type === 'bike' ? (input.bike_profile ?? null) : null,
|
||||
boat_type: input.boat_type ?? null,
|
||||
water_context: input.water_context ?? null,
|
||||
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
|
||||
@@ -381,6 +384,7 @@ export class ToursService {
|
||||
row.wanderer_ref && row.tour_type === 'hike'
|
||||
? wandererDifficultyToSac(row.difficulty as WandererDifficulty | null)
|
||||
: null,
|
||||
bike_profile: row.bike_profile as BikeProfile | null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
@@ -486,6 +490,7 @@ export class ToursService {
|
||||
break_additional_minutes: row.break_additional_minutes,
|
||||
match_confidence: row.match_confidence,
|
||||
max_hiking_difficulty: row.max_hiking_difficulty,
|
||||
bike_profile: row.bike_profile as BikeProfile | null,
|
||||
boat_type: row.boat_type as PaddleBoatType | null,
|
||||
water_context: row.water_context as PaddleWaterContext | null,
|
||||
paddle_speed_kmh: row.paddle_speed_kmh,
|
||||
@@ -647,7 +652,9 @@ export class ToursService {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
const paddle = {
|
||||
// What the type adds to each imported tour: a bike tour its profile, a paddle tour its boat and water.
|
||||
const typeFields = {
|
||||
bike_profile: fields.bike_profile ?? null,
|
||||
boat_type: fields.boat_type ?? null,
|
||||
water_context: fields.water_context ?? null,
|
||||
paddle_speed_kmh: null,
|
||||
@@ -663,7 +670,7 @@ export class ToursService {
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: place.id,
|
||||
tour_type: fields.tour_type,
|
||||
...paddle,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
@@ -682,7 +689,7 @@ export class ToursService {
|
||||
description: place.description ?? null,
|
||||
website: place.website ?? null,
|
||||
tour_type: fields.tour_type,
|
||||
...paddle,
|
||||
...typeFields,
|
||||
distance: metrics.distanceKm,
|
||||
elevation_gain: metrics.elevationGainM,
|
||||
elevation_loss: metrics.elevationLossM,
|
||||
|
||||
Reference in New Issue
Block a user