mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(tours): a hike's day with sun, weather and warnings, and the proposed line kept for create
This commit is contained in:
@@ -38,7 +38,11 @@ describe('a hike the server plans through its points', () => {
|
||||
it('HIKE-PLAN-SCHEMA-001: takes two points and nothing else, and the limit, the preferences and the names it can be given', () => {
|
||||
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0), point(1)] }).success).toBe(true);
|
||||
const full = {
|
||||
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
|
||||
waypoints: [
|
||||
{ ...point(0), name: 'Parking' },
|
||||
{ ...point(1), poi_category: 'shelter' },
|
||||
{ ...point(2), name: 'Summit', poi_category: 'peak' },
|
||||
],
|
||||
max_hiking_difficulty: 4,
|
||||
hiking_preferences: { avoid_steps: true },
|
||||
};
|
||||
@@ -58,6 +62,9 @@ describe('a hike the server plans through its points', () => {
|
||||
);
|
||||
expect(hikePlanRequestSchema.safeParse({ waypoints: two, tripId: 1 }).success).toBe(false);
|
||||
expect(hikePlanRequestSchema.safeParse({ waypoints: [{ lat: 91, lng: 0 }, point(1)] }).success).toBe(false);
|
||||
expect(
|
||||
hikePlanRequestSchema.safeParse({ waypoints: [{ ...point(0), poi_category: 'volcano' }, point(1)] }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-SCHEMA-003: reads a routed plan with or without heights and grades', () => {
|
||||
|
||||
@@ -28,13 +28,16 @@ export const HIKE_PLAN_PROFILE_SAMPLES = 200;
|
||||
*/
|
||||
export const HIKE_MAX_LINE_POINTS = 10_000;
|
||||
|
||||
const { lat, lng, name } = tourWaypointSchema.shape;
|
||||
const { lat, lng, name, poi_category } = tourWaypointSchema.shape;
|
||||
|
||||
export const hikePlanPointSchema = z
|
||||
.object({
|
||||
lat,
|
||||
lng,
|
||||
name: name.describe('What the point is, such as a car park, a hut or a summit, where it has a name'),
|
||||
poi_category: poi_category.describe(
|
||||
'What kind of place the point is, where it is one: peak for a summit, shelter for a hut, drinking_water, viewpoint',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
export type HikePlanPoint = z.infer<typeof hikePlanPointSchema>;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { HourlyEntry, WeatherResult } from '../weather/weather.schema';
|
||||
import {
|
||||
arrivalsOf,
|
||||
HIKE_DAYLIGHT_HOURS,
|
||||
HIKE_SCHEDULE_HOURS,
|
||||
hikeScheduleOf,
|
||||
hikeScheduleSchema,
|
||||
hikeWarningSchema,
|
||||
type HikeScheduleInput,
|
||||
} from './hike-schedule';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A day's hours, clear, 10 % rain, 12 km/h of wind, with the hours `over` names changed. */
|
||||
function hoursOf(over: Record<number, Partial<HourlyEntry>> = {}): HourlyEntry[] {
|
||||
return Array.from({ length: 24 }, (_, hour) => ({
|
||||
hour,
|
||||
temp: 8 + hour / 2,
|
||||
precipitation: 0,
|
||||
precipitation_probability: 10,
|
||||
main: 'Clear',
|
||||
wind: 12.4,
|
||||
humidity: 60,
|
||||
...over[hour],
|
||||
}));
|
||||
}
|
||||
|
||||
/** A day's weather; a forecast says the day's highest likelihood of rain beside its hours, as Open-Meteo's does. */
|
||||
function day(type: 'forecast' | 'climate', over: Record<number, Partial<HourlyEntry>> = {}): WeatherResult {
|
||||
return {
|
||||
type,
|
||||
temp: 14,
|
||||
main: 'Clear',
|
||||
description: '',
|
||||
sunrise: '06:30',
|
||||
sunset: '19:00',
|
||||
...(type === 'forecast' ? { precipitation_probability_max: 10 } : {}),
|
||||
hourly: hoursOf(over),
|
||||
};
|
||||
}
|
||||
|
||||
const input = (over: Partial<HikeScheduleInput> = {}): HikeScheduleInput => ({
|
||||
date: '2027-07-10',
|
||||
dayId: 4,
|
||||
start: '08:00',
|
||||
minutes: 200,
|
||||
weather: day('forecast'),
|
||||
...over,
|
||||
});
|
||||
|
||||
const kinds = (over: Partial<HikeScheduleInput>) => hikeScheduleOf(input(over)).warnings.map(({ kind }) => kind);
|
||||
|
||||
describe('the day of a hike with a start', () => {
|
||||
it('HIKE-SCHED-001: arrives the walking time after the start, and shows the hours from one before to one after', () => {
|
||||
const { schedule, warnings } = hikeScheduleOf(input());
|
||||
expect(hikeScheduleSchema.parse(schedule)).toEqual(schedule);
|
||||
expect(schedule).toMatchObject({
|
||||
date: '2027-07-10',
|
||||
day_id: 4,
|
||||
start: '08:00',
|
||||
arrive: '11:20',
|
||||
next_day: false,
|
||||
sunrise: '06:30',
|
||||
sunset: '19:00',
|
||||
latest_start: null,
|
||||
weather: 'forecast',
|
||||
});
|
||||
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 8, 9, 10, 11, 12]);
|
||||
expect(schedule.hours[1]).toEqual({ hour: 8, main: 'Clear', temp_c: 12, rain_pct: 10, wind_kmh: 12 });
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-002: warns of a start before sunrise and of an arrival after sunset, at the sun times', () => {
|
||||
expect(hikeScheduleOf(input({ start: '05:45' })).warnings).toEqual([{ kind: 'dark_start', at: '06:30' }]);
|
||||
const late = hikeScheduleOf(input({ start: '16:00' }));
|
||||
expect(late.schedule.arrive).toBe('19:20');
|
||||
expect(late.warnings).toEqual([{ kind: 'dark_end', at: '19:00' }]);
|
||||
expect(kinds({ start: '04:00', minutes: 16 * 60 })).toEqual(['dark_start', 'dark_end']);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-003: warns of dusk for an arrival within the hour before sunset, 59 minutes yes and 61 no', () => {
|
||||
// A walk of 3 h 20 min against a sunset at 19:00.
|
||||
expect(kinds({ start: '14:41' })).toEqual(['dusk']);
|
||||
expect(kinds({ start: '14:39' })).toEqual([]);
|
||||
expect(kinds({ start: '14:40' })).toEqual(['dusk']);
|
||||
expect(kinds({ start: '15:40' })).toEqual(['dusk']);
|
||||
expect(hikeScheduleOf(input({ start: '14:41' })).warnings).toEqual([{ kind: 'dusk', at: '19:00' }]);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-004: warns of thunder and rain only in an hour walked, and only on a forecast', () => {
|
||||
const stormy = { 10: { main: 'Thunderstorm' }, 13: { main: 'Thunderstorm' }, 9: { precipitation_probability: 60 } };
|
||||
const { warnings } = hikeScheduleOf(input({ weather: day('forecast', stormy) }));
|
||||
expect(warnings).toEqual([
|
||||
{ kind: 'thunder', at: '10:00' },
|
||||
{ kind: 'rain', at: '09:00' },
|
||||
]);
|
||||
for (const warning of warnings) expect(hikeWarningSchema.parse(warning)).toEqual(warning);
|
||||
// A storm after the arrival (11:20) or before the start is no warning; rain at 59 % neither.
|
||||
const outside = { 12: { main: 'Thunderstorm' }, 7: { main: 'Thunderstorm' }, 9: { precipitation_probability: 59 } };
|
||||
expect(kinds({ weather: day('forecast', outside) })).toEqual([]);
|
||||
// The hour the hike starts in warns from the start, not from the full hour.
|
||||
expect(
|
||||
hikeScheduleOf(input({ start: '08:30', weather: day('forecast', { 8: { main: 'Thunderstorm' } }) })).warnings,
|
||||
).toEqual([{ kind: 'thunder', at: '08:30' }]);
|
||||
// An arrival on the full hour walks nothing of the hour that follows.
|
||||
expect(kinds({ minutes: 180, weather: day('forecast', { 11: { main: 'Thunderstorm' } }) })).toEqual([]);
|
||||
const climate = hikeScheduleOf(input({ weather: day('climate', stormy) }));
|
||||
expect(climate.warnings).toEqual([]);
|
||||
expect(climate.schedule.weather).toBe('climate');
|
||||
expect(climate.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-012: shows no likelihood of rain for a past day as observed, and warns of its storms only', () => {
|
||||
// A past day is answered as a forecast without the day's highest likelihood, its hours at 0 %.
|
||||
const { precipitation_probability_max: _max, ...observed } = day('forecast', {
|
||||
9: { main: 'Thunderstorm', precipitation_probability: 0 },
|
||||
10: { precipitation_probability: 90 },
|
||||
});
|
||||
const past = hikeScheduleOf(input({ weather: observed }));
|
||||
expect(past.schedule.weather).toBe('forecast');
|
||||
expect(past.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
|
||||
expect(past.warnings).toEqual([{ kind: 'thunder', at: '09:00' }]);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-005: puts an arrival past midnight on the next day, after sunset', () => {
|
||||
const { schedule, warnings } = hikeScheduleOf(input({ start: '20:30', minutes: 240 }));
|
||||
expect(schedule).toMatchObject({ arrive: '00:30', next_day: true });
|
||||
expect(schedule.hours.map(({ hour }) => hour)).toEqual([19, 20, 21, 22, 23]);
|
||||
expect(warnings.map(({ kind }) => kind)).toEqual(['dark_end']);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-006: shows at most the first hours of a very long hike', () => {
|
||||
const { schedule } = hikeScheduleOf(input({ start: '00:10', minutes: 23 * 60 }));
|
||||
expect(schedule.hours).toHaveLength(HIKE_SCHEDULE_HOURS);
|
||||
expect(schedule.hours[0]?.hour).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the day of a hike without a start', () => {
|
||||
it('HIKE-SCHED-007: says the latest start that ends half an hour before sunset, with every second hour of daylight', () => {
|
||||
const { schedule, warnings } = hikeScheduleOf(input({ start: null }));
|
||||
expect(schedule).toMatchObject({ start: null, arrive: null, next_day: false, latest_start: '15:10' });
|
||||
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 9, 11, 13, 15, 17]);
|
||||
expect(warnings).toEqual([]);
|
||||
const long = hikeScheduleOf(
|
||||
input({ start: null, weather: { ...day('forecast'), sunrise: '04:00', sunset: '22:00' } }),
|
||||
);
|
||||
expect(long.schedule.hours).toHaveLength(HIKE_DAYLIGHT_HOURS);
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-008: has no latest start where the hike does not fit into the daylight', () => {
|
||||
expect(hikeScheduleOf(input({ start: null, minutes: 720 })).schedule.latest_start).toBe('06:30');
|
||||
expect(hikeScheduleOf(input({ start: null, minutes: 721 })).schedule.latest_start).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike whose weather could not be had', () => {
|
||||
it('HIKE-SCHED-009: keeps the start and arrival, without sun, hours or warnings', () => {
|
||||
for (const weather of [null, { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }]) {
|
||||
const { schedule, warnings } = hikeScheduleOf(input({ start: '05:00', weather }));
|
||||
expect(schedule).toEqual({
|
||||
date: '2027-07-10',
|
||||
day_id: 4,
|
||||
start: '05:00',
|
||||
arrive: '08:20',
|
||||
next_day: false,
|
||||
sunrise: null,
|
||||
sunset: null,
|
||||
latest_start: null,
|
||||
weather: null,
|
||||
hours: [],
|
||||
});
|
||||
expect(warnings).toEqual([]);
|
||||
}
|
||||
const unknown = hikeScheduleOf(input({ start: null, weather: { ...day('forecast'), type: 'current' } }));
|
||||
expect(unknown.schedule).toMatchObject({ weather: null, latest_start: null, hours: [] });
|
||||
});
|
||||
|
||||
it('HIKE-SCHED-010: reads no sun from a time it cannot read, and no hours without the sun', () => {
|
||||
const { schedule } = hikeScheduleOf(
|
||||
input({ start: null, weather: { ...day('forecast'), sunrise: '', sunset: null } }),
|
||||
);
|
||||
expect(schedule).toMatchObject({ sunrise: null, sunset: null, latest_start: null, hours: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when each point is reached', () => {
|
||||
it('HIKE-SCHED-011: adds the walking minutes to the start, past midnight onto the next day, none without a start', () => {
|
||||
expect(arrivalsOf('08:00', [0, 95, null, 1000])).toEqual(['08:00', '09:35', null, '00:40']);
|
||||
expect(arrivalsOf(null, [0, 95])).toEqual([null, null]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { clockTimeSchema } from '../place/place-hours';
|
||||
import type { WeatherResult } from '../weather/weather.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* A hike's day: when it starts and arrives, the sun, the weather by the hour
|
||||
* and what to look out for, from the day's detailed weather at one point of
|
||||
* the way (its highest where the heights are known). Pure: the server asks the
|
||||
* weather and hands it in, the rules are here once.
|
||||
*
|
||||
* - The arrival is the start plus the walking time; past midnight it is on
|
||||
* the next day.
|
||||
* - With a start, the hours run from the one before it to the one after the
|
||||
* arrival. Without one, every second hour of daylight is shown, and the
|
||||
* latest start that still ends half an hour before sunset (none where that
|
||||
* lies before sunrise).
|
||||
* - A start before sunrise, an arrival after sunset or within the hour before
|
||||
* it are warned of; so is the first walking hour with a thunderstorm or
|
||||
* rain more likely than not, on a forecast only, since a day further out is
|
||||
* read from last year's weather.
|
||||
* - How likely rain is shows only where a forecast says it; a past day,
|
||||
* answered as observed, shows none and warns of no rain.
|
||||
* - Without the weather there are no sun times, no hours and no warnings; the
|
||||
* start and the arrival still stand.
|
||||
*/
|
||||
|
||||
/** The most hours a schedule shows; a longer hike shows its first ones. */
|
||||
export const HIKE_SCHEDULE_HOURS = 18;
|
||||
/** The most hours of daylight shown for a hike without a start, every second one. */
|
||||
export const HIKE_DAYLIGHT_HOURS = 8;
|
||||
/** How long before sunset an arrival is close enough to dusk to be warned of, in minutes. */
|
||||
export const HIKE_DUSK_MINUTES = 60;
|
||||
/** How long before sunset the latest start has a hike back, in minutes. */
|
||||
export const HIKE_SUNSET_MARGIN_MINUTES = 30;
|
||||
/** How likely rain is, in percent, before a walking hour warns of it. */
|
||||
export const HIKE_RAIN_PCT = 60;
|
||||
/** The weather a schedule warns of: thunderstorms, rain, an end after dark, a start before light, an end at dusk. */
|
||||
export const HIKE_WARNING_KINDS = ['thunder', 'rain', 'dark_end', 'dark_start', 'dusk'] as const;
|
||||
|
||||
const MINUTES_A_DAY = 24 * 60;
|
||||
|
||||
export const hikeScheduleHourSchema = z.object({
|
||||
hour: z.number().int().min(0).max(23),
|
||||
/** The hour's weather in one word, as the weather of a day names it (Clear, Rain, Thunderstorm). */
|
||||
main: z.string(),
|
||||
temp_c: z.number().int(),
|
||||
/** Null where the day's weather knows no likelihood: last year's weather, or a past day as observed. */
|
||||
rain_pct: z.number().int().min(0).max(100).nullable(),
|
||||
wind_kmh: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
export type HikeScheduleHour = z.infer<typeof hikeScheduleHourSchema>;
|
||||
|
||||
export const hikeScheduleSchema = z.object({
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
/** The trip's day the hike is planned on; null where only a date was given. */
|
||||
day_id: idSchema.nullable(),
|
||||
start: clockTimeSchema.nullable(),
|
||||
arrive: clockTimeSchema.nullable(),
|
||||
/** The arrival falls on the day after the start. */
|
||||
next_day: z.boolean(),
|
||||
sunrise: clockTimeSchema.nullable(),
|
||||
sunset: clockTimeSchema.nullable(),
|
||||
/** Without a start: the latest one that ends half an hour before sunset; null where none does. */
|
||||
latest_start: clockTimeSchema.nullable(),
|
||||
/** A forecast, last year's weather for a day further out (climate), or none to be had. */
|
||||
weather: z.enum(['forecast', 'climate']).nullable(),
|
||||
hours: z.array(hikeScheduleHourSchema).max(HIKE_SCHEDULE_HOURS),
|
||||
});
|
||||
export type HikeSchedule = z.infer<typeof hikeScheduleSchema>;
|
||||
|
||||
export const hikeWarningSchema = z.object({
|
||||
kind: z.enum(HIKE_WARNING_KINDS),
|
||||
/** The clock it is about: the first such hour, or the sunrise or sunset passed. */
|
||||
at: clockTimeSchema.nullable(),
|
||||
});
|
||||
export type HikeWarning = z.infer<typeof hikeWarningSchema>;
|
||||
|
||||
export interface HikeScheduleInput {
|
||||
date: string;
|
||||
dayId: number | null;
|
||||
/** HH:MM, or null where the hike has no start yet. */
|
||||
start: string | null;
|
||||
/** The walking time. */
|
||||
minutes: number;
|
||||
/** The day's detailed weather, or null where it could not be had. */
|
||||
weather: WeatherResult | null;
|
||||
}
|
||||
|
||||
/** Minutes after midnight of a clock, null where it is no clock. */
|
||||
function minutesOf(clock: string | null | undefined): number | null {
|
||||
if (!clock || !clockTimeSchema.safeParse(clock).success) return null;
|
||||
return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
|
||||
}
|
||||
|
||||
/** A time of day as a clock, past midnight wrapped onto the next day. */
|
||||
function clockOf(minutes: number): string {
|
||||
const within = ((Math.round(minutes) % MINUTES_A_DAY) + MINUTES_A_DAY) % MINUTES_A_DAY;
|
||||
return `${String(Math.floor(within / 60)).padStart(2, '0')}:${String(within % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** The kind of weather a day was answered with, or null for no weather. */
|
||||
function weatherKindOf(weather: WeatherResult | null): HikeSchedule['weather'] {
|
||||
if (!weather || weather.error) return null;
|
||||
return weather.type === 'forecast' || weather.type === 'climate' ? weather.type : null;
|
||||
}
|
||||
|
||||
function hourOf(entry: NonNullable<WeatherResult['hourly']>[number], likelihood: boolean): HikeScheduleHour {
|
||||
return {
|
||||
hour: entry.hour,
|
||||
main: entry.main,
|
||||
temp_c: Math.round(entry.temp),
|
||||
rain_pct: likelihood ? Math.max(0, Math.min(100, Math.round(entry.precipitation_probability))) : null,
|
||||
wind_kmh: Number.isFinite(entry.wind) ? Math.max(0, Math.round(entry.wind)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The hours from `first` to `last` the weather has, every `step`, at most `most`. */
|
||||
function hoursBetween(hourly: readonly HikeScheduleHour[], first: number, last: number, step: number, most: number) {
|
||||
return hourly
|
||||
.filter((entry) => entry.hour >= first && entry.hour <= last && (entry.hour - first) % step === 0)
|
||||
.slice(0, most);
|
||||
}
|
||||
|
||||
/** The first hour walked whose weather matches, as the clock it starts at (never before the start). */
|
||||
function firstWalked(
|
||||
hourly: readonly HikeScheduleHour[],
|
||||
start: number,
|
||||
arrive: number,
|
||||
matches: (hour: HikeScheduleHour) => boolean,
|
||||
) {
|
||||
const first = Math.floor(start / 60);
|
||||
const last = Math.max(first, Math.ceil(arrive / 60) - 1);
|
||||
const hit = hourly.find((entry) => entry.hour >= first && entry.hour <= last && matches(entry));
|
||||
return hit ? clockOf(Math.max(hit.hour * 60, start)) : null;
|
||||
}
|
||||
|
||||
/** The warnings of a hike with a start, in the order they matter most: the dark first. */
|
||||
function warningsOf(
|
||||
start: number,
|
||||
arrive: number,
|
||||
sun: { rise: number | null; set: number | null },
|
||||
hourly: readonly HikeScheduleHour[],
|
||||
kind: HikeSchedule['weather'],
|
||||
): HikeWarning[] {
|
||||
const warnings: HikeWarning[] = [];
|
||||
if (sun.rise !== null && start < sun.rise) warnings.push({ kind: 'dark_start', at: clockOf(sun.rise) });
|
||||
if (sun.set !== null && arrive > sun.set) warnings.push({ kind: 'dark_end', at: clockOf(sun.set) });
|
||||
else if (sun.set !== null && sun.set - arrive <= HIKE_DUSK_MINUTES)
|
||||
warnings.push({ kind: 'dusk', at: clockOf(sun.set) });
|
||||
if (kind !== 'forecast') return warnings;
|
||||
const thunder = firstWalked(hourly, start, arrive, (hour) => hour.main === 'Thunderstorm');
|
||||
if (thunder) warnings.push({ kind: 'thunder', at: thunder });
|
||||
const rain = firstWalked(hourly, start, arrive, (hour) => (hour.rain_pct ?? 0) >= HIKE_RAIN_PCT);
|
||||
if (rain) warnings.push({ kind: 'rain', at: rain });
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** A hike's day by the rules above: its schedule, and what to look out for. */
|
||||
export function hikeScheduleOf(input: HikeScheduleInput): { schedule: HikeSchedule; warnings: HikeWarning[] } {
|
||||
const kind = weatherKindOf(input.weather);
|
||||
const known = kind === null ? null : input.weather;
|
||||
// A forecast says how likely rain is, with the day's highest likelihood beside its hours. Last year's
|
||||
// weather does not, nor a past day as observed, which is answered as a forecast without it.
|
||||
const likelihood = known?.type === 'forecast' && known.precipitation_probability_max !== undefined;
|
||||
const hourly = (known?.hourly ?? []).map((entry) => hourOf(entry, likelihood));
|
||||
const sun = { rise: minutesOf(known?.sunrise), set: minutesOf(known?.sunset) };
|
||||
const start = minutesOf(input.start);
|
||||
const minutes = Math.max(0, Math.round(input.minutes));
|
||||
const base = {
|
||||
date: input.date,
|
||||
day_id: input.dayId,
|
||||
sunrise: sun.rise === null ? null : clockOf(sun.rise),
|
||||
sunset: sun.set === null ? null : clockOf(sun.set),
|
||||
weather: kind,
|
||||
};
|
||||
if (start === null) {
|
||||
const latest = sun.set === null ? null : sun.set - HIKE_SUNSET_MARGIN_MINUTES - minutes;
|
||||
const daylight =
|
||||
sun.rise === null || sun.set === null
|
||||
? []
|
||||
: hoursBetween(hourly, Math.ceil(sun.rise / 60), Math.ceil(sun.set / 60) - 1, 2, HIKE_DAYLIGHT_HOURS);
|
||||
return {
|
||||
schedule: {
|
||||
...base,
|
||||
start: null,
|
||||
arrive: null,
|
||||
next_day: false,
|
||||
latest_start: latest !== null && sun.rise !== null && latest >= sun.rise ? clockOf(latest) : null,
|
||||
hours: daylight,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
const arrive = start + minutes;
|
||||
const first = Math.floor(start / 60) - 1;
|
||||
const last = Math.floor(arrive / 60) + 1;
|
||||
return {
|
||||
schedule: {
|
||||
...base,
|
||||
start: clockOf(start),
|
||||
arrive: clockOf(arrive),
|
||||
next_day: arrive >= MINUTES_A_DAY,
|
||||
latest_start: null,
|
||||
hours: hoursBetween(hourly, first, last, 1, HIKE_SCHEDULE_HOURS),
|
||||
},
|
||||
warnings: warningsOf(start, arrive, sun, hourly, kind),
|
||||
};
|
||||
}
|
||||
|
||||
/** The clock of each point reached `minutes` after a start, null for every point without one. */
|
||||
export function arrivalsOf(start: string | null, minutes: ReadonlyArray<number | null>): Array<string | null> {
|
||||
const from = minutesOf(start);
|
||||
return minutes.map((walked) => (from === null || walked === null ? null : clockOf(from + walked)));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import {
|
||||
cumulativeMetres,
|
||||
legMinutes,
|
||||
nearestAlongM,
|
||||
pointsPerLeg,
|
||||
pointsPerLine,
|
||||
type AlongPoint,
|
||||
} from './line-along';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A point `k` steps of a ten-thousandth of a degree north of 47° N on 11° E, about 11 m each, at `height`. */
|
||||
const north = (k: number, height?: number): AlongPoint =>
|
||||
height === undefined ? [47 + k / 1e4, 11] : [47 + k / 1e4, 11, height];
|
||||
const STEP_M = haversineMetres(47, 11, 47.0001, 11);
|
||||
|
||||
describe('metres along a line', () => {
|
||||
it('LINE-ALONG-001: measures each point from the start, the first at nothing', () => {
|
||||
const along = cumulativeMetres([north(0), north(1), north(3)]);
|
||||
expect(along[0]).toBe(0);
|
||||
expect(along[1]).toBeCloseTo(STEP_M, 6);
|
||||
expect(along[2]).toBeCloseTo(3 * STEP_M, 6);
|
||||
expect(cumulativeMetres([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('LINE-ALONG-002: finds the spot nearest a point beside the line, and holds it to the line ends', () => {
|
||||
const line = [north(0), north(10), north(20)];
|
||||
expect(nearestAlongM(line, 47.0005, 11.0003)).toBeCloseTo(5 * STEP_M, 0);
|
||||
expect(nearestAlongM(line, 47.0015, 10.9998)).toBeCloseTo(15 * STEP_M, 0);
|
||||
expect(nearestAlongM(line, 46.99, 11)).toBe(0);
|
||||
expect(nearestAlongM(line, 47.01, 11)).toBeCloseTo(20 * STEP_M, 6);
|
||||
expect(nearestAlongM([north(0)], 47, 11)).toBe(0);
|
||||
expect(nearestAlongM([north(0), north(0)], 47, 11)).toBe(0);
|
||||
});
|
||||
|
||||
it('LINE-ALONG-003: on a loop back to its start, places the start on the first pass and the far side halfway', () => {
|
||||
const loop: AlongPoint[] = [
|
||||
[47, 11],
|
||||
[47.001, 11],
|
||||
[47.001, 11.0015],
|
||||
[47, 11.0015],
|
||||
[47, 11],
|
||||
];
|
||||
const along = cumulativeMetres(loop);
|
||||
const total = along[along.length - 1] ?? 0;
|
||||
expect(nearestAlongM(loop, 47.00001, 11.00001)).toBeLessThan(5);
|
||||
expect(nearestAlongM(loop, 47.0011, 11.0016)).toBeCloseTo(along[2] ?? 0, 0);
|
||||
expect(along[2]).toBeCloseTo(total / 2, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the walking minutes of each leg', () => {
|
||||
it('LINE-ALONG-004: splits the whole time over the legs, a climbing leg longer than a flat one, summing to the total', () => {
|
||||
// Flat for 10 steps, then 10 steps climbing 30 m each: 300 m up.
|
||||
const line = [north(0, 500), north(10, 500), ...Array.from({ length: 10 }, (_, k) => north(11 + k, 530 + 30 * k))];
|
||||
const along = cumulativeMetres(line);
|
||||
const atMs = [0, along[1] ?? 0, along[along.length - 1] ?? 0];
|
||||
const legs = legMinutes(line, atMs, 75);
|
||||
expect(legs).toHaveLength(2);
|
||||
expect(legs.reduce((sum, leg) => sum + leg, 0)).toBe(75);
|
||||
expect(legs[1]).toBeGreaterThan(3 * (legs[0] ?? 0));
|
||||
expect(legs.every(Number.isInteger)).toBe(true);
|
||||
});
|
||||
|
||||
it('LINE-ALONG-005: weighs legs without heights by their length, and rounds a time that is not whole', () => {
|
||||
const line = [north(0), north(10), north(40)];
|
||||
const legs = legMinutes(line, [0, 10 * STEP_M, 40 * STEP_M], 40.4);
|
||||
expect(legs).toEqual([10, 30]);
|
||||
// A leg as short as a few metres keeps a share of its own rather than none.
|
||||
expect(legMinutes(line, [0, 3, 40 * STEP_M], 1000)[0]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('LINE-ALONG-006: splits evenly where no leg has any length, and gives nothing for a single point', () => {
|
||||
const line = [north(0, 500), north(1, 510)];
|
||||
expect(legMinutes(line, [0, 0, 0], 7)).toEqual([4, 3]);
|
||||
expect(legMinutes(line, [0], 7)).toEqual([]);
|
||||
expect(legMinutes(line, [0, STEP_M], -3)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the points each line gets for drawing', () => {
|
||||
it('LINE-ALONG-007: shares the points by length, two at least for each, never more than a line has', () => {
|
||||
const shares = pointsPerLine([1000, 3000], 100);
|
||||
expect(shares).toEqual([26, 74]);
|
||||
expect(pointsPerLine([1000, 3000], 100, [10, 5000])).toEqual([10, 74]);
|
||||
expect(pointsPerLine([0, 0, 0], 4)).toEqual([2, 2, 2]);
|
||||
expect(pointsPerLine([500, 0], 10)).toEqual([8, 2]);
|
||||
expect(pointsPerLine([], 300)).toEqual([]);
|
||||
});
|
||||
|
||||
it('LINE-ALONG-008: shares the points over the planned legs in order, a leg not planned drawing none', () => {
|
||||
const legs = [{ lengthM: 1000, points: 5000 }, null, { lengthM: 3000, points: 5000 }];
|
||||
expect(pointsPerLeg(legs, 100)).toEqual([26, null, 74]);
|
||||
expect(pointsPerLeg([null, null], 300)).toEqual([null, null]);
|
||||
expect(pointsPerLeg([{ lengthM: 10, points: 3 }], 300)).toEqual([3]);
|
||||
expect(pointsPerLeg([], 300)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
import { haversineMetres } from '../geo/haversine';
|
||||
import { hikingTimeMinutes } from './tour-time-estimate';
|
||||
|
||||
/**
|
||||
* Measures along a planned line, in metres from its start, the way the
|
||||
* server answers a planned way and a chat card draws it: how far along each
|
||||
* point lies, where along the line a point beside it is nearest, and how a
|
||||
* walking time splits over the legs between the points a way was planned
|
||||
* through. Pure, for the server and the client alike.
|
||||
*/
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** A point of a line, `[lat, lng]`, with its height in metres where the line has one (null where it is unknown). */
|
||||
export type AlongPoint = readonly [number, number, ...Array<number | null>];
|
||||
|
||||
/** One stretch of a line: its two ends and how far along the line each lies. */
|
||||
interface Stretch {
|
||||
a: AlongPoint;
|
||||
b: AlongPoint;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** Metres along the line at each of its points, the first at 0. */
|
||||
export function cumulativeMetres(line: readonly AlongPoint[]): number[] {
|
||||
let along = 0;
|
||||
let before: AlongPoint | undefined;
|
||||
return line.map((point) => {
|
||||
if (before) along += haversineMetres(before[0], before[1], point[0], point[1]);
|
||||
before = point;
|
||||
return along;
|
||||
});
|
||||
}
|
||||
|
||||
/** The stretches of a line in order, each with where along the line it starts and ends. */
|
||||
function stretchesOf(line: readonly AlongPoint[]): Stretch[] {
|
||||
const along = cumulativeMetres(line);
|
||||
const stretches: Stretch[] = [];
|
||||
line.forEach((b, index) => {
|
||||
const a = line[index - 1];
|
||||
if (a) stretches.push({ a, b, from: along[index - 1] ?? 0, to: along[index] ?? 0 });
|
||||
});
|
||||
return stretches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metres along the line to the spot of it nearest to a point, measured as
|
||||
* cumulativeMetres measures the line. Where the line passes the point twice
|
||||
* (a loop back to its start, a summit walked up and down one path) the first
|
||||
* pass counts. A line of fewer than two points gives 0.
|
||||
*/
|
||||
export function nearestAlongM(line: readonly AlongPoint[], lat: number, lng: number): number {
|
||||
// Laid flat around the point: east scaled by the latitude, so the nearest spot is found as on a map.
|
||||
const east = Math.cos(lat * RAD);
|
||||
let best = { gap: Number.POSITIVE_INFINITY, at: 0 };
|
||||
for (const { a, b, from, to } of stretchesOf(line)) {
|
||||
const dx = (b[1] - a[1]) * east;
|
||||
const dy = b[0] - a[0];
|
||||
const px = (lng - a[1]) * east;
|
||||
const py = lat - a[0];
|
||||
const span = dx * dx + dy * dy;
|
||||
const t = span === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / span));
|
||||
const gap = Math.hypot(px - t * dx, py - t * dy);
|
||||
if (gap < best.gap) best = { gap, at: from + t * (to - from) };
|
||||
}
|
||||
return best.at;
|
||||
}
|
||||
|
||||
/** The height of a point, null where it has none. */
|
||||
function heightOf(point: AlongPoint): number | null {
|
||||
const height = point[2];
|
||||
return typeof height === 'number' && Number.isFinite(height) ? height : null;
|
||||
}
|
||||
|
||||
/** The climb and descent between two places along the line, each stretch counted for its part between them. */
|
||||
function climbBetween(stretches: readonly Stretch[], from: number, to: number) {
|
||||
let climb = 0;
|
||||
let descent = 0;
|
||||
for (const stretch of stretches) {
|
||||
const inside = Math.min(to, stretch.to) - Math.max(from, stretch.from);
|
||||
const start = heightOf(stretch.a);
|
||||
const end = heightOf(stretch.b);
|
||||
if (inside <= 0 || stretch.to <= stretch.from || start === null || end === null) continue;
|
||||
const change = ((end - start) * inside) / (stretch.to - stretch.from);
|
||||
if (change > 0) climb += change;
|
||||
else descent -= change;
|
||||
}
|
||||
return { climb, descent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole numbers in proportion to `weights` that sum to `total` exactly: each
|
||||
* share rounded down, what is left over handed to the largest remainders.
|
||||
* Weights that are all nothing split the total evenly.
|
||||
*/
|
||||
function sharesOf(weights: readonly number[], total: number): number[] {
|
||||
const sum = weights.reduce((all, weight) => all + weight, 0);
|
||||
const parts = sum > 0 ? weights : weights.map(() => 1);
|
||||
const whole = parts.reduce((all, weight) => all + weight, 0);
|
||||
const exact = parts.map((weight) => (whole > 0 ? (weight * total) / whole : 0));
|
||||
const shares = exact.map((value) => Math.floor(value));
|
||||
let left = total - shares.reduce((all, share) => all + share, 0);
|
||||
const order = exact
|
||||
.map((value, index) => ({ index, rest: value - Math.floor(value) }))
|
||||
.sort((a, b) => b.rest - a.rest);
|
||||
for (const { index } of order) {
|
||||
if (left <= 0) break;
|
||||
shares[index] = (shares[index] ?? 0) + 1;
|
||||
left -= 1;
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
/**
|
||||
* The walking minutes of each leg between the points a way was planned
|
||||
* through (`atMs`, metres along the line in order), as the signposts' rule
|
||||
* (DIN 33466, hikingTimeMinutes) weighs the leg's length, climb and descent,
|
||||
* scaled so the legs sum to the way's whole time `total`. A line without
|
||||
* heights weighs the legs by their length alone. One leg fewer than points.
|
||||
*/
|
||||
export function legMinutes(line: readonly AlongPoint[], atMs: readonly number[], total: number): number[] {
|
||||
const stretches = stretchesOf(line);
|
||||
const weights = atMs.slice(1).map((to, index) => {
|
||||
const from = atMs[index] ?? 0;
|
||||
const length = Math.max(0, to - from);
|
||||
const { climb, descent } = climbBetween(stretches, from, from + length);
|
||||
// The rule rounds to whole minutes and scales with the leg, so it is asked of a leg a thousand times
|
||||
// as long and the answer shrunk back: a short leg keeps the seconds it walks instead of none.
|
||||
return hikingTimeMinutes(length, climb * 1000, descent * 1000) / 1000;
|
||||
});
|
||||
return sharesOf(weights, Math.max(0, Math.round(total)));
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of `most` points each of several lines gets for drawing, by its
|
||||
* length: at least two each, so every line keeps its ends, and never more
|
||||
* than its own points (`available`) where those are given.
|
||||
*/
|
||||
export function pointsPerLine(
|
||||
lengths: readonly number[],
|
||||
most: number,
|
||||
available: readonly number[] = lengths.map(() => Number.POSITIVE_INFINITY),
|
||||
): number[] {
|
||||
const spare = Math.max(0, most - 2 * lengths.length);
|
||||
const extra = sharesOf(
|
||||
lengths.map((length) => Math.max(0, length)),
|
||||
spare,
|
||||
);
|
||||
return extra.map((share, index) => Math.max(2, Math.min(available[index] ?? 2, 2 + share)));
|
||||
}
|
||||
|
||||
/** A planned leg as pointsPerLeg weighs it: its length in metres and how many points its line holds. */
|
||||
export interface DrawnLeg {
|
||||
lengthM: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of `most` points each leg of a plan draws with, shared by length
|
||||
* as pointsPerLine shares them, in the order of the legs. A leg that was not
|
||||
* planned (null) draws nothing and takes no share.
|
||||
*/
|
||||
export function pointsPerLeg(legs: ReadonlyArray<DrawnLeg | null>, most: number): Array<number | null> {
|
||||
const drawn = legs.filter((leg): leg is DrawnLeg => leg !== null);
|
||||
const shares = pointsPerLine(
|
||||
drawn.map((leg) => leg.lengthM),
|
||||
most,
|
||||
drawn.map((leg) => leg.points),
|
||||
).reverse();
|
||||
return legs.map((leg) => (leg === null ? null : (shares.pop() ?? null)));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { includeGeometryFlagSchema, TOUR_DISPLAY_LINE_POINTS } from './tour-display.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('the line a planning tool adds for drawing', () => {
|
||||
it('TOUR-DISPLAY-001: is asked for with a yes or a no, or left out, and says so to a model', () => {
|
||||
expect(includeGeometryFlagSchema.parse(true)).toBe(true);
|
||||
expect(includeGeometryFlagSchema.parse(false)).toBe(false);
|
||||
expect(includeGeometryFlagSchema.parse(undefined)).toBeUndefined();
|
||||
expect(includeGeometryFlagSchema.safeParse('true').success).toBe(false);
|
||||
expect(includeGeometryFlagSchema.description).toContain('line_geometry');
|
||||
expect(TOUR_DISPLAY_LINE_POINTS).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* What a planning tool answers for drawing. A tool that plans a way (a hike,
|
||||
* a city walk, a paddle or a ski tour) can add the line it planned, thinned
|
||||
* for display, under a key holding `geometry`, so a chat card draws it. Only
|
||||
* when asked: a client that reads the answer as text has no use for a few
|
||||
* hundred coordinates, and the assistant hides every such key from the model.
|
||||
*/
|
||||
|
||||
/** The flag a planning tool takes to add its line for drawing. */
|
||||
export const includeGeometryFlagSchema = z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Adds the line for drawing as line_geometry; leave out');
|
||||
|
||||
/** The most points of a line a planning tool answers for drawing, all its legs together. */
|
||||
export const TOUR_DISPLAY_LINE_POINTS = 300;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HIKE_PLAN_LINE_POINTS } from './hike-plan.schema';
|
||||
import {
|
||||
HIKE_ALONG_MAX,
|
||||
HIKE_MAX_WAYPOINTS,
|
||||
HIKE_PROPOSAL_SAC_SEGMENTS,
|
||||
hikeCreateRequestSchema,
|
||||
@@ -29,38 +30,75 @@ const proposal = {
|
||||
heights_known: true,
|
||||
min_ele_m: 720,
|
||||
max_ele_m: 1360,
|
||||
line: [
|
||||
line_geometry: [
|
||||
[47.4, 11.1, 720],
|
||||
[47.41, 11.1, 1360],
|
||||
],
|
||||
profile: [
|
||||
profile_geometry: [
|
||||
[0, 720],
|
||||
[8200, 1360],
|
||||
],
|
||||
sac: { highest: 2, segments: [{ from_m: 0, to_m: 1200, sac: 2 }], unknown_share: 0.4 },
|
||||
high_point: { lat: 47.41, lng: 11.1, ele_m: 1360, at_m: 8200 },
|
||||
waypoints: [
|
||||
{ lat: 47.4, lng: 11.1, name: 'Parking', at_m: 0 },
|
||||
{ lat: 47.41, lng: 11.1, name: null, at_m: 8200 },
|
||||
{
|
||||
lat: 47.4,
|
||||
lng: 11.1,
|
||||
name: 'Parking',
|
||||
poi_category: null,
|
||||
at_m: 0,
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
},
|
||||
{
|
||||
lat: 47.41,
|
||||
lng: 11.1,
|
||||
name: null,
|
||||
poi_category: 'peak',
|
||||
at_m: 8200,
|
||||
snapped_m: 4,
|
||||
minutes: 200,
|
||||
arrival: '11:50',
|
||||
},
|
||||
],
|
||||
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
|
||||
max_hiking_difficulty: 3,
|
||||
hiking_preferences: null,
|
||||
round_trip: false,
|
||||
day_id: 7,
|
||||
date: '2027-07-10',
|
||||
start_time: '08:30',
|
||||
schedule: {
|
||||
date: '2027-07-10',
|
||||
day_id: 7,
|
||||
start: '08:30',
|
||||
arrive: '11:50',
|
||||
next_day: false,
|
||||
sunrise: '05:31',
|
||||
sunset: '21:10',
|
||||
latest_start: null,
|
||||
weather: 'forecast',
|
||||
hours: [{ hour: 8, main: 'Clear', temp_c: 14, rain_pct: 10, wind_kmh: 8 }],
|
||||
},
|
||||
warnings: [{ kind: 'thunder', at: '11:00' }],
|
||||
};
|
||||
|
||||
describe('a hike proposed over MCP', () => {
|
||||
it('TOUR-HIKE-SCHEMA-001: takes two points and everything a proposal can be asked with', () => {
|
||||
expect(hikeProposeRequestSchema.safeParse({ waypoints: two }).success).toBe(true);
|
||||
it('TOUR-HIKE-SCHEMA-001: takes a name, two points and everything a proposal can be asked with', () => {
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'Herzogstand', waypoints: two }).success).toBe(true);
|
||||
const full = {
|
||||
name: 'Herzogstand',
|
||||
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
|
||||
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit', poi_category: 'peak' }],
|
||||
max_hiking_difficulty: 4,
|
||||
hiking_preferences: { avoid_steps: true },
|
||||
round_trip: true,
|
||||
variant_label: 'by the lake',
|
||||
day_id: 7,
|
||||
date: '2027-07-10',
|
||||
start_time: '08:30',
|
||||
along: false,
|
||||
include_geometry: true,
|
||||
};
|
||||
expect(hikeProposeRequestSchema.parse(full)).toEqual(full);
|
||||
expect(hikeProposeRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
|
||||
@@ -69,15 +107,19 @@ describe('a hike proposed over MCP', () => {
|
||||
it('TOUR-HIKE-SCHEMA-002: refuses one point, too many, a long label, a date or a time it cannot read, and a key it does not know', () => {
|
||||
const many = Array.from({ length: HIKE_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
|
||||
for (const request of [
|
||||
{ waypoints: [point(0)] },
|
||||
{ waypoints: many },
|
||||
{ waypoints: two, variant_label: 'x'.repeat(41) },
|
||||
{ waypoints: two, variant_label: ' ' },
|
||||
{ waypoints: two, date: '10.07.2027' },
|
||||
{ waypoints: two, start_time: '8:30' },
|
||||
{ waypoints: two, start_time: '24:00' },
|
||||
{ waypoints: two, max_hiking_difficulty: 7 },
|
||||
{ waypoints: two, tripId: 1 },
|
||||
{ waypoints: two },
|
||||
{ name: 'X', waypoints: [point(0)] },
|
||||
{ name: 'X', waypoints: many },
|
||||
{ name: 'X', waypoints: two, variant_label: 'x'.repeat(41) },
|
||||
{ name: 'X', waypoints: two, variant_label: ' ' },
|
||||
{ name: 'X', waypoints: two, date: '10.07.2027' },
|
||||
{ name: 'X', waypoints: two, start_time: '8:30' },
|
||||
{ name: 'X', waypoints: two, start_time: '24:00' },
|
||||
{ name: 'X', waypoints: two, max_hiking_difficulty: 7 },
|
||||
{ name: 'X', waypoints: two, day_id: 0 },
|
||||
{ name: 'X', waypoints: two, include_geometry: 'yes' },
|
||||
{ name: 'X', waypoints: [point(0), { ...point(1), poi_category: 'volcano' }] },
|
||||
{ name: 'X', waypoints: two, tripId: 1 },
|
||||
]) {
|
||||
expect(hikeProposeRequestSchema.safeParse(request).success, JSON.stringify(request).slice(0, 80)).toBe(false);
|
||||
}
|
||||
@@ -97,17 +139,29 @@ describe('a hike proposed over MCP', () => {
|
||||
heights_known: false,
|
||||
min_ele_m: null,
|
||||
max_ele_m: null,
|
||||
line: [],
|
||||
profile: [],
|
||||
line_geometry: undefined,
|
||||
profile_geometry: undefined,
|
||||
sac: null,
|
||||
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null })),
|
||||
high_point: null,
|
||||
along: null,
|
||||
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null, snapped_m: null, minutes: null })),
|
||||
schedule: null,
|
||||
warnings: [],
|
||||
};
|
||||
expect(hikeProposalSchema.safeParse(unrouted).success).toBe(true);
|
||||
const { line_geometry: _line, profile_geometry: _profile, ...withoutGeometry } = proposal;
|
||||
expect(hikeProposalSchema.safeParse(withoutGeometry).success).toBe(true);
|
||||
});
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-004: keeps a proposal within what a card draws', () => {
|
||||
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, line: long }).success).toBe(false);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: long }).success).toBe(false);
|
||||
const crowded = Array.from({ length: HIKE_ALONG_MAX + 1 }, () => proposal.along[0]);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, along: crowded }).success).toBe(false);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, warnings: [{ kind: 'snow', at: null }] }).success).toBe(false);
|
||||
expect(
|
||||
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], arrival: '8:30' }] }).success,
|
||||
).toBe(false);
|
||||
const segments = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
|
||||
from_m: index,
|
||||
to_m: index + 1,
|
||||
@@ -119,7 +173,7 @@ describe('a hike proposed over MCP', () => {
|
||||
false,
|
||||
);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, duration_basis: 'guess' }).success).toBe(false);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, line: [[47.4, 191]] }).success).toBe(false);
|
||||
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: [[47.4, 191]] }).success).toBe(false);
|
||||
expect(
|
||||
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], lat: -91 }] }).success,
|
||||
).toBe(false);
|
||||
@@ -146,6 +200,8 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
|
||||
hiking_preferences: { avoid_climbs: true },
|
||||
round_trip: true,
|
||||
planned_duration_minutes: 360,
|
||||
day_id: 3,
|
||||
start_time: '08:00',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(hikeCreateRequestSchema.safeParse({ waypoints: two }).success).toBe(false);
|
||||
@@ -154,6 +210,7 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
|
||||
hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, planned_duration_minutes: 1441 }).success,
|
||||
).toBe(false);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, variant_label: 'a' }).success).toBe(false);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, start_time: '25:00' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-007: changes a hike only against the revision it was read at', () => {
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
hikeSacSegmentSchema,
|
||||
hikeTrailGradesSchema,
|
||||
} from './hike-plan.schema';
|
||||
import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hike-schedule';
|
||||
import { includeGeometryFlagSchema } from './tour-display.schema';
|
||||
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
|
||||
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
|
||||
import {
|
||||
hikingPreferencesSchema,
|
||||
plannedTourDurationMinutesSchema,
|
||||
@@ -36,7 +39,18 @@ export const HIKE_MAX_WAYPOINTS = 25;
|
||||
/** The longest label a variant is given, short enough for a chip that compares two ways. */
|
||||
export const HIKE_VARIANT_LABEL_MAX = 40;
|
||||
/** The most graded stretches a proposal shows; more are joined, the harder grade kept. */
|
||||
export const HIKE_PROPOSAL_SAC_SEGMENTS = 200;
|
||||
export const HIKE_PROPOSAL_SAC_SEGMENTS = 80;
|
||||
/** The kinds of places a proposal looks for along its way. */
|
||||
export const HIKE_ALONG_CATEGORIES = [
|
||||
'peak',
|
||||
'shelter',
|
||||
'drinking_water',
|
||||
'viewpoint',
|
||||
] as const satisfies readonly TourPoiCategory[];
|
||||
/** How far from the way a place along it may lie, in metres. */
|
||||
export const HIKE_ALONG_CORRIDOR_M = 250;
|
||||
/** The most places along the way a proposal names, the nearest to it. */
|
||||
export const HIKE_ALONG_MAX = 12;
|
||||
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
|
||||
export const TOUR_GPX_TEXT_MAX = 1_000_000;
|
||||
|
||||
@@ -62,11 +76,20 @@ const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
|
||||
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
|
||||
);
|
||||
|
||||
const hikeDateSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.describe(
|
||||
'The day the hike is planned for, YYYY-MM-DD, where it is on no day of the trip; day_id takes its own date',
|
||||
);
|
||||
|
||||
const hikeDayIdSchema = idSchema.describe('The day of the trip the hike is planned on, as list_days gives it');
|
||||
|
||||
const hikeStartTimeSchema = clockTimeSchema.describe('When the hike starts, HH:MM');
|
||||
|
||||
export const hikeProposeRequestSchema = z
|
||||
.object({
|
||||
name: tourNameSchema
|
||||
.optional()
|
||||
.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
|
||||
name: tourNameSchema.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
|
||||
waypoints: hikeWaypointsSchema,
|
||||
max_hiking_difficulty: hikeLimitSchema.optional(),
|
||||
hiking_preferences: hikePreferencesSchema.optional(),
|
||||
@@ -80,12 +103,16 @@ export const hikeProposeRequestSchema = z
|
||||
.describe(
|
||||
'A short name for this way when several are compared, such as "short and steep" or "by the lake"; given back as it is',
|
||||
),
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
day_id: hikeDayIdSchema.optional(),
|
||||
date: hikeDateSchema.optional(),
|
||||
start_time: hikeStartTimeSchema.optional(),
|
||||
along: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('The day the hike is planned for, YYYY-MM-DD; given back as it is'),
|
||||
start_time: clockTimeSchema.optional().describe('When the hike starts, HH:MM; given back as it is'),
|
||||
.describe(
|
||||
'Looks for summits, huts, water and viewpoints along the way; true when left out, false when comparing ways',
|
||||
),
|
||||
include_geometry: includeGeometryFlagSchema,
|
||||
})
|
||||
.strict();
|
||||
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
|
||||
@@ -114,14 +141,53 @@ export const hikeProposalSacSchema = hikeTrailGradesSchema.extend({
|
||||
});
|
||||
export type HikeProposalSac = z.infer<typeof hikeProposalSacSchema>;
|
||||
|
||||
/** The highest point of a way with heights, and where along it it lies. */
|
||||
export const hikeHighPointSchema = z.object({
|
||||
lat,
|
||||
lng,
|
||||
ele_m: z.number().int(),
|
||||
at_m: z.number().int().nonnegative(),
|
||||
});
|
||||
export type HikeHighPoint = z.infer<typeof hikeHighPointSchema>;
|
||||
|
||||
/** A place along a proposed way: what it is, where, how far along the way and how far off it, in metres. */
|
||||
export const hikeAlongPlaceSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
category: tourPoiCategorySchema,
|
||||
lat,
|
||||
lng,
|
||||
at_m: z.number().int().nonnegative(),
|
||||
off_m: z.number().int().nonnegative(),
|
||||
});
|
||||
export type HikeAlongPlace = z.infer<typeof hikeAlongPlaceSchema>;
|
||||
|
||||
/** A point a hike was asked through, as a proposal shows it. */
|
||||
export const hikeProposalWaypointSchema = z.object({
|
||||
lat,
|
||||
lng,
|
||||
name: z.string().nullable(),
|
||||
poi_category: tourPoiCategorySchema.nullable(),
|
||||
/** Where it lies along the way; null where no way was planned. */
|
||||
at_m: z.number().int().nonnegative().nullable(),
|
||||
/** How far the router moved it onto a way, in metres; null where no way was planned. */
|
||||
snapped_m: z.number().int().nonnegative().nullable(),
|
||||
/** The walking minutes from the start to it; null where no way was planned. */
|
||||
minutes: z.number().int().nonnegative().nullable(),
|
||||
/** When it is reached, HH:MM; null without a start. */
|
||||
arrival: clockTimeSchema.nullable(),
|
||||
});
|
||||
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
|
||||
|
||||
/**
|
||||
* What propose_hike answers, and what a chat card for the hike is drawn
|
||||
* from: the way, its measures, its height profile and grades, the points it
|
||||
* was asked through with where each lies along the way, and what the request
|
||||
* named for the schedule, given back as it came.
|
||||
* from: the way, its measures, its grades and highest point, the points it
|
||||
* was asked through with where and when each is reached, the places along it,
|
||||
* the day's schedule and what to look out for, and the request's own words
|
||||
* given back. The line and the height profile come only when asked for
|
||||
* (include_geometry), under keys a model never reads.
|
||||
*/
|
||||
export const hikeProposalSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
name: z.string(),
|
||||
variant_label: z.string().nullable(),
|
||||
route: hikeRouteOutcomeSchema,
|
||||
/** Where a point lies further from any way than a tour allows: which one, and how far it was off. */
|
||||
@@ -138,25 +204,25 @@ export const hikeProposalSchema = z.object({
|
||||
heights_known: z.boolean(),
|
||||
min_ele_m: z.number().int().nullable(),
|
||||
max_ele_m: z.number().int().nullable(),
|
||||
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; empty where none was planned. */
|
||||
line: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS),
|
||||
/** The height profile thinned for display, `[metres along the way, height in metres]`. */
|
||||
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
|
||||
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; only with include_geometry. */
|
||||
line_geometry: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS).optional(),
|
||||
/** The height profile thinned for display, `[metres along the way, height]`; only with include_geometry. */
|
||||
profile_geometry: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES).optional(),
|
||||
sac: hikeProposalSacSchema.nullable(),
|
||||
/** The points in the order walked, a round trip's way back included; at_m null where no way was planned. */
|
||||
waypoints: z.array(
|
||||
z.object({
|
||||
lat,
|
||||
lng,
|
||||
name: z.string().nullable(),
|
||||
at_m: z.number().int().nonnegative().nullable(),
|
||||
}),
|
||||
),
|
||||
high_point: hikeHighPointSchema.nullable(),
|
||||
/** The points in the order walked, a round trip's way back included. */
|
||||
waypoints: z.array(hikeProposalWaypointSchema),
|
||||
/** Summits, huts, water and viewpoints along the way, in the order passed; null where not looked for or not found. */
|
||||
along: z.array(hikeAlongPlaceSchema).max(HIKE_ALONG_MAX).nullable(),
|
||||
max_hiking_difficulty: tourMaxHikingDifficultySchema,
|
||||
hiking_preferences: hikingPreferencesSchema.nullable(),
|
||||
round_trip: z.boolean(),
|
||||
day_id: idSchema.nullable(),
|
||||
date: z.string().nullable(),
|
||||
start_time: z.string().nullable(),
|
||||
/** The day's schedule, sun and weather; null without a date. */
|
||||
schedule: hikeScheduleSchema.nullable(),
|
||||
warnings: z.array(hikeWarningSchema).max(HIKE_WARNING_KINDS.length),
|
||||
});
|
||||
export type HikeProposal = z.infer<typeof hikeProposalSchema>;
|
||||
|
||||
@@ -169,6 +235,10 @@ export const hikeCreateRequestSchema = z
|
||||
hiking_preferences: hikePreferencesSchema.optional(),
|
||||
round_trip: roundTripSchema.optional(),
|
||||
planned_duration_minutes: plannedMinutesSchema.optional(),
|
||||
day_id: hikeDayIdSchema.optional().describe('The day of the trip the hike is put on, as list_days gives it'),
|
||||
start_time: hikeStartTimeSchema
|
||||
.optional()
|
||||
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
|
||||
})
|
||||
.strict();
|
||||
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
|
||||
|
||||
@@ -37,6 +37,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
|
||||
theme_places_max: 0,
|
||||
corridor_m: 500,
|
||||
lang: 'de',
|
||||
include_geometry: true,
|
||||
};
|
||||
expect(cityWalkBriefingRequestSchema.parse(full)).toEqual(full);
|
||||
});
|
||||
@@ -64,6 +65,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
|
||||
},
|
||||
{ ...briefing, theme_places_max: CITY_WALK_BRIEFING_LIMITS.themePlaces + 1 },
|
||||
{ ...briefing, corridor_m: 5000 },
|
||||
{ ...briefing, include_geometry: 'yes' },
|
||||
];
|
||||
expect(refused.map((body) => cityWalkBriefingRequestSchema.safeParse(body).success)).toEqual(
|
||||
refused.map(() => false),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { clockTimeSchema } from '../place/place-hours';
|
||||
import { includeGeometryFlagSchema } from './tour-display.schema';
|
||||
import { tourPoiSearchRequestSchema, walkThemeSchema } from './tour-poi-search.schema';
|
||||
import { TOUR_STATION_NAME_MAX, tourDescriptionSchema, tourNameSchema, tourWaypointSchema } from './tours.schema';
|
||||
|
||||
@@ -103,6 +104,7 @@ export const cityWalkBriefingRequestSchema = z
|
||||
.optional()
|
||||
.describe('How far from the start, the wishes and the appointments to search, in metres; 1000 when left out'),
|
||||
lang: tourPoiSearchRequestSchema.shape.lang,
|
||||
include_geometry: includeGeometryFlagSchema,
|
||||
})
|
||||
.strict();
|
||||
export type CityWalkBriefingRequest = z.infer<typeof cityWalkBriefingRequestSchema>;
|
||||
|
||||
Reference in New Issue
Block a user